Compare commits
2 Commits
main
...
9459196804
| Author | SHA1 | Date | |
|---|---|---|---|
| 9459196804 | |||
| ce228d9756 |
90
.gitignore
vendored
Normal file
90
.gitignore
vendored
Normal 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
20
python_parser/Dockerfile_
Normal 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"]
|
||||||
@@ -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
|
```bash
|
||||||
# Запуск MinIO в Docker
|
# Запуск MinIO в Docker
|
||||||
docker-compose up -d minio
|
docker-compose up -d minio
|
||||||
@@ -13,16 +25,8 @@ docker-compose up -d minio
|
|||||||
python run_dev.py
|
python run_dev.py
|
||||||
|
|
||||||
# В отдельном терминале запуск Streamlit
|
# В отдельном терминале запуск Streamlit
|
||||||
python run_streamlit.py
|
cd streamlit_app
|
||||||
```
|
streamlit run app.py
|
||||||
|
|
||||||
### **Вариант 2: MinIO + FastAPI в Docker + Streamlit локально**
|
|
||||||
```bash
|
|
||||||
# Запуск MinIO и FastAPI в Docker
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# В отдельном терминале запуск Streamlit
|
|
||||||
python run_streamlit.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Вариант 3: Только MinIO в Docker**
|
### **Вариант 3: Только MinIO в Docker**
|
||||||
@@ -37,13 +41,6 @@ docker-compose up -d minio
|
|||||||
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
|
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
|
||||||
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
|
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
|
||||||
|
|
||||||
## 🔧 Диагностика
|
|
||||||
|
|
||||||
Для проверки состояния всех сервисов:
|
|
||||||
```bash
|
|
||||||
python check_services.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛑 Остановка
|
## 🛑 Остановка
|
||||||
|
|
||||||
### Остановка Docker сервисов:
|
### Остановка Docker сервисов:
|
||||||
@@ -55,9 +52,9 @@ docker-compose down
|
|||||||
docker-compose stop minio
|
docker-compose stop minio
|
||||||
```
|
```
|
||||||
|
|
||||||
### Остановка Streamlit:
|
### Остановка локальных сервисов:
|
||||||
```bash
|
```bash
|
||||||
# Нажмите Ctrl+C в терминале с Streamlit
|
# Нажмите Ctrl+C в терминале с FastAPI/Streamlit
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
@@ -74,124 +71,73 @@ python_parser/
|
|||||||
├── adapters/ # Адаптеры для внешних систем
|
├── adapters/ # Адаптеры для внешних систем
|
||||||
│ ├── storage.py # MinIO адаптер
|
│ ├── storage.py # MinIO адаптер
|
||||||
│ └── parsers/ # Парсеры Excel файлов
|
│ └── parsers/ # Парсеры Excel файлов
|
||||||
|
├── streamlit_app/ # Изолированный Streamlit пакет
|
||||||
|
│ ├── app.py # Основное Streamlit приложение
|
||||||
|
│ ├── requirements.txt # Зависимости Streamlit
|
||||||
|
│ ├── Dockerfile # Docker образ для Streamlit
|
||||||
|
│ └── .streamlit/ # Конфигурация Streamlit
|
||||||
├── data/ # Тестовые данные
|
├── data/ # Тестовые данные
|
||||||
├── docker-compose.yml # Docker Compose конфигурация
|
├── docker-compose.yml # Docker Compose конфигурация
|
||||||
├── Dockerfile # Docker образ для FastAPI
|
├── Dockerfile # Docker образ для FastAPI
|
||||||
├── run_dev.py # Запуск FastAPI локально
|
└── run_dev.py # Запуск FastAPI локально
|
||||||
├── run_streamlit.py # Запуск Streamlit
|
|
||||||
└── check_services.py # Диагностика сервисов
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔍 Доступные эндпоинты
|
## 🔍 Доступные эндпоинты
|
||||||
|
|
||||||
- **GET /** - Информация об API
|
- **GET /** - Информация об API
|
||||||
- **GET /docs** - Swagger документация
|
- **GET /docs** - Swagger документация
|
||||||
|
- **GET /parsers** - Список доступных парсеров
|
||||||
|
- **GET /parsers/{parser_name}/getters** - Информация о геттерах парсера
|
||||||
- **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
|
- **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
|
||||||
- **POST /svodka_ca/upload-zip** - Загрузка сводок ЦА
|
- **POST /svodka_ca/upload** - Загрузка сводок ЦА
|
||||||
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
|
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
|
||||||
- **GET /svodka_pm/data** - Получение данных сводок ПМ
|
- **POST /svodka_pm/get_data** - Получение данных сводок ПМ
|
||||||
- **GET /svodka_ca/data** - Получение данных сводок ЦА
|
- **POST /svodka_ca/get_data** - Получение данных сводок ЦА
|
||||||
- **GET /monitoring_fuel/data** - Получение данных мониторинга топлива
|
- **POST /monitoring_fuel/get_data** - Получение данных мониторинга топлива
|
||||||
|
|
||||||
## 📊 Поддерживаемые типы отчетов
|
## 📊 Поддерживаемые типы отчетов
|
||||||
|
|
||||||
1. **svodka_pm** - Сводки по переработке нефти (ПМ)
|
1. **svodka_pm** - Сводки по переработке нефти (ПМ)
|
||||||
|
- Геттеры: `single_og`, `total_ogs`
|
||||||
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
|
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
|
||||||
|
- Геттеры: `get_data`
|
||||||
3. **monitoring_fuel** - Мониторинг топлива
|
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
|
```bash
|
||||||
# Все сервисы
|
# FastAPI
|
||||||
docker-compose up -d --build
|
docker build -t nin-fastapi .
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
docker build -t nin-streamlit ./streamlit_app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск отдельных сервисов:
|
||||||
|
```bash
|
||||||
# Только MinIO
|
# Только MinIO
|
||||||
docker-compose up -d minio
|
docker-compose up -d minio
|
||||||
|
|
||||||
# Только FastAPI (требует MinIO)
|
# MinIO + FastAPI
|
||||||
docker-compose up -d fastapi
|
docker-compose up -d minio fastapi
|
||||||
```
|
|
||||||
|
|
||||||
### Просмотр логов:
|
|
||||||
```bash
|
|
||||||
# Все сервисы
|
# Все сервисы
|
||||||
docker-compose logs
|
docker-compose up -d
|
||||||
|
```
|
||||||
# Конкретный сервис
|
|
||||||
docker-compose logs fastapi
|
|
||||||
docker-compose logs minio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Остановка:
|
|
||||||
```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=.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Лицензия
|
|
||||||
|
|
||||||
Проект разработан для внутреннего использования НИН.
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import re
|
import re
|
||||||
from typing import Dict
|
import zipfile
|
||||||
|
from typing import Dict, Tuple
|
||||||
from core.ports import ParserPort
|
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):
|
class MonitoringFuelParser(ParserPort):
|
||||||
@@ -11,6 +11,82 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
|
|
||||||
name = "Мониторинг топлива"
|
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:
|
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
|
||||||
"""Определение индекса заголовка в Excel по ключевому слову"""
|
"""Определение индекса заголовка в Excel по ключевому слову"""
|
||||||
# Читаем первые max_rows строк без заголовков
|
# Читаем первые max_rows строк без заголовков
|
||||||
@@ -64,46 +140,15 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
# Проверяем, что колонка 'name' существует
|
# Проверяем, что колонка 'name' существует
|
||||||
if 'name' in df_full.columns:
|
if 'name' in df_full.columns:
|
||||||
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
# Применяем функцию 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 как индекс
|
# Устанавливаем id как индекс
|
||||||
df_full.set_index('id', inplace=True)
|
df_full.set_index('id', inplace=True)
|
||||||
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
||||||
return df_full
|
return df_full
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
|
||||||
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):
|
|
||||||
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
||||||
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
||||||
means = {} # Для хранения средних
|
means = {} # Для хранения средних
|
||||||
@@ -185,22 +230,3 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
total.name = 'mean'
|
total.name = 'mean'
|
||||||
|
|
||||||
return total, df_combined
|
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
|
|
||||||
|
|||||||
@@ -6,85 +6,44 @@ from adapters.pconfig import get_og_by_name
|
|||||||
|
|
||||||
|
|
||||||
class SvodkaCAParser(ParserPort):
|
class SvodkaCAParser(ParserPort):
|
||||||
"""Парсер для сводки СА"""
|
"""Парсер для сводок СА"""
|
||||||
|
|
||||||
name = "Сводка СА"
|
name = "Сводки СА"
|
||||||
|
|
||||||
def extract_all_tables(self, file_path, sheet_name=0):
|
def _register_default_getters(self):
|
||||||
"""Извлекает все таблицы из Excel файла"""
|
"""Регистрация геттеров по умолчанию"""
|
||||||
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
|
self.register_getter(
|
||||||
df_filled = df.fillna('')
|
name="get_data",
|
||||||
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
method=self._get_data_wrapper,
|
||||||
|
required_params=["modes", "tables"],
|
||||||
|
optional_params=[],
|
||||||
|
description="Получение данных по режимам и таблицам"
|
||||||
|
)
|
||||||
|
|
||||||
non_empty_rows = ~(df_clean.eq('').all(axis=1))
|
def _get_data_wrapper(self, params: dict):
|
||||||
non_empty_cols = ~(df_clean.eq('').all(axis=0))
|
"""Обертка для получения данных (для совместимости)"""
|
||||||
|
modes = params["modes"]
|
||||||
|
tables = params["tables"]
|
||||||
|
|
||||||
|
if not isinstance(modes, list):
|
||||||
|
raise ValueError("Поле 'modes' должно быть списком")
|
||||||
|
if not isinstance(tables, list):
|
||||||
|
raise ValueError("Поле 'tables' должно быть списком")
|
||||||
|
|
||||||
|
# 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_indices = non_empty_rows[non_empty_rows].index.tolist()
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
col_indices = non_empty_cols[non_empty_cols].index.tolist()
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
# Сохраняем DataFrame для использования в геттерах
|
||||||
|
self.df = self.parse_svodka_ca(file_path, params)
|
||||||
|
return self.df
|
||||||
|
|
||||||
if not row_indices or not col_indices:
|
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
|
||||||
return []
|
"""Парсинг сводки СА"""
|
||||||
|
|
||||||
row_blocks = self._get_contiguous_blocks(row_indices)
|
|
||||||
col_blocks = self._get_contiguous_blocks(col_indices)
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Собственно функция парсинга отчета СА"""
|
|
||||||
# === Извлечение и фильтрация ===
|
# === Извлечение и фильтрация ===
|
||||||
tables = self.extract_all_tables(file_path, sheet_name)
|
tables = self.extract_all_tables(file_path, sheet_name)
|
||||||
|
|
||||||
@@ -190,76 +149,185 @@ class SvodkaCAParser(ParserPort):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
def extract_all_tables(self, file_path, sheet_name=0):
|
||||||
"""Парсинг файла сводки СА"""
|
"""Извлекает все таблицы из Excel файла"""
|
||||||
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
|
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
|
||||||
# Выгружаем План в df_ca_plan
|
df_filled = df.fillna('')
|
||||||
inclusion_list_plan = {
|
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) # ЭТО ДАТАФРЕЙМ ПЛАНА В СВОДКЕ ЦА
|
non_empty_rows = ~(df_clean.eq('').all(axis=1))
|
||||||
print(f"\n--- Объединённый и отсортированный План: {df_ca_plan.shape} ---")
|
non_empty_cols = ~(df_clean.eq('').all(axis=0))
|
||||||
|
|
||||||
# Выгружаем Факт
|
row_indices = non_empty_rows[non_empty_rows].index.tolist()
|
||||||
inclusion_list_fact = {
|
col_indices = non_empty_cols[non_empty_cols].index.tolist()
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн",
|
|
||||||
"в т.ч. Неидентифицированные потери, %"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) # ЭТО ДАТАФРЕЙМ ФАКТА В СВОДКЕ ЦА
|
if not row_indices or not col_indices:
|
||||||
print(f"\n--- Объединённый и отсортированный Факт: {df_ca_fact.shape} ---")
|
return []
|
||||||
|
|
||||||
# Выгружаем План в df_ca_normativ
|
row_blocks = self._get_contiguous_blocks(row_indices)
|
||||||
inclusion_list_normativ = {
|
col_blocks = self._get_contiguous_blocks(col_indices)
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ЭТО ДАТАФРЕЙМ НОРМАТИВА В СВОДКЕ ЦА
|
tables = []
|
||||||
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
|
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 = {
|
tables.append(block)
|
||||||
"plan": df_ca_plan,
|
|
||||||
"fact": df_ca_fact,
|
return tables
|
||||||
"normativ": df_ca_normativ
|
|
||||||
}
|
def _get_contiguous_blocks(self, indices):
|
||||||
return df_dict
|
"""Группирует индексы в непрерывные блоки"""
|
||||||
|
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):
|
def data_dict_to_json(self, data_dict):
|
||||||
''' Служебная функция для парсинга словаря в json. '''
|
''' Служебная функция для парсинга словаря в json. '''
|
||||||
@@ -308,17 +376,3 @@ class SvodkaCAParser(ParserPort):
|
|||||||
filtered_df = df[df['table'].isin(table_values)].copy()
|
filtered_df = df[df['table'].isin(table_values)].copy()
|
||||||
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
||||||
return result_dict
|
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)
|
|
||||||
|
|||||||
@@ -9,6 +9,60 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
name = "Сводки ПМ"
|
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:
|
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
|
||||||
"""Определения индекса заголовка в excel по ключевому слову"""
|
"""Определения индекса заголовка в excel по ключевому слову"""
|
||||||
# Читаем первые max_rows строк без заголовков
|
# Читаем первые 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'
|
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
|
||||||
|
|
||||||
# Проверяем, начинается ли на "Итого"
|
if is_empty_or_unnamed:
|
||||||
if col_str.startswith('Итого'):
|
# Если это пустая колонка, используем последнее хорошее имя
|
||||||
current_name = 'Итого'
|
if last_good_name:
|
||||||
last_good_name = current_name # обновляем last_good_name
|
new_columns.append(last_good_name)
|
||||||
new_columns.append(current_name)
|
else:
|
||||||
elif is_empty_or_unnamed:
|
# Если нет хорошего имени, пропускаем
|
||||||
# Используем последнее хорошее имя
|
continue
|
||||||
new_columns.append(last_good_name)
|
|
||||||
else:
|
else:
|
||||||
# Имя, полученное из exel
|
# Это хорошая колонка
|
||||||
last_good_name = col_str
|
last_good_name = col_str
|
||||||
new_columns.append(col_str)
|
new_columns.append(col_str)
|
||||||
|
|
||||||
|
# Применяем новые заголовки
|
||||||
df_final.columns = new_columns
|
df_final.columns = new_columns
|
||||||
|
|
||||||
print(f"Окончательное количество столбцов: {len(df_final.columns)}")
|
|
||||||
return df_final
|
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
|
import zipfile
|
||||||
pm_dict = {
|
pm_dict = {
|
||||||
"facts": {},
|
"facts": {},
|
||||||
@@ -125,7 +179,7 @@ class SvodkaPMParser(ParserPort):
|
|||||||
}
|
}
|
||||||
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
||||||
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
|
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()
|
file_list = zip_ref.namelist()
|
||||||
for name, id in OG_IDS.items():
|
for name, id in OG_IDS.items():
|
||||||
if id == 'BASH':
|
if id == 'BASH':
|
||||||
@@ -155,9 +209,9 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
return pm_dict
|
return pm_dict
|
||||||
|
|
||||||
def get_svodka_value(self, df_svodka, id, code, search_value=None):
|
def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None):
|
||||||
''' Служебная функция для простой выборке по сводке '''
|
''' Служебная функция получения значения по коду и столбцу '''
|
||||||
row_index = id
|
row_index = code
|
||||||
|
|
||||||
mask_value = df_svodka.iloc[0] == code
|
mask_value = df_svodka.iloc[0] == code
|
||||||
if search_value is None:
|
if search_value is None:
|
||||||
@@ -254,22 +308,4 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
return total_result
|
return total_result
|
||||||
|
|
||||||
def get_value(self, df, params):
|
# Убираем старый метод get_value, так как он теперь в базовом классе
|
||||||
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
|
|
||||||
|
|||||||
@@ -96,6 +96,54 @@ async def get_available_parsers():
|
|||||||
return {"parsers": 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=["Общее"],
|
@app.get("/server-info", tags=["Общее"],
|
||||||
summary="Информация о сервере",
|
summary="Информация о сервере",
|
||||||
response_model=ServerInfoResponse,)
|
response_model=ServerInfoResponse,)
|
||||||
|
|||||||
@@ -2,28 +2,93 @@
|
|||||||
Порты (интерфейсы) для hexagonal architecture
|
Порты (интерфейсы) для hexagonal architecture
|
||||||
"""
|
"""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional
|
from typing import Optional, Dict, List, Any, Callable
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
class ParserPort(ABC):
|
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
|
@abstractmethod
|
||||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
"""Парсинг файла и возврат DataFrame"""
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_value(self, df: pd.DataFrame, params: dict):
|
|
||||||
"""Получение значения из DataFrame по параметрам"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# @abstractmethod
|
|
||||||
# def get_schema(self) -> dict:
|
|
||||||
# """Возвращает схему входных параметров для парсера"""
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
class StoragePort(ABC):
|
class StoragePort(ABC):
|
||||||
"""Интерфейс для хранилища данных"""
|
"""Интерфейс для хранилища данных"""
|
||||||
|
|||||||
@@ -99,9 +99,35 @@ class ReportService:
|
|||||||
|
|
||||||
# Получаем парсер
|
# Получаем парсер
|
||||||
parser = get_parser(request.report_type)
|
parser = get_parser(request.report_type)
|
||||||
|
|
||||||
|
# Устанавливаем DataFrame в парсер для использования в геттерах
|
||||||
|
parser.df = df
|
||||||
|
|
||||||
# Получаем значение
|
# Получаем параметры запроса
|
||||||
value = parser.get_value(df, request.get_params)
|
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:
|
if value is not None:
|
||||||
|
|||||||
@@ -28,5 +28,16 @@ services:
|
|||||||
- minio
|
- minio
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
minio_data:
|
minio_data:
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Запуск Streamlit интерфейса для NIN Excel Parsers API
|
Запуск Streamlit интерфейса локально из изолированного пакета
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import time
|
import os
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Основная функция"""
|
"""Основная функция"""
|
||||||
print("🚀 ЗАПУСК STREAMLIT ИНТЕРФЕЙСА")
|
print("🚀 ЗАПУСК STREAMLIT ИЗ ИЗОЛИРОВАННОГО ПАКЕТА")
|
||||||
print("=" * 50)
|
print("=" * 60)
|
||||||
print("Убедитесь, что FastAPI сервер запущен на порту 8000")
|
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
|
# Проверяем, установлен ли Streamlit
|
||||||
try:
|
try:
|
||||||
@@ -21,7 +30,7 @@ def main():
|
|||||||
print(f"✅ Streamlit {streamlit.__version__} установлен")
|
print(f"✅ Streamlit {streamlit.__version__} установлен")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("❌ Streamlit не установлен")
|
print("❌ Streamlit не установлен")
|
||||||
print("Установите: pip install streamlit")
|
print("Установите: pip install -r requirements.txt")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("\n🚀 Запускаю Streamlit...")
|
print("\n🚀 Запускаю Streamlit...")
|
||||||
@@ -38,7 +47,7 @@ def main():
|
|||||||
# Запускаем Streamlit
|
# Запускаем Streamlit
|
||||||
try:
|
try:
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
sys.executable, "-m", "streamlit", "run", "streamlit_app.py",
|
sys.executable, "-m", "streamlit", "run", "app.py",
|
||||||
"--server.port", "8501",
|
"--server.port", "8501",
|
||||||
"--server.address", "localhost",
|
"--server.address", "localhost",
|
||||||
"--server.headless", "false",
|
"--server.headless", "false",
|
||||||
@@ -254,8 +254,8 @@ def main():
|
|||||||
|
|
||||||
modes = st.multiselect(
|
modes = st.multiselect(
|
||||||
"Выберите режимы",
|
"Выберите режимы",
|
||||||
["План", "Факт", "Норматив"],
|
["plan", "fact", "normativ"],
|
||||||
default=["План", "Факт"],
|
default=["plan", "fact"],
|
||||||
key="ca_modes"
|
key="ca_modes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
31
python_parser/streamlit_app/.dockerignore
Normal file
31
python_parser/streamlit_app/.dockerignore
Normal 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
|
||||||
|
*~
|
||||||
23
python_parser/streamlit_app/Dockerfile
Normal file
23
python_parser/streamlit_app/Dockerfile
Normal 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"]
|
||||||
44
python_parser/streamlit_app/README.md
Normal file
44
python_parser/streamlit_app/README.md
Normal 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**
|
||||||
447
python_parser/streamlit_app/app.py
Normal file
447
python_parser/streamlit_app/app.py
Normal 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()
|
||||||
4
python_parser/streamlit_app/requirements.txt
Normal file
4
python_parser/streamlit_app/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
streamlit>=1.28.0
|
||||||
|
requests>=2.31.0
|
||||||
|
pandas>=1.5.0
|
||||||
|
numpy>=1.24.0
|
||||||
Reference in New Issue
Block a user