2 Commits

Author SHA1 Message Date
9459196804 all in docker 2025-09-01 12:24:37 +03:00
ce228d9756 work 2025-09-01 12:08:16 +03:00
17 changed files with 1259 additions and 379 deletions

90
.gitignore vendored Normal file
View File

@@ -0,0 +1,90 @@
data/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# VS Code
.vscode/
# PyCharm
.idea/
# Local envs
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# MacOS
.DS_Store
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# MinIO test data
minio_data/
minio_test/
minio/
# Logs
*.log
# Streamlit cache
.streamlit/

20
python_parser/Dockerfile_ Normal file
View File

@@ -0,0 +1,20 @@
FROM repo-dev.predix.rosneft.ru/python:3.11-slim
WORKDIR /app
# RUN pip install kafka-python==2.0.2
# RUN pip freeze > /app/requirements.txt
# ADD . /app
COPY requirements.txt .
RUN mkdir -p vendor
RUN pip download -r /app/requirements.txt --no-binary=:none: -d /app/vendor
# ADD . /app
# ENV KAFKA_BROKER=10.234.160.10:9093,10.234.160.10:9094,10.234.160.10:9095
# ENV KAFKA_UPDATE_ALGORITHM_RULES_TOPIC=algorithm-rule-update
# ENV KAFKA_CLIENT_USERNAME=cf-service
# CMD ["python", "/app/run_dev.py"]

View File

@@ -4,7 +4,19 @@ API для парсинга Excel отчетов нефтеперерабаты
## 🚀 Быстрый запуск
### **Вариант 1: Только MinIO в Docker + FastAPI локально**
### **Вариант 1: Все сервисы в Docker (рекомендуется)**
```bash
# Запуск всех сервисов: MinIO + FastAPI + Streamlit
docker-compose up -d
# Доступ:
# - MinIO Console: http://localhost:9001
# - FastAPI: http://localhost:8000
# - Streamlit: http://localhost:8501
# - API Docs: http://localhost:8000/docs
```
### **Вариант 2: Только MinIO в Docker + FastAPI локально**
```bash
# Запуск MinIO в Docker
docker-compose up -d minio
@@ -13,16 +25,8 @@ docker-compose up -d minio
python run_dev.py
# В отдельном терминале запуск Streamlit
python run_streamlit.py
```
### **Вариант 2: MinIO + FastAPI в Docker + Streamlit локально**
```bash
# Запуск MinIO и FastAPI в Docker
docker-compose up -d
# В отдельном терминале запуск Streamlit
python run_streamlit.py
cd streamlit_app
streamlit run app.py
```
### **Вариант 3: Только MinIO в Docker**
@@ -37,13 +41,6 @@ docker-compose up -d minio
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
## 🔧 Диагностика
Для проверки состояния всех сервисов:
```bash
python check_services.py
```
## 🛑 Остановка
### Остановка Docker сервисов:
@@ -55,9 +52,9 @@ docker-compose down
docker-compose stop minio
```
### Остановка Streamlit:
### Остановка локальных сервисов:
```bash
# Нажмите Ctrl+C в терминале с Streamlit
# Нажмите Ctrl+C в терминале с FastAPI/Streamlit
```
## 📁 Структура проекта
@@ -74,124 +71,73 @@ python_parser/
├── adapters/ # Адаптеры для внешних систем
│ ├── storage.py # MinIO адаптер
│ └── parsers/ # Парсеры Excel файлов
├── streamlit_app/ # Изолированный Streamlit пакет
│ ├── app.py # Основное Streamlit приложение
│ ├── requirements.txt # Зависимости Streamlit
│ ├── Dockerfile # Docker образ для Streamlit
│ └── .streamlit/ # Конфигурация Streamlit
├── data/ # Тестовые данные
├── docker-compose.yml # Docker Compose конфигурация
├── Dockerfile # Docker образ для FastAPI
── run_dev.py # Запуск FastAPI локально
├── run_streamlit.py # Запуск Streamlit
└── check_services.py # Диагностика сервисов
── run_dev.py # Запуск FastAPI локально
```
## 🔍 Доступные эндпоинты
- **GET /** - Информация об API
- **GET /docs** - Swagger документация
- **GET /parsers** - Список доступных парсеров
- **GET /parsers/{parser_name}/getters** - Информация о геттерах парсера
- **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
- **POST /svodka_ca/upload-zip** - Загрузка сводок ЦА
- **POST /svodka_ca/upload** - Загрузка сводок ЦА
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
- **GET /svodka_pm/data** - Получение данных сводок ПМ
- **GET /svodka_ca/data** - Получение данных сводок ЦА
- **GET /monitoring_fuel/data** - Получение данных мониторинга топлива
- **POST /svodka_pm/get_data** - Получение данных сводок ПМ
- **POST /svodka_ca/get_data** - Получение данных сводок ЦА
- **POST /monitoring_fuel/get_data** - Получение данных мониторинга топлива
## 📊 Поддерживаемые типы отчетов
1. **svodka_pm** - Сводки по переработке нефти (ПМ)
- Геттеры: `single_og`, `total_ogs`
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
- Геттеры: `get_data`
3. **monitoring_fuel** - Мониторинг топлива
- Геттеры: `total_by_columns`, `month_by_code`
## 🐳 Docker команды
## 🏗️ Архитектура
### Сборка и запуск:
Проект использует **Hexagonal Architecture (Ports and Adapters)**:
- **Порты (Ports)**: Интерфейсы для бизнес-логики
- **Адаптеры (Adapters)**: Реализации для внешних систем
- **Сервисы (Services)**: Бизнес-логика приложения
### Система геттеров парсеров
Каждый парсер может иметь несколько методов получения данных (геттеров):
- Регистрация геттеров в словаре с метаданными
- Валидация параметров для каждого геттера
- Единый интерфейс `get_value(getter_name, params)`
## 🐳 Docker
### Сборка образов:
```bash
# Все сервисы
docker-compose up -d --build
# FastAPI
docker build -t nin-fastapi .
# Streamlit
docker build -t nin-streamlit ./streamlit_app
```
### Запуск отдельных сервисов:
```bash
# Только MinIO
docker-compose up -d minio
# Только FastAPI (требует MinIO)
docker-compose up -d fastapi
```
# MinIO + FastAPI
docker-compose up -d minio fastapi
### Просмотр логов:
```bash
# Все сервисы
docker-compose logs
# Конкретный сервис
docker-compose logs fastapi
docker-compose logs minio
docker-compose up -d
```
### Остановка:
```bash
docker-compose down
```
## 🔧 Устранение неполадок
### Проблема: "Streamlit не может подключиться к FastAPI"
**Симптомы:**
- Streamlit открывается, но показывает "API недоступен по адресу http://localhost:8000"
- FastAPI не отвечает на порту 8000
**Решения:**
1. **Проверьте порты:**
```bash
# Windows
netstat -an | findstr :8000
# Linux/Mac
netstat -an | grep :8000
```
2. **Перезапустите FastAPI:**
```bash
# Остановите текущий процесс (Ctrl+C)
python run_dev.py
```
3. **Проверьте логи Docker:**
```bash
docker-compose logs fastapi
```
### Проблема: "MinIO недоступен"
**Решения:**
1. Запустите Docker Desktop
2. Проверьте статус контейнера: `docker ps`
3. Перезапустите MinIO: `docker-compose restart minio`
### Проблема: "Порт уже занят"
**Решения:**
1. Найдите процесс: `netstat -ano | findstr :8000`
2. Остановите процесс: `taskkill /PID <номер_процесса>`
3. Или используйте другой порт в конфигурации
## 🚀 Разработка
### Добавление нового парсера:
1. Создайте файл в `adapters/parsers/`
2. Реализуйте интерфейс `ParserPort`
3. Добавьте в `core/services.py`
4. Создайте схемы в `app/schemas/`
5. Добавьте эндпоинты в `app/main.py`
### Тестирование:
```bash
# Запуск тестов
pytest
# Запуск с покрытием
pytest --cov=.
```
## 📝 Лицензия
Проект разработан для внутреннего использования НИН.

View File

@@ -1,9 +1,9 @@
import pandas as pd
import re
from typing import Dict
import zipfile
from typing import Dict, Tuple
from core.ports import ParserPort
from adapters.pconfig import data_to_json, get_object_by_name
from adapters.pconfig import data_to_json
class MonitoringFuelParser(ParserPort):
@@ -11,6 +11,82 @@ class MonitoringFuelParser(ParserPort):
name = "Мониторинг топлива"
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
self.register_getter(
name="total_by_columns",
method=self._get_total_by_columns,
required_params=["columns"],
optional_params=[],
description="Агрегация данных по колонкам"
)
self.register_getter(
name="month_by_code",
method=self._get_month_by_code,
required_params=["month"],
optional_params=[],
description="Получение данных за конкретный месяц"
)
def _get_total_by_columns(self, params: dict):
"""Агрегация по колонкам (обертка для совместимости)"""
columns = params["columns"]
if not columns:
raise ValueError("Отсутствуют идентификаторы столбцов")
# TODO: Переделать под новую архитектуру
df_means, _ = self.aggregate_by_columns(self.df, columns)
return df_means.to_dict(orient='index')
def _get_month_by_code(self, params: dict):
"""Получение данных за месяц (обертка для совместимости)"""
month = params["month"]
if not month:
raise ValueError("Отсутствует идентификатор месяца")
# TODO: Переделать под новую архитектуру
df_month = self.get_month(self.df, month)
return df_month.to_dict(orient='index')
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame"""
# Сохраняем DataFrame для использования в геттерах
self.df = self.parse_monitoring_fuel_files(file_path, params)
return self.df
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
"""Парсинг ZIP архива с файлами мониторинга топлива"""
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
file_list = zip_ref.namelist()
for month in range(1, 13):
mm = f"{month:02d}"
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
candidates = [f for f in file_list if file_temp in f]
if len(candidates) == 1:
file = candidates[0]
print(f'Загрузка {file}')
with zip_ref.open(file) as excel_file:
try:
df = self.parse_single(excel_file, 'Мониторинг потребления')
df_monitorings[mm] = df
print(f"✅ Данные за месяц {mm} загружены")
except Exception as e:
print(f"Ошибка при загрузке файла {file_temp}: {e}")
else:
print(f"⚠️ Файл не найден: {file_temp}")
return df_monitorings
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
"""Определение индекса заголовка в Excel по ключевому слову"""
# Читаем первые max_rows строк без заголовков
@@ -64,46 +140,15 @@ class MonitoringFuelParser(ParserPort):
# Проверяем, что колонка 'name' существует
if 'name' in df_full.columns:
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
df_full['id'] = df_full['name'].apply(get_object_by_name)
# df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
pass # Placeholder for new_code
# Устанавливаем id как индекс
df_full.set_index('id', inplace=True)
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
return df_full
def parse(self, file_path: str, params: dict) -> dict:
import zipfile
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
with zipfile.ZipFile(file_path, 'r') as zip_ref:
file_list = zip_ref.namelist()
for month in range(1, 13):
mm = f"{month:02d}"
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
candidates = [f for f in file_list if file_temp in f]
if len(candidates) == 1:
file = candidates[0]
print(f'Загрузка {file}')
with zip_ref.open(file) as excel_file:
try:
df = self.parse_single(excel_file, 'Мониторинг потребления')
df_monitorings[mm] = df
print(f"✅ Данные за месяц {mm} загружены")
except Exception as e:
print(f"Ошибка при загрузке файла {file_temp}: {e}")
else:
print(f"⚠️ Файл не найден: {file_temp}")
return df_monitorings
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns):
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
means = {} # Для хранения средних
@@ -185,22 +230,3 @@ class MonitoringFuelParser(ParserPort):
total.name = 'mean'
return total, df_combined
def get_value(self, df, params):
mode = params.get("mode", "total")
columns = params.get("columns", None)
month = params.get("month", None)
data = None
if mode == "total":
if not columns:
raise ValueError("Отсутствуют идентификаторы столбцов")
df_means, _ = self.aggregate_by_columns(df, columns)
data = df_means.to_dict(orient='index')
elif mode == "month":
if not month:
raise ValueError("Отсутствуют идентификатор месяца")
df_month = self.get_month(df, month)
data = df_month.to_dict(orient='index')
json_result = data_to_json(data)
return json_result

View File

@@ -6,85 +6,44 @@ from adapters.pconfig import get_og_by_name
class SvodkaCAParser(ParserPort):
"""Парсер для сводки СА"""
"""Парсер для сводок СА"""
name = "Сводка СА"
name = "Сводки СА"
def extract_all_tables(self, file_path, sheet_name=0):
"""Извлекает все таблицы из Excel файла"""
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
df_filled = df.fillna('')
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
self.register_getter(
name="get_data",
method=self._get_data_wrapper,
required_params=["modes", "tables"],
optional_params=[],
description="Получение данных по режимам и таблицам"
)
non_empty_rows = ~(df_clean.eq('').all(axis=1))
non_empty_cols = ~(df_clean.eq('').all(axis=0))
def _get_data_wrapper(self, params: dict):
"""Обертка для получения данных (для совместимости)"""
modes = params["modes"]
tables = params["tables"]
row_indices = non_empty_rows[non_empty_rows].index.tolist()
col_indices = non_empty_cols[non_empty_cols].index.tolist()
if not isinstance(modes, list):
raise ValueError("Поле 'modes' должно быть списком")
if not isinstance(tables, list):
raise ValueError("Поле 'tables' должно быть списком")
if not row_indices or not col_indices:
return []
# TODO: Переделать под новую архитектуру
data_dict = {}
for mode in modes:
data_dict[mode] = self.get_data(self.df, mode, tables)
return self.data_dict_to_json(data_dict)
row_blocks = self._get_contiguous_blocks(row_indices)
col_blocks = self._get_contiguous_blocks(col_indices)
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame"""
# Сохраняем DataFrame для использования в геттерах
self.df = self.parse_svodka_ca(file_path, params)
return self.df
tables = []
for r_start, r_end in row_blocks:
for c_start, c_end in col_blocks:
block = df.iloc[r_start:r_end + 1, c_start:c_end + 1]
if block.empty or block.fillna('').astype(str).replace(r'^\s*$', '', regex=True).eq('').all().all():
continue
if self._is_header_row(block.iloc[0]):
block.columns = block.iloc[0]
block = block.iloc[1:].reset_index(drop=True)
else:
block = block.reset_index(drop=True)
block.columns = [f"col_{i}" for i in range(block.shape[1])]
tables.append(block)
return tables
def _get_contiguous_blocks(self, indices):
"""Группирует индексы в непрерывные блоки"""
if not indices:
return []
blocks = []
start = indices[0]
for i in range(1, len(indices)):
if indices[i] != indices[i-1] + 1:
blocks.append((start, indices[i-1]))
start = indices[i]
blocks.append((start, indices[-1]))
return blocks
def _is_header_row(self, series):
"""Определяет, похожа ли строка на заголовок"""
series_str = series.astype(str).str.strip()
non_empty = series_str[series_str != '']
if len(non_empty) == 0:
return False
def is_not_numeric(val):
try:
float(val.replace(',', '.'))
return False
except (ValueError, TypeError):
return True
not_numeric_count = non_empty.apply(is_not_numeric).sum()
return not_numeric_count / len(non_empty) > 0.6
def _get_og_by_name(self, name):
"""Функция для получения ID по имени (упрощенная версия)"""
# Упрощенная версия - возвращаем имя как есть
if not name or not isinstance(name, str):
return None
return name.strip()
def parse_sheet(self, file_path, sheet_name, inclusion_list):
"""Собственно функция парсинга отчета СА"""
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
"""Парсинг сводки СА"""
# === Извлечение и фильтрация ===
tables = self.extract_all_tables(file_path, sheet_name)
@@ -190,76 +149,185 @@ class SvodkaCAParser(ParserPort):
else:
return None
def parse(self, file_path: str, params: dict) -> dict:
"""Парсинг файла сводки СА"""
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
# Выгружаем План в df_ca_plan
inclusion_list_plan = {
"ТиП, %",
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
"в т.ч. Идентифицированные безвозвратные потери, %**",
"в т.ч. Неидентифицированные потери, тонн**",
"в т.ч. Неидентифицированные потери, %**"
}
def extract_all_tables(self, file_path, sheet_name=0):
"""Извлекает все таблицы из Excel файла"""
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
df_filled = df.fillna('')
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) # ЭТО ДАТАФРЕЙМ ПЛАНА В СВОДКЕ ЦА
print(f"\n--- Объединённый и отсортированный План: {df_ca_plan.shape} ---")
non_empty_rows = ~(df_clean.eq('').all(axis=1))
non_empty_cols = ~(df_clean.eq('').all(axis=0))
# Выгружаем Факт
inclusion_list_fact = {
"ТиП, %",
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн",
"в т.ч. Идентифицированные безвозвратные потери, %",
"в т.ч. Неидентифицированные потери, тонн",
"в т.ч. Неидентифицированные потери, %"
}
row_indices = non_empty_rows[non_empty_rows].index.tolist()
col_indices = non_empty_cols[non_empty_cols].index.tolist()
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) # ЭТО ДАТАФРЕЙМ ФАКТА В СВОДКЕ ЦА
print(f"\n--- Объединённый и отсортированный Факт: {df_ca_fact.shape} ---")
if not row_indices or not col_indices:
return []
# Выгружаем План в df_ca_normativ
inclusion_list_normativ = {
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
"в т.ч. Идентифицированные безвозвратные потери, %**",
"в т.ч. Неидентифицированные потери, тонн**",
"в т.ч. Неидентифицированные потери, %**"
}
row_blocks = self._get_contiguous_blocks(row_indices)
col_blocks = self._get_contiguous_blocks(col_indices)
# ЭТО ДАТАФРЕЙМ НОРМАТИВА В СВОДКЕ ЦА
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
tables = []
for r_start, r_end in row_blocks:
for c_start, c_end in col_blocks:
block = df.iloc[r_start:r_end + 1, c_start:c_end + 1]
if block.empty or block.fillna('').astype(str).replace(r'^\s*$', '', regex=True).eq('').all().all():
continue
print(f"\n--- Объединённый и отсортированный Норматив: {df_ca_normativ.shape} ---")
if self._is_header_row(block.iloc[0]):
block.columns = block.iloc[0]
block = block.iloc[1:].reset_index(drop=True)
else:
block = block.reset_index(drop=True)
block.columns = [f"col_{i}" for i in range(block.shape[1])]
df_dict = {
"plan": df_ca_plan,
"fact": df_ca_fact,
"normativ": df_ca_normativ
}
return df_dict
tables.append(block)
return tables
def _get_contiguous_blocks(self, indices):
"""Группирует индексы в непрерывные блоки"""
if not indices:
return []
blocks = []
start = indices[0]
for i in range(1, len(indices)):
if indices[i] != indices[i-1] + 1:
blocks.append((start, indices[i-1]))
start = indices[i]
blocks.append((start, indices[-1]))
return blocks
def _is_header_row(self, series):
"""Определяет, похожа ли строка на заголовок"""
series_str = series.astype(str).str.strip()
non_empty = series_str[series_str != '']
if len(non_empty) == 0:
return False
def is_not_numeric(val):
try:
float(val.replace(',', '.'))
return False
except (ValueError, TypeError):
return True
not_numeric_count = non_empty.apply(is_not_numeric).sum()
return not_numeric_count / len(non_empty) > 0.6
def _get_og_by_name(self, name):
"""Функция для получения ID по имени (упрощенная версия)"""
# Упрощенная версия - возвращаем имя как есть
if not name or not isinstance(name, str):
return None
return name.strip()
def parse_sheet(self, file_path: str, sheet_name: str, inclusion_list: set) -> pd.DataFrame:
"""Парсинг листа Excel"""
# === Извлечение и фильтрация ===
tables = self.extract_all_tables(file_path, sheet_name)
# Фильтруем таблицы: оставляем только те, где первая строка содержит нужные заголовки
filtered_tables = []
for table in tables:
if table.empty:
continue
first_row_values = table.iloc[0].astype(str).str.strip().tolist()
if any(val in inclusion_list for val in first_row_values):
filtered_tables.append(table)
tables = filtered_tables
# === Итоговый список таблиц датафреймов ===
result_list = []
for table in tables:
if table.empty:
continue
# Получаем первую строку (до удаления)
first_row_values = table.iloc[0].astype(str).str.strip().tolist()
# Находим, какой элемент из inclusion_list присутствует
matched_key = None
for val in first_row_values:
if val in inclusion_list:
matched_key = val
break # берём первый совпадающий заголовок
if matched_key is None:
continue # на всякий случай (хотя уже отфильтровано)
# Удаляем первую строку (заголовок) и сбрасываем индекс
df_cleaned = table.iloc[1:].copy().reset_index(drop=True)
# Пропускаем, если таблица пустая
if df_cleaned.empty:
continue
# Первая строка становится заголовком
new_header = df_cleaned.iloc[0] # извлекаем первую строку как потенциальные названия столбцов
# Преобразуем заголовок: только первый столбец может быть заменён на "name"
cleaned_header = []
# Обрабатываем первый столбец отдельно
first_item = new_header.iloc[0] if isinstance(new_header, pd.Series) else new_header[0]
first_item_str = str(first_item).strip() if pd.notna(first_item) else ""
if first_item_str == "" or first_item_str == "nan":
cleaned_header.append("name")
else:
cleaned_header.append(first_item_str)
# Остальные столбцы добавляем без изменений (или с минимальной очисткой)
for item in new_header[1:]:
# Опционально: приводим к строке и убираем лишние пробелы, но не заменяем на "name"
item_str = str(item).strip() if pd.notna(item) else ""
cleaned_header.append(item_str)
# Применяем очищенные названия столбцов
df_cleaned = df_cleaned[1:] # удаляем строку с заголовком
df_cleaned.columns = cleaned_header
df_cleaned = df_cleaned.reset_index(drop=True)
if matched_key.endswith('**'):
cleaned_key = matched_key[:-2] # удаляем последние **
else:
cleaned_key = matched_key
# Добавляем новую колонку с именем параметра
df_cleaned["table"] = cleaned_key
# Проверяем, что колонка 'name' существует
if 'name' not in df_cleaned.columns:
print(
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
continue # или обработать по-другому
else:
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
df_cleaned['id'] = df_cleaned['name'].apply(get_og_by_name)
# Удаляем строки, где id — None, NaN или пустой
df_cleaned = df_cleaned.dropna(subset=['id']) # dropna удаляет NaN
# Дополнительно: удаляем None (если не поймал dropna)
df_cleaned = df_cleaned[df_cleaned['id'].notna() & (df_cleaned['id'].astype(str) != 'None')]
# Добавляем в словарь
result_list.append(df_cleaned)
# === Объединение и сортировка по id (индекс) и table ===
if result_list:
combined_df = pd.concat(result_list, axis=0)
# Сортируем по индексу (id) и по столбцу 'table'
combined_df = combined_df.sort_values(by=['id', 'table'], axis=0)
# Устанавливаем id как индекс
# combined_df.set_index('id', inplace=True)
return combined_df
else:
return None
def data_dict_to_json(self, data_dict):
''' Служебная функция для парсинга словаря в json. '''
@@ -308,17 +376,3 @@ class SvodkaCAParser(ParserPort):
filtered_df = df[df['table'].isin(table_values)].copy()
result_dict = {key: group for key, group in filtered_df.groupby('table')}
return result_dict
def get_value(self, df: pd.DataFrame, params: dict):
modes = params.get("modes")
tables = params.get("tables")
if not isinstance(modes, list):
raise ValueError("Поле 'modes' должно быть списком")
if not isinstance(tables, list):
raise ValueError("Поле 'tables' должно быть списком")
# Собираем данные
data_dict = {}
for mode in modes:
data_dict[mode] = self.get_data(df, mode, tables)
return self.data_dict_to_json(data_dict)

View File

@@ -9,6 +9,60 @@ class SvodkaPMParser(ParserPort):
name = "Сводки ПМ"
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
self.register_getter(
name="single_og",
method=self._get_single_og,
required_params=["id", "codes", "columns"],
optional_params=["search"],
description="Получение данных по одному ОГ"
)
self.register_getter(
name="total_ogs",
method=self._get_total_ogs,
required_params=["codes", "columns"],
optional_params=["search"],
description="Получение данных по всем ОГ"
)
def _get_single_og(self, params: dict):
"""Получение данных по одному ОГ (обертка для совместимости)"""
og_id = params["id"]
codes = params["codes"]
columns = params["columns"]
search = params.get("search")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику
# TODO: Переделать под новую архитектуру
return self.get_svodka_og(self.df, og_id, codes, columns, search)
def _get_total_ogs(self, params: dict):
"""Получение данных по всем ОГ (обертка для совместимости)"""
codes = params["codes"]
columns = params["columns"]
search = params.get("search")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
# TODO: Переделать под новую архитектуру
return self.get_svodka_total(self.df, codes, columns, search)
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame"""
# Сохраняем DataFrame для использования в геттерах
self.df = self.parse_svodka_pm_files(file_path, params)
return self.df
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
"""Определения индекса заголовка в excel по ключевому слову"""
# Читаем первые max_rows строк без заголовков
@@ -99,25 +153,25 @@ class SvodkaPMParser(ParserPort):
# Проверяем, является ли колонка пустой/некорректной
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
# Проверяем, начинается ли на "Итого"
if col_str.startswith('Итого'):
current_name = 'Итого'
last_good_name = current_name # обновляем last_good_name
new_columns.append(current_name)
elif is_empty_or_unnamed:
# Используем последнее хорошее имя
new_columns.append(last_good_name)
if is_empty_or_unnamed:
# Если это пустая колонка, используем последнее хорошее имя
if last_good_name:
new_columns.append(last_good_name)
else:
# Если нет хорошего имени, пропускаем
continue
else:
# Имя, полученное из exel
# Это хорошая колонка
last_good_name = col_str
new_columns.append(col_str)
# Применяем новые заголовки
df_final.columns = new_columns
print(f"Окончательное количество столбцов: {len(df_final.columns)}")
return df_final
def parse(self, file_path: str, params: dict) -> dict:
def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict:
"""Парсинг ZIP архива со сводками ПМ"""
import zipfile
pm_dict = {
"facts": {},
@@ -125,7 +179,7 @@ class SvodkaPMParser(ParserPort):
}
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
with zipfile.ZipFile(file_path, 'r') as zip_ref:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
file_list = zip_ref.namelist()
for name, id in OG_IDS.items():
if id == 'BASH':
@@ -155,9 +209,9 @@ class SvodkaPMParser(ParserPort):
return pm_dict
def get_svodka_value(self, df_svodka, id, code, search_value=None):
''' Служебная функция для простой выборке по сводке '''
row_index = id
def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None):
''' Служебная функция получения значения по коду и столбцу '''
row_index = code
mask_value = df_svodka.iloc[0] == code
if search_value is None:
@@ -254,22 +308,4 @@ class SvodkaPMParser(ParserPort):
return total_result
def get_value(self, df, params):
og_id = params.get("id")
codes = params.get("codes")
columns = params.get("columns")
search = params.get("search")
mode = params.get("mode", "total")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
data = None
if mode == "single":
if not og_id:
raise ValueError("Отсутствует идентификатор ОГ")
data = self.get_svodka_og(df, og_id, codes, columns, search)
elif mode == "total":
data = self.get_svodka_total(df, codes, columns, search)
json_result = data_to_json(data)
return json_result
# Убираем старый метод get_value, так как он теперь в базовом классе

View File

@@ -96,6 +96,54 @@ async def get_available_parsers():
return {"parsers": parsers}
@app.get("/parsers/{parser_name}/getters", tags=["Общее"],
summary="Информация о геттерах парсера",
description="Возвращает информацию о доступных геттерах для указанного парсера",
responses={
200: {
"content": {
"application/json": {
"example": {
"parser": "svodka_pm",
"getters": {
"single_og": {
"required_params": ["id", "codes", "columns"],
"optional_params": ["search"],
"description": "Получение данных по одному ОГ"
},
"total_ogs": {
"required_params": ["codes", "columns"],
"optional_params": ["search"],
"description": "Получение данных по всем ОГ"
}
}
}
}
}
},
404: {
"description": "Парсер не найден"
}
})
async def get_parser_getters(parser_name: str):
"""Получение информации о геттерах парсера"""
if parser_name not in PARSERS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Парсер '{parser_name}' не найден"
)
parser_class = PARSERS[parser_name]
parser_instance = parser_class()
getters_info = parser_instance.get_available_getters()
return {
"parser": parser_name,
"getters": getters_info
}
@app.get("/server-info", tags=["Общее"],
summary="Информация о сервере",
response_model=ServerInfoResponse,)

View File

@@ -2,28 +2,93 @@
Порты (интерфейсы) для hexagonal architecture
"""
from abc import ABC, abstractmethod
from typing import Optional
from typing import Optional, Dict, List, Any, Callable
import pandas as pd
class ParserPort(ABC):
"""Интерфейс для парсеров"""
"""Интерфейс для парсеров с поддержкой множественных геттеров"""
def __init__(self):
"""Инициализация с пустым словарем геттеров"""
self.getters: Dict[str, Dict[str, Any]] = {}
self._register_default_getters()
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию - переопределяется в наследниках"""
pass
def register_getter(self, name: str, method: Callable, required_params: List[str],
optional_params: List[str] = None, description: str = ""):
"""
Регистрация нового геттера
Args:
name: Имя геттера
method: Метод для выполнения
required_params: Список обязательных параметров
optional_params: Список необязательных параметров
description: Описание геттера
"""
if optional_params is None:
optional_params = []
self.getters[name] = {
"method": method,
"required_params": required_params,
"optional_params": optional_params,
"description": description
}
def get_available_getters(self) -> Dict[str, Dict[str, Any]]:
"""Получение списка доступных геттеров с их описанием"""
return {
name: {
"required_params": info["required_params"],
"optional_params": info["optional_params"],
"description": info["description"]
}
for name, info in self.getters.items()
}
# Добавить схему
def get_value(self, getter_name: str, params: Dict[str, Any]):
"""
Получение значения через указанный геттер
Args:
getter_name: Имя геттера
params: Параметры для геттера
Returns:
Результат выполнения геттера
Raises:
ValueError: Если геттер не найден или параметры неверны
"""
if getter_name not in self.getters:
available = list(self.getters.keys())
raise ValueError(f"Геттер '{getter_name}' не найден. Доступные: {available}")
getter_info = self.getters[getter_name]
required = getter_info["required_params"]
# Проверка обязательных параметров
missing = [p for p in required if p not in params]
if missing:
raise ValueError(f"Отсутствуют обязательные параметры для геттера '{getter_name}': {missing}")
# Вызов метода геттера
try:
return getter_info["method"](params)
except Exception as e:
raise ValueError(f"Ошибка выполнения геттера '{getter_name}': {str(e)}")
@abstractmethod
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame"""
pass
@abstractmethod
def get_value(self, df: pd.DataFrame, params: dict):
"""Получение значения из DataFrame по параметрам"""
pass
# @abstractmethod
# def get_schema(self) -> dict:
# """Возвращает схему входных параметров для парсера"""
# pass
class StoragePort(ABC):
"""Интерфейс для хранилища данных"""

View File

@@ -100,8 +100,34 @@ class ReportService:
# Получаем парсер
parser = get_parser(request.report_type)
# Получаем значение
value = parser.get_value(df, request.get_params)
# Устанавливаем DataFrame в парсер для использования в геттерах
parser.df = df
# Получаем параметры запроса
get_params = request.get_params or {}
# Определяем имя геттера (по умолчанию используем первый доступный)
getter_name = get_params.pop("getter", None)
if not getter_name:
# Если геттер не указан, берем первый доступный
available_getters = list(parser.getters.keys())
if available_getters:
getter_name = available_getters[0]
print(f"⚠️ Геттер не указан, используем первый доступный: {getter_name}")
else:
return DataResult(
success=False,
message="Парсер не имеет доступных геттеров"
)
# Получаем значение через указанный геттер
try:
value = parser.get_value(getter_name, get_params)
except ValueError as e:
return DataResult(
success=False,
message=f"Ошибка параметров: {str(e)}"
)
# Формируем результат
if value is not None:

View File

@@ -28,5 +28,16 @@ services:
- minio
restart: unless-stopped
streamlit:
build: ./streamlit_app
container_name: svodka_streamlit
ports:
- "8501:8501"
environment:
- API_BASE_URL=http://fastapi:8000
depends_on:
- fastapi
restart: unless-stopped
volumes:
minio_data:

View File

@@ -1,19 +1,28 @@
#!/usr/bin/env python3
"""
Запуск Streamlit интерфейса для NIN Excel Parsers API
Запуск Streamlit интерфейса локально из изолированного пакета
"""
import subprocess
import sys
import webbrowser
import time
import os
def main():
"""Основная функция"""
print("🚀 ЗАПУСК STREAMLIT ИНТЕРФЕЙСА")
print("=" * 50)
print("🚀 ЗАПУСК STREAMLIT ИЗ ИЗОЛИРОВАННОГО ПАКЕТА")
print("=" * 60)
print("Убедитесь, что FastAPI сервер запущен на порту 8000")
print("=" * 50)
print("=" * 60)
# Проверяем, существует ли папка streamlit_app
if not os.path.exists("streamlit_app"):
print("❌ Папка streamlit_app не найдена")
print("Создайте изолированный пакет или используйте docker-compose up -d")
return
# Переходим в папку streamlit_app
os.chdir("streamlit_app")
# Проверяем, установлен ли Streamlit
try:
@@ -21,7 +30,7 @@ def main():
print(f"✅ Streamlit {streamlit.__version__} установлен")
except ImportError:
print("❌ Streamlit не установлен")
print("Установите: pip install streamlit")
print("Установите: pip install -r requirements.txt")
return
print("\n🚀 Запускаю Streamlit...")
@@ -38,7 +47,7 @@ def main():
# Запускаем Streamlit
try:
subprocess.run([
sys.executable, "-m", "streamlit", "run", "streamlit_app.py",
sys.executable, "-m", "streamlit", "run", "app.py",
"--server.port", "8501",
"--server.address", "localhost",
"--server.headless", "false",

View File

@@ -254,8 +254,8 @@ def main():
modes = st.multiselect(
"Выберите режимы",
["План", "Факт", "Норматив"],
default=["План", "Факт"],
["plan", "fact", "normativ"],
default=["plan", "fact"],
key="ca_modes"
)

View File

@@ -0,0 +1,31 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
.DS_Store
.env
.venv
venv/
ENV/
env/
.idea/
.vscode/
*.swp
*.swo
*~

View File

@@ -0,0 +1,23 @@
FROM python:3.11-slim
WORKDIR /app
# Устанавливаем системные зависимости
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Копируем файлы зависимостей
COPY requirements.txt .
# Устанавливаем Python зависимости
RUN pip install --no-cache-dir -r requirements.txt
# Копируем код приложения
COPY . .
# Открываем порт
EXPOSE 8501
# Команда запуска
CMD ["streamlit", "run", "app.py", "--server.port", "8501", "--server.address", "0.0.0.0"]

View File

@@ -0,0 +1,44 @@
# 📊 Streamlit App - NIN Excel Parsers API
Изолированное Streamlit приложение для демонстрации работы NIN Excel Parsers API.
## 🚀 Запуск
### Локально:
```bash
cd streamlit_app
pip install -r requirements.txt
streamlit run app.py
```
### В Docker:
```bash
docker build -t streamlit-app .
docker run -p 8501:8501 streamlit-app
```
## 🔧 Конфигурация
### Переменные окружения:
- `API_BASE_URL` - адрес FastAPI сервера (по умолчанию: `http://fastapi:8000`)
### Параметры Streamlit:
- Порт: 8501
- Адрес: 0.0.0.0 (для Docker)
- Режим: headless (для Docker)
## 📁 Структура
```
streamlit_app/
├── app.py # Основное приложение
├── requirements.txt # Зависимости Python
├── Dockerfile # Docker образ
├── .streamlit/ # Конфигурация Streamlit
│ └── config.toml # Настройки
└── README.md # Документация
```
## 🌐 Доступ
После запуска приложение доступно по адресу: **http://localhost:8501**

View File

@@ -0,0 +1,447 @@
import streamlit as st
import requests
import json
import pandas as pd
import io
import zipfile
from typing import Dict, Any
import os
# Конфигурация страницы
st.set_page_config(
page_title="NIN Excel Parsers API Demo",
page_icon="📊",
layout="wide",
initial_sidebar_state="expanded"
)
# Конфигурация API - используем переменную окружения или значение по умолчанию
API_BASE_URL = os.getenv("API_BASE_URL", "http://fastapi:8000")
def check_api_health():
"""Проверка доступности API"""
try:
response = requests.get(f"{API_BASE_URL}/", timeout=5)
return response.status_code == 200
except:
return False
def get_available_parsers():
"""Получение списка доступных парсеров"""
try:
response = requests.get(f"{API_BASE_URL}/parsers")
if response.status_code == 200:
return response.json()["parsers"]
return []
except:
return []
def get_parser_getters(parser_name: str):
"""Получение информации о геттерах парсера"""
try:
response = requests.get(f"{API_BASE_URL}/parsers/{parser_name}/getters")
if response.status_code == 200:
return response.json()
return {}
except:
return {}
def get_server_info():
"""Получение информации о сервере"""
try:
response = requests.get(f"{API_BASE_URL}/server-info")
if response.status_code == 200:
return response.json()
return {}
except:
return {}
def upload_file_to_api(endpoint: str, file_data: bytes, filename: str):
"""Загрузка файла на API"""
try:
files = {"zip_file": (filename, file_data, "application/zip")}
response = requests.post(f"{API_BASE_URL}{endpoint}", files=files)
return response.json(), response.status_code
except Exception as e:
return {"error": str(e)}, 500
def make_api_request(endpoint: str, data: Dict[str, Any]):
"""Выполнение API запроса"""
try:
response = requests.post(f"{API_BASE_URL}{endpoint}", json=data)
return response.json(), response.status_code
except Exception as e:
return {"error": str(e)}, 500
def main():
st.title("🚀 NIN Excel Parsers API - Демонстрация")
st.markdown("---")
# Проверка доступности API
if not check_api_health():
st.error(f"❌ API недоступен по адресу {API_BASE_URL}")
st.info("Убедитесь, что FastAPI сервер запущен")
return
st.success(f"✅ API доступен по адресу {API_BASE_URL}")
# Боковая панель с информацией
with st.sidebar:
st.header(" Информация")
# Информация о сервере
server_info = get_server_info()
if server_info:
st.subheader("Сервер")
st.write(f"PID: {server_info.get('process_id', 'N/A')}")
st.write(f"CPU ядер: {server_info.get('cpu_cores', 'N/A')}")
st.write(f"Память: {server_info.get('memory_mb', 'N/A'):.1f} MB")
# Доступные парсеры
parsers = get_available_parsers()
if parsers:
st.subheader("Доступные парсеры")
for parser in parsers:
st.write(f"{parser}")
# Основные вкладки - по одной на каждый парсер
tab1, tab2, tab3 = st.tabs([
"📊 Сводки ПМ",
"🏭 Сводки СА",
"⛽ Мониторинг топлива"
])
# Вкладка 1: Сводки ПМ - полный функционал
with tab1:
st.header("📊 Сводки ПМ - Полный функционал")
# Получаем информацию о геттерах
getters_info = get_parser_getters("svodka_pm")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_pm = st.file_uploader(
"Выберите ZIP архив со сводками ПМ",
type=['zip'],
key="pm_upload"
)
if uploaded_pm is not None:
if st.button("📤 Загрузить сводки ПМ", key="upload_pm_btn"):
with st.spinner("Загружаю файл..."):
result, status = upload_file_to_api(
"/svodka_pm/upload-zip",
uploaded_pm.read(),
uploaded_pm.name
)
if status == 200:
st.success(f"{result.get('message', 'Файл загружен')}")
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
st.markdown("---")
# Секция получения данных
st.subheader("🔍 Получение данных")
# Показываем доступные геттеры
if getters_info and "getters" in getters_info:
st.info("📋 Доступные геттеры:")
for getter_name, getter_info in getters_info["getters"].items():
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
if getter_info.get('optional_params'):
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
col1, col2 = st.columns(2)
with col1:
st.subheader("Данные по одному ОГ")
og_id = st.selectbox(
"Выберите ОГ",
["SNPZ", "KNPZ", "ANHK", "AchNPZ", "UNPZ", "UNH", "NOV",
"NovKuybNPZ", "KuybNPZ", "CyzNPZ", "TuapsNPZ", "RNPK",
"NVNPO", "KLNPZ", "PurNP", "YANOS"],
key="pm_single_og"
)
codes = st.multiselect(
"Выберите коды строк",
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
default=[78, 79],
key="pm_single_codes"
)
columns = st.multiselect(
"Выберите столбцы",
["БП", "ПП", "СЭБ", "Факт", "План"],
default=["БП", "ПП"],
key="pm_single_columns"
)
if st.button("🔍 Получить данные по ОГ", key="pm_single_btn"):
if codes and columns:
with st.spinner("Получаю данные..."):
data = {
"getter": "single_og",
"id": og_id,
"codes": codes,
"columns": columns
}
result, status = make_api_request("/svodka_pm/get_data", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите коды и столбцы")
with col2:
st.subheader("Данные по всем ОГ")
codes_total = st.multiselect(
"Выберите коды строк",
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
default=[78, 79, 394, 395],
key="pm_total_codes"
)
columns_total = st.multiselect(
"Выберите столбцы",
["БП", "ПП", "СЭБ", "Факт", "План"],
default=["БП", "ПП", "СЭБ"],
key="pm_total_columns"
)
if st.button("🔍 Получить данные по всем ОГ", key="pm_total_btn"):
if codes_total and columns_total:
with st.spinner("Получаю данные..."):
data = {
"getter": "total_ogs",
"codes": codes_total,
"columns": columns_total
}
result, status = make_api_request("/svodka_pm/get_data", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите коды и столбцы")
# Вкладка 2: Сводки СА - полный функционал
with tab2:
st.header("🏭 Сводки СА - Полный функционал")
# Получаем информацию о геттерах
getters_info = get_parser_getters("svodka_ca")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_ca = st.file_uploader(
"Выберите Excel файл сводки СА",
type=['xlsx', 'xlsm', 'xls'],
key="ca_upload"
)
if uploaded_ca is not None:
if st.button("📤 Загрузить сводку СА", key="upload_ca_btn"):
with st.spinner("Загружаю файл..."):
try:
files = {"file": (uploaded_ca.name, uploaded_ca.read(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
response = requests.post(f"{API_BASE_URL}/svodka_ca/upload", files=files)
result = response.json()
if response.status_code == 200:
st.success(f"{result.get('message', 'Файл загружен')}")
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
except Exception as e:
st.error(f"❌ Ошибка: {str(e)}")
st.markdown("---")
# Секция получения данных
st.subheader("🔍 Получение данных")
# Показываем доступные геттеры
if getters_info and "getters" in getters_info:
st.info("📋 Доступные геттеры:")
for getter_name, getter_info in getters_info["getters"].items():
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
if getter_info.get('optional_params'):
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
col1, col2 = st.columns(2)
with col1:
st.subheader("Параметры запроса")
modes = st.multiselect(
"Выберите режимы",
["План", "Факт", "Норматив"],
default=["План", "Факт"],
key="ca_modes"
)
tables = st.multiselect(
"Выберите таблицы",
["ТиП", "Топливо", "Потери"],
default=["ТиП", "Топливо"],
key="ca_tables"
)
with col2:
st.subheader("Результат")
if st.button("🔍 Получить данные СА", key="ca_btn"):
if modes and tables:
with st.spinner("Получаю данные..."):
data = {
"getter": "get_data",
"modes": modes,
"tables": tables
}
result, status = make_api_request("/svodka_ca/get_data", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите режимы и таблицы")
# Вкладка 3: Мониторинг топлива - полный функционал
with tab3:
st.header("⛽ Мониторинг топлива - Полный функционал")
# Получаем информацию о геттерах
getters_info = get_parser_getters("monitoring_fuel")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_fuel = st.file_uploader(
"Выберите ZIP архив с мониторингом топлива",
type=['zip'],
key="fuel_upload"
)
if uploaded_fuel is not None:
if st.button("📤 Загрузить мониторинг топлива", key="upload_fuel_btn"):
with st.spinner("Загружаю файл..."):
result, status = upload_file_to_api(
"/monitoring_fuel/upload-zip",
uploaded_fuel.read(),
uploaded_fuel.name
)
if status == 200:
st.success(f"{result.get('message', 'Файл загружен')}")
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
st.markdown("---")
# Секция получения данных
st.subheader("🔍 Получение данных")
# Показываем доступные геттеры
if getters_info and "getters" in getters_info:
st.info("📋 Доступные геттеры:")
for getter_name, getter_info in getters_info["getters"].items():
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
if getter_info.get('optional_params'):
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
col1, col2 = st.columns(2)
with col1:
st.subheader("Агрегация по колонкам")
columns_fuel = st.multiselect(
"Выберите столбцы",
["normativ", "total", "total_1"],
default=["normativ", "total"],
key="fuel_columns"
)
if st.button("🔍 Получить агрегированные данные", key="fuel_total_btn"):
if columns_fuel:
with st.spinner("Получаю данные..."):
data = {
"getter": "total_by_columns",
"columns": columns_fuel
}
result, status = make_api_request("/monitoring_fuel/get_data", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите столбцы")
with col2:
st.subheader("Данные за месяц")
month = st.selectbox(
"Выберите месяц",
[f"{i:02d}" for i in range(1, 13)],
key="fuel_month"
)
if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"):
with st.spinner("Получаю данные..."):
data = {
"getter": "month_by_code",
"month": month
}
result, status = make_api_request("/monitoring_fuel/get_data", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Футер
st.markdown("---")
st.markdown("### 📚 Документация API")
st.markdown(f"Полная документация доступна по адресу: {API_BASE_URL}/docs")
# Информация о проекте
with st.expander(" О проекте"):
st.markdown("""
**NIN Excel Parsers API** - это веб-сервис для парсинга и обработки Excel-файлов нефтеперерабатывающих заводов.
**Возможности:**
- 📊 Парсинг сводок ПМ (план и факт)
- 🏭 Парсинг сводок СА
- ⛽ Мониторинг топлива
**Технологии:**
- FastAPI
- Pandas
- MinIO (S3-совместимое хранилище)
- Streamlit (веб-интерфейс)
""")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,4 @@
streamlit>=1.28.0
requests>=2.31.0
pandas>=1.5.0
numpy>=1.24.0