3 Commits

Author SHA1 Message Date
4cbdaf1b60 ch 2025-09-01 13:58:42 +03:00
9459196804 all in docker 2025-09-01 12:24:37 +03:00
ce228d9756 work 2025-09-01 12:08:16 +03:00
72 changed files with 1867 additions and 6515 deletions

224
.gitignore vendored
View File

@@ -1,26 +1,15 @@
# Python data
__pycache__ .streamlit
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
python_parser/__pycache__/
python_parser/core/__pycache__/
python_parser/adapters/__pycache__/
python_parser/tests/__pycache__/
python_parser/tests/test_core/__pycache__/
python_parser/tests/test_adapters/__pycache__/
python_parser/tests/test_app/__pycache__/
python_parser/app/__pycache__/
python_parser/app/schemas/__pycache__/
python_parser/app/schemas/test_schemas/__pycache__/
python_parser/app/schemas/test_schemas/test_core/__pycache__/
python_parser/app/schemas/test_schemas/test_adapters/__pycache__/
python_parser/app/schemas/test_schemas/test_app/__pycache__/
nin_python_parser
*.pyc
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions
*.so *.so
# Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
@@ -34,13 +23,88 @@ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
# Virtual environments # PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# 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
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env .env
.venv .venv
env/ env/
@@ -49,86 +113,6 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Logs
*.log
logs/
log/
# MinIO data and cache
minio_data/
.minio.sys/
*.meta
part.*
# Docker
.dockerignore
docker-compose.override.yml
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Temporary files
*.tmp
*.temp
*.bak
*.backup
*.orig
# Data files (Excel, CSV, etc.)
*.xlsx
*.xls
*.xlsm
*.csv
*.json
data/
uploads/
# Cache directories
.cache/
.pytest_cache/
.coverage
htmlcov/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# Celery
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
@@ -147,29 +131,23 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer # IDE
.pytype/ .vscode/
.idea/
*.swp
*.swo
*~
# Cython debug symbols # OS
cython_debug/ .DS_Store
Thumbs.db
# Local development # Project specific
local_settings.py data/
db.sqlite3 *.zip
db.sqlite3-journal *.xlsx
*.xls
*.xlsm
# FastAPI # MinIO data directory
.pytest_cache/ minio_data/
.coverage
htmlcov/
# Streamlit
.streamlit/secrets.toml
# Node.js (if any frontend components)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
__pycache__/

File diff suppressed because it is too large Load Diff

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: python /app/run_stand.py

View File

@@ -1,41 +0,0 @@
# 🚀 Быстрый запуск проекта
## 1. Запуск всех сервисов
```bash
docker compose up -d
```
## 2. Проверка статуса
```bash
docker compose ps
```
## 3. Доступ к сервисам
- **FastAPI**: http://localhost:8000
- **Streamlit**: http://localhost:8501
- **MinIO Console**: http://localhost:9001
- **MinIO API**: http://localhost:9000
## 4. Остановка
```bash
docker compose down
```
## 5. Просмотр логов
```bash
# Все сервисы
docker compose logs
# Конкретный сервис
docker compose logs fastapi
docker compose logs streamlit
docker compose logs minio
```
## 6. Пересборка и перезапуск
```bash
docker compose up -d --build
```
---
**Примечание**: При первом запуске Docker будет скачивать образы и собирать контейнеры, это может занять несколько минут.

227
README.md
View File

@@ -1,117 +1,182 @@
# Python Parser CF - Система анализа данных # 🚀 NIN Excel Parsers API - Полная система
Проект состоит из трех основных компонентов: Полноценная система для парсинга Excel отчетов нефтеперерабатывающих заводов (НПЗ) с использованием FastAPI, MinIO и Streamlit.
- **python_parser** - FastAPI приложение для парсинга и обработки данных
- **streamlit_app** - Streamlit приложение для визуализации и анализа ## 🏗️ Архитектура проекта
- **minio_data** - хранилище данных MinIO
Проект состоит из **двух изолированных пакетов**:
- **`python_parser/`** - FastAPI сервер + парсеры Excel
- **`streamlit_app/`** - Веб-интерфейс для демонстрации API
## 🚀 Быстрый запуск ## 🚀 Быстрый запуск
### Предварительные требования ### **Вариант 1: Все сервисы в Docker (рекомендуется)**
- Docker и Docker Compose
- Git
### Запуск всех сервисов (продакшн)
```bash ```bash
docker compose up -d # Запуск всех сервисов: 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 + сервисы локально**
```bash ```bash
# Автоматический запуск # Запуск MinIO в Docker
python start_dev.py docker-compose up -d minio
# Или вручную # Запуск FastAPI локально
docker compose -f docker-compose.dev.yml up -d cd python_parser
python run_dev.py
# В отдельном терминале - Streamlit
cd streamlit_app
streamlit run app.py
``` ```
**Режим разработки** позволяет: ### **Вариант 3: Только MinIO в Docker**
- Автоматически перезагружать Streamlit при изменении кода
- Монтировать исходный код напрямую в контейнер
- Видеть изменения без пересборки контейнеров
### Доступ к сервисам
- **FastAPI**: http://localhost:8000
- **Streamlit**: http://localhost:8501
- **MinIO Console**: http://localhost:9001
- **MinIO API**: http://localhost:9000
### Остановка сервисов
```bash ```bash
docker-compose down # Запуск только MinIO
docker-compose up -d minio
``` ```
## 📋 Описание сервисов
- **MinIO** (порт 9000-9001): S3-совместимое хранилище для данных
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
## 📁 Структура проекта ## 📁 Структура проекта
``` ```
python_parser_cf/ python_parser_cf/ # Корень проекта
├── python_parser/ # FastAPI приложение ├── python_parser/ # Пакет FastAPI + парсеры
│ ├── app/ # Основной код приложения │ ├── app/ # FastAPI приложение
│ ├── adapters/ # Адаптеры для парсеров │ ├── main.py # Основной файл приложения
├── core/ # Основная бизнес-логика │ └── schemas/ # Pydantic схемы
│ ├── data/ # Тестовые данные │ ├── core/ # Бизнес-логика
└── Dockerfile # Docker образ для FastAPI │ ├── models.py # Модели данных
├── streamlit_app/ # Streamlit приложение │ │ ├── ports.py # Интерфейсы (порты)
├── streamlit_app.py # Основной файл приложения │ └── services.py # Сервисы
│ ├── requirements.txt # Зависимости Python │ ├── adapters/ # Адаптеры для внешних систем
│ ├── .streamlit/ # Конфигурация Streamlit │ ├── storage.py # MinIO адаптер
│ └── Dockerfile # Docker образ для Streamlit │ └── parsers/ # Парсеры Excel файлов
├── minio_data/ # Данные для MinIO ├── data/ # Тестовые данные
├── docker-compose.yml # Конфигурация всех сервисов ├── Dockerfile # Docker образ для FastAPI
└── README.md # Документация │ ├── requirements.txt # Зависимости FastAPI
│ └── run_dev.py # Запуск FastAPI локально
├── streamlit_app/ # Пакет Streamlit
│ ├── app.py # Основное Streamlit приложение
│ ├── requirements.txt # Зависимости Streamlit
│ ├── Dockerfile # Docker образ для Streamlit
│ ├── .streamlit/ # Конфигурация Streamlit
│ │ └── config.toml # Настройки
│ └── README.md # Документация Streamlit
├── docker-compose.yml # Docker Compose конфигурация
├── .gitignore # Git исключения
└── README.md # Общая документация
``` ```
## 🔧 Конфигурация ## 🔍 Доступные эндпоинты
### Переменные окружения - **GET /** - Информация об API
Все сервисы используют следующие переменные окружения: - **GET /docs** - Swagger документация
- `MINIO_ENDPOINT` - адрес MinIO сервера - **GET /parsers** - Список доступных парсеров
- `MINIO_ACCESS_KEY` - ключ доступа к MinIO - **GET /parsers/{parser_name}/getters** - Информация о геттерах парсера
- `MINIO_SECRET_KEY` - секретный ключ MinIO - **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
- `MINIO_SECURE` - использование SSL/TLS - **POST /svodka_ca/upload** - Загрузка сводок ЦА
- `MINIO_BUCKET` - имя bucket'а для данных - **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
- **POST /svodka_pm/get_data** - Получение данных сводок ПМ
- **POST /svodka_ca/get_data** - Получение данных сводок ЦА
- **POST /monitoring_fuel/get_data** - Получение данных мониторинга топлива
### Порты ## 📊 Поддерживаемые типы отчетов
- **8000** - FastAPI
- **8501** - Streamlit
- **9000** - MinIO API
- **9001** - MinIO Console
## 📊 Использование 1. **svodka_pm** - Сводки по переработке нефти (ПМ)
- Геттеры: `single_og`, `total_ogs`
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
- Геттеры: `get_data`
3. **monitoring_fuel** - Мониторинг топлива
- Геттеры: `total_by_columns`, `month_by_code`
1. **Запустите все сервисы**: `docker-compose up -d` ## 🏗️ Архитектура
2. **Откройте Streamlit**: http://localhost:8501
3. **Выберите тип данных** для анализа
4. **Просматривайте результаты** в интерактивном интерфейсе
## 🛠️ Разработка Проект использует **Hexagonal Architecture (Ports and Adapters)**:
### Режим разработки (рекомендуется) - **Порты (Ports)**: Интерфейсы для бизнес-логики
- **Адаптеры (Adapters)**: Реализации для внешних систем
- **Сервисы (Services)**: Бизнес-логика приложения
### Система геттеров парсеров
Каждый парсер может иметь несколько методов получения данных (геттеров):
- Регистрация геттеров в словаре с метаданными
- Валидация параметров для каждого геттера
- Единый интерфейс `get_value(getter_name, params)`
## 🐳 Docker
### Сборка образов:
```bash ```bash
# Запуск режима разработки # FastAPI
python start_dev.py docker build -t nin-fastapi ./python_parser
# Остановка # Streamlit
docker compose -f docker-compose.dev.yml down docker build -t nin-streamlit ./streamlit_app
# Возврат к продакшн режиму
python start_prod.py
``` ```
### Локальная разработка FastAPI ### Запуск отдельных сервисов:
```bash ```bash
# Только MinIO
docker-compose up -d minio
# MinIO + FastAPI
docker-compose up -d minio fastapi
# Все сервисы
docker-compose up -d
```
## 🛑 Остановка
### Остановка Docker сервисов:
```bash
# Все сервисы
docker-compose down
# Только MinIO
docker-compose stop minio
```
### Остановка локальных сервисов:
```bash
# Нажмите Ctrl+C в терминале с FastAPI/Streamlit
```
## 🔧 Разработка
### Добавление нового парсера:
1. Создайте файл в `python_parser/adapters/parsers/`
2. Реализуйте интерфейс `ParserPort`
3. Добавьте в `python_parser/core/services.py`
4. Создайте схемы в `python_parser/app/schemas/`
5. Добавьте эндпоинты в `python_parser/app/main.py`
### Тестирование:
```bash
# Запуск тестов
cd python_parser cd python_parser
pip install -r requirements.txt pytest
uvicorn app.main:app --reload
```
### Локальная разработка Streamlit # Запуск с покрытием
```bash pytest --cov=.
cd streamlit_app
pip install -r requirements.txt
streamlit run streamlit_app.py
``` ```
## 📝 Лицензия ## 📝 Лицензия
Проект разработан для внутреннего использования. Проект разработан для внутреннего использования НИН.

View File

@@ -170,16 +170,11 @@ def main():
if not port_8000_ok: if not port_8000_ok:
print("\n🔧 РЕШЕНИЕ: Запустите FastAPI сервер") print("\n🔧 РЕШЕНИЕ: Запустите FastAPI сервер")
print("python run_dev.py") print("docker-compose up -d fastapi")
if not port_8501_ok: if not port_8501_ok:
print("\n🔧 РЕШЕНИЕ: Запустите Streamlit") print("\n🔧 РЕШЕНИЕ: Запустите Streamlit")
print("python run_streamlit.py") print("docker-compose up -d streamlit")
print("\n🚀 Для автоматического запуска используйте:")
print("python start_demo.py")
print("\n🔍 Для пошагового запуска используйте:")
print("python run_manual.py")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

34
create_test_excel.py Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
Создание тестового Excel файла для тестирования API
"""
import pandas as pd
import numpy as np
def create_test_excel():
"""Создание тестового Excel файла"""
# Создаем тестовые данные
data = {
'name': ['Установка 1', 'Установка 2', 'Установка 3'],
'normativ': [100, 200, 300],
'total': [95, 195, 295],
'total_1': [90, 190, 290]
}
df = pd.DataFrame(data)
# Сохраняем в Excel
filename = 'test_file.xlsx'
with pd.ExcelWriter(filename, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Мониторинг потребления', index=False)
print(f"✅ Тестовый файл создан: {filename}")
print(f"📊 Содержимое: {len(df)} строк, {len(df.columns)} столбцов")
print(f"📋 Столбцы: {list(df.columns)}")
return filename
if __name__ == "__main__":
create_test_excel()

View File

@@ -1,69 +0,0 @@
services:
minio:
image: minio/minio:latest
container_name: svodka_minio_dev
ports:
- "9000:9000" # API порт
- "9001:9001" # Консоль порт
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- ./minio_data:/data
restart: unless-stopped
fastapi:
image: python:3.11-slim
container_name: svodka_fastapi_dev
ports:
- "8000:8000"
environment:
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_SECURE=false
- MINIO_BUCKET=svodka-data
volumes:
# Монтируем исходный код для автоматической перезагрузки
- ./python_parser:/app
# Монтируем requirements.txt для установки зависимостей
- ./python_parser/requirements.txt:/app/requirements.txt
working_dir: /app
depends_on:
- minio
restart: unless-stopped
command: >
bash -c "
pip install --no-cache-dir -r requirements.txt &&
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
"
streamlit:
image: python:3.11-slim
container_name: svodka_streamlit_dev
ports:
- "8501:8501"
environment:
- API_BASE_URL=http://fastapi:8000
- API_PUBLIC_URL=http://localhost:8000
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_SECURE=false
- MINIO_BUCKET=svodka-data
volumes:
# Монтируем исходный код для автоматической перезагрузки
- ./streamlit_app:/app
# Монтируем requirements.txt для установки зависимостей
- ./streamlit_app/requirements.txt:/app/requirements.txt
working_dir: /app
depends_on:
- minio
- fastapi
restart: unless-stopped
command: >
bash -c "
pip install --no-cache-dir -r requirements.txt &&
streamlit run streamlit_app.py --server.port=8501 --server.address=0.0.0.0 --server.runOnSave=true
"

View File

@@ -1,5 +1,3 @@
# Продакшн конфигурация
# Для разработки используйте: docker compose -f docker-compose.dev.yml up -d
services: services:
minio: minio:
image: minio/minio:latest image: minio/minio:latest
@@ -37,13 +35,7 @@ services:
- "8501:8501" - "8501:8501"
environment: environment:
- API_BASE_URL=http://fastapi:8000 - API_BASE_URL=http://fastapi:8000
- API_PUBLIC_URL=http://localhost:8000 - DOCKER_ENV=true
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_SECURE=false
- MINIO_BUCKET=svodka-data
depends_on: depends_on:
- minio
- fastapi - fastapi
restart: unless-stopped restart: unless-stopped

17
manifest.yml Normal file
View File

@@ -0,0 +1,17 @@
applications:
- name: nin-python-parser-dev-test
buildpack: python_buildpack
health-check-type: web
services:
- logging-shared-dev
command: python /app/run_stand.py
path: .
disk_quota: 2G
memory: 4G
instances: 1
env:
MINIO_ENDPOINT: s3-region1.ppc-jv-dev.sibintek.ru
MINIO_ACCESS_KEY: 00a70fac02c1208446de
MINIO_SECRET_KEY: 1gk9tVYEEoH9ADRxb4kiAuCo6CCISdV6ie0p6oDO
MINIO_BUCKET: bucket-476684e7-1223-45ac-a101-8b5aeda487d6
MINIO_SECURE: false

Binary file not shown.

Binary file not shown.

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"]

104
python_parser/README.md Normal file
View File

@@ -0,0 +1,104 @@
# 📊 Python Parser - FastAPI + Парсеры Excel
Пакет FastAPI сервера и парсеров Excel для нефтеперерабатывающих заводов.
## 🚀 Быстрый запуск
### **Локально:**
```bash
# Установка зависимостей
pip install -r requirements.txt
# Запуск FastAPI сервера
python run_dev.py
```
### **В Docker:**
```bash
# Сборка образа
docker build -t nin-fastapi .
# Запуск контейнера
docker run -p 8000:8000 nin-fastapi
```
## 📁 Структура пакета
```
python_parser/
├── app/ # FastAPI приложение
│ ├── main.py # Основной файл приложения
│ └── schemas/ # Pydantic схемы
├── core/ # Бизнес-логика
│ ├── models.py # Модели данных
│ ├── ports.py # Интерфейсы (порты)
│ └── services.py # Сервисы
├── adapters/ # Адаптеры для внешних систем
│ ├── storage.py # MinIO адаптер
│ └── parsers/ # Парсеры Excel файлов
├── data/ # Тестовые данные
├── Dockerfile # Docker образ для FastAPI
├── requirements.txt # Зависимости Python
└── 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** - Загрузка сводок ЦА
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
- **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`
## 🏗️ Архитектура
Использует **Hexagonal Architecture (Ports and Adapters)**:
- **Порты (Ports)**: Интерфейсы для бизнес-логики
- **Адаптеры (Adapters)**: Реализации для внешних систем
- **Сервисы (Services)**: Бизнес-логика приложения
### Система геттеров парсеров
Каждый парсер может иметь несколько методов получения данных (геттеров):
- Регистрация геттеров в словаре с метаданными
- Валидация параметров для каждого геттера
- Единый интерфейс `get_value(getter_name, params)`
## 🔧 Разработка
### Добавление нового парсера:
1. Создайте файл в `adapters/parsers/`
2. Реализуйте интерфейс `ParserPort`
3. Добавьте в `core/services.py`
4. Создайте схемы в `app/schemas/`
5. Добавьте эндпоинты в `app/main.py`
### Тестирование:
```bash
# Запуск тестов
pytest
# Запуск с покрытием
pytest --cov=.
```
## 📝 Примечание
Этот пакет является частью большей системы. Для полной документации и запуска всех сервисов см. README.md в корне проекта.

View File

@@ -1,135 +0,0 @@
# Интеграция схем Pydantic с парсерами
## Обзор
Этот документ описывает решение для устранения дублирования логики между схемами Pydantic и парсерами. Теперь схемы Pydantic являются единым источником правды для определения параметров парсеров.
## Проблема
Ранее в парсерах дублировалась информация о параметрах:
```python
# В парсере
self.register_getter(
name="single_og",
method=self._get_single_og,
required_params=["id", "codes", "columns"], # Дублирование
optional_params=["search"], # Дублирование
description="Получение данных по одному ОГ"
)
# В схеме
class SvodkaPMSingleOGRequest(BaseModel):
id: OGID = Field(...) # Обязательное поле
codes: List[int] = Field(...) # Обязательное поле
columns: List[str] = Field(...) # Обязательное поле
search: Optional[str] = Field(None) # Необязательное поле
```
## Решение
### 1. Утилиты для работы со схемами
Создан модуль `core/schema_utils.py` с функциями:
- `get_required_fields_from_schema()` - извлекает обязательные поля
- `get_optional_fields_from_schema()` - извлекает необязательные поля
- `register_getter_from_schema()` - регистрирует геттер с использованием схемы
- `validate_params_with_schema()` - валидирует параметры с помощью схемы
### 2. Обновленные парсеры
Теперь парсеры используют схемы как единый источник правды:
```python
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды
register_getter_from_schema(
parser_instance=self,
getter_name="single_og",
method=self._get_single_og,
schema_class=SvodkaPMSingleOGRequest,
description="Получение данных по одному ОГ"
)
```
### 3. Валидация параметров
Методы геттеров теперь автоматически валидируют параметры:
```python
def _get_single_og(self, params: dict):
"""Получение данных по одному ОГ"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
og_id = validated_params["id"]
codes = validated_params["codes"]
columns = validated_params["columns"]
search = validated_params.get("search")
# ... остальная логика
```
## Преимущества
1. **Единый источник правды** - информация о параметрах хранится только в схемах Pydantic
2. **Автоматическая валидация** - параметры автоматически валидируются с помощью Pydantic
3. **Синхронизация** - изменения в схемах автоматически отражаются в парсерах
4. **Типобезопасность** - использование типов Pydantic обеспечивает типобезопасность
5. **Документация** - Swagger документация автоматически генерируется из схем
## Совместимость
Решение работает с:
- Pydantic v1 (через `__fields__`)
- Pydantic v2 (через `model_fields` и `is_required()`)
## Использование
### Для новых парсеров
1. Создайте схему Pydantic с нужными полями
2. Используйте `register_getter_from_schema()` для регистрации геттера
3. Используйте `validate_params_with_schema()` в методах геттеров
### Для существующих парсеров
1. Убедитесь, что у вас есть соответствующая схема Pydantic
2. Замените ручную регистрацию геттеров на `register_getter_from_schema()`
3. Добавьте валидацию параметров в методы геттеров
## Примеры
### Схема с обязательными и необязательными полями
```python
class ExampleRequest(BaseModel):
required_field: str = Field(..., description="Обязательное поле")
optional_field: Optional[str] = Field(None, description="Необязательное поле")
```
### Регистрация геттера
```python
register_getter_from_schema(
parser_instance=self,
getter_name="example_getter",
method=self._example_method,
schema_class=ExampleRequest,
description="Пример геттера"
)
```
### Валидация в методе
```python
def _example_method(self, params: dict):
validated_params = validate_params_with_schema(params, ExampleRequest)
# validated_params содержит валидированные данные
```
## Заключение
Это решение устраняет дублирование кода и обеспечивает единообразие между API схемами и парсерами. Теперь изменения в схемах автоматически отражаются в парсерах, что упрощает поддержку и развитие системы.

View File

@@ -1,88 +0,0 @@
# Парсер Сводки ПМ
## Описание
Парсер для обработки сводок ПМ (план и факт) с поддержкой множественных геттеров. Наследуется от `ParserPort` и реализует архитектуру hexagonal architecture.
## Доступные геттеры
### 1. `get_single_og`
Получение данных по одному ОГ из сводки ПМ.
**Обязательные параметры:**
- `id` (str): ID ОГ (например, "SNPZ", "KNPZ")
- `codes` (list): Список кодов показателей (например, [78, 79, 81, 82])
- `columns` (list): Список столбцов для извлечения (например, ["ПП", "БП", "СЭБ"])
**Необязательные параметры:**
- `search` (str): Значение для поиска в столбцах
**Пример использования:**
```python
parser = SvodkaPMParser()
params = {
"id": "SNPZ",
"codes": [78, 79, 81, 82],
"columns": ["ПП", "БП", "СЭБ"]
}
result = parser.get_value("get_single_og", params)
```
### 2. `get_total_ogs`
Получение данных по всем ОГ из сводки ПМ.
**Обязательные параметры:**
- `codes` (list): Список кодов показателей
- `columns` (list): Список столбцов для извлечения
**Необязательные параметры:**
- `search` (str): Значение для поиска в столбцах
**Пример использования:**
```python
parser = SvodkaPMParser()
params = {
"codes": [78, 79, 81, 82],
"columns": ["ПП", "БП", "СЭБ"]
}
result = parser.get_value("get_total_ogs", params)
```
## Поддерживаемые столбцы
- **ПП, БП**: Данные из файлов плана
- **ТБ, СЭБ, НЭБ**: Данные из файлов факта
## Структура файлов
Парсер ожидает следующую структуру файлов:
- `data/pm_fact/svodka_fact_pm_{OG_ID}.xlsx` или `.xlsm`
- `data/pm_plan/svodka_plan_pm_{OG_ID}.xlsx` или `.xlsm`
Где `{OG_ID}` - это ID ОГ (например, SNPZ, KNPZ и т.д.)
## Формат результата
Результат возвращается в формате JSON со следующей структурой:
```json
{
"ПП": {
"78": 123.45,
"79": 234.56
},
"БП": {
"78": 111.11,
"79": 222.22
},
"СЭБ": {
"78": 333.33,
"79": 444.44
}
}
```
## Обработка ошибок
- Если файл плана/факта не найден, соответствующие столбцы будут пустыми
- Если код показателя не найден, возвращается 0
- Валидация параметров выполняется автоматически

View File

@@ -1,17 +1,9 @@
from .monitoring_fuel import MonitoringFuelParser from .monitoring_fuel import MonitoringFuelParser
from .monitoring_tar import MonitoringTarParser
from .svodka_ca import SvodkaCAParser from .svodka_ca import SvodkaCAParser
from .svodka_pm import SvodkaPMParser from .svodka_pm import SvodkaPMParser
from .svodka_repair_ca import SvodkaRepairCAParser
from .statuses_repair_ca import StatusesRepairCAParser
from .oper_spravka_tech_pos import OperSpravkaTechPosParser
__all__ = [ __all__ = [
'MonitoringFuelParser', 'MonitoringFuelParser',
'MonitoringTarParser',
'SvodkaCAParser', 'SvodkaCAParser',
'SvodkaPMParser', 'SvodkaPMParser'
'SvodkaRepairCAParser',
'StatusesRepairCAParser',
'OperSpravkaTechPosParser'
] ]

View File

@@ -1,16 +1,10 @@
import pandas as pd import pandas as pd
import re import re
import zipfile import zipfile
import logging
from typing import Dict, Tuple from typing import Dict, Tuple
from core.ports import ParserPort from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
from app.schemas.monitoring_fuel import MonitoringFuelTotalRequest, MonitoringFuelMonthRequest
from adapters.pconfig import data_to_json from adapters.pconfig import data_to_json
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class MonitoringFuelParser(ParserPort): class MonitoringFuelParser(ParserPort):
"""Парсер для мониторинга топлива""" """Парсер для мониторинга топлива"""
@@ -19,130 +13,46 @@ class MonitoringFuelParser(ParserPort):
def _register_default_getters(self): def _register_default_getters(self):
"""Регистрация геттеров по умолчанию""" """Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды self.register_getter(
register_getter_from_schema( name="total_by_columns",
parser_instance=self,
getter_name="total_by_columns",
method=self._get_total_by_columns, method=self._get_total_by_columns,
schema_class=MonitoringFuelTotalRequest, required_params=["columns"],
optional_params=[],
description="Агрегация данных по колонкам" description="Агрегация данных по колонкам"
) )
register_getter_from_schema( self.register_getter(
parser_instance=self, name="month_by_code",
getter_name="month_by_code",
method=self._get_month_by_code, method=self._get_month_by_code,
schema_class=MonitoringFuelMonthRequest, required_params=["month"],
optional_params=[],
description="Получение данных за конкретный месяц" description="Получение данных за конкретный месяц"
) )
def _get_total_by_columns(self, params: dict): def _get_total_by_columns(self, params: dict):
"""Агрегация данных по колонкам""" """Агрегация по колонкам (обертка для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic columns = params["columns"]
validated_params = validate_params_with_schema(params, MonitoringFuelTotalRequest) if not columns:
raise ValueError("Отсутствуют идентификаторы столбцов")
columns = validated_params["columns"] # TODO: Переделать под новую архитектуру
df_means, _ = self.aggregate_by_columns(self.df, columns)
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки) return df_means.to_dict(orient='index')
if hasattr(self, 'data_dict') and self.data_dict is not None:
# Данные из парсинга
data_source = self.data_dict
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из загрузки - преобразуем DataFrame обратно в словарь
data_source = self._df_to_data_dict()
else:
return {}
# Агрегируем данные по колонкам
df_means, _ = self.aggregate_by_columns(data_source, columns)
# Преобразуем в JSON-совместимый формат
result = {}
for idx, row in df_means.iterrows():
result[str(idx)] = {}
for col in columns:
value = row.get(col)
if pd.isna(value) or value == float('inf') or value == float('-inf'):
result[str(idx)][col] = None
else:
result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value
return result
def _get_month_by_code(self, params: dict): def _get_month_by_code(self, params: dict):
"""Получение данных за конкретный месяц""" """Получение данных за месяц (обертка для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic month = params["month"]
validated_params = validate_params_with_schema(params, MonitoringFuelMonthRequest) if not month:
raise ValueError("Отсутствует идентификатор месяца")
month = validated_params["month"] # TODO: Переделать под новую архитектуру
df_month = self.get_month(self.df, month)
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки) return df_month.to_dict(orient='index')
if hasattr(self, 'data_dict') and self.data_dict is not None:
# Данные из парсинга
data_source = self.data_dict
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из загрузки - преобразуем DataFrame обратно в словарь
data_source = self._df_to_data_dict()
else:
return {}
# Получаем данные за конкретный месяц
df_month = self.get_month(data_source, month)
# Преобразуем в JSON-совместимый формат
result = {}
for idx, row in df_month.iterrows():
result[str(idx)] = {}
for col in df_month.columns:
value = row[col]
if pd.isna(value) or value == float('inf') or value == float('-inf'):
result[str(idx)][col] = None
else:
result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value
return result
def _df_to_data_dict(self):
"""Преобразование DataFrame обратно в словарь данных"""
if not hasattr(self, 'df') or self.df is None or self.df.empty:
return {}
data_dict = {}
# Группируем данные по месяцам
for _, row in self.df.iterrows():
month = row.get('month')
data = row.get('data')
if month and data is not None:
data_dict[month] = data
return data_dict
def parse(self, file_path: str, params: dict) -> pd.DataFrame: def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame""" """Парсинг файла и возврат DataFrame"""
# Парсим данные и сохраняем словарь для использования в геттерах # Сохраняем DataFrame для использования в геттерах
self.data_dict = self.parse_monitoring_fuel_files(file_path, params) self.df = self.parse_monitoring_fuel_files(file_path, params)
# Преобразуем словарь в DataFrame для совместимости с services.py
if self.data_dict:
# Создаем DataFrame с информацией о месяцах и данных
data_rows = []
for month, df_data in self.data_dict.items():
if df_data is not None and not df_data.empty:
data_rows.append({
'month': month,
'rows_count': len(df_data),
'data': df_data
})
if data_rows:
df = pd.DataFrame(data_rows)
self.df = df
return df
# Если данных нет, возвращаем пустой DataFrame
self.df = pd.DataFrame()
return self.df return self.df
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]: def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
@@ -161,19 +71,19 @@ class MonitoringFuelParser(ParserPort):
if len(candidates) == 1: if len(candidates) == 1:
file = candidates[0] file = candidates[0]
logger.info(f'Загрузка {file}') print(f'Загрузка {file}')
with zip_ref.open(file) as excel_file: with zip_ref.open(file) as excel_file:
try: try:
df = self.parse_single(excel_file, 'Мониторинг потребления') df = self.parse_single(excel_file, 'Мониторинг потребления')
df_monitorings[mm] = df df_monitorings[mm] = df
logger.info(f"✅ Данные за месяц {mm} загружены") print(f"✅ Данные за месяц {mm} загружены")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при загрузке файла {file_temp}: {e}") print(f"Ошибка при загрузке файла {file_temp}: {e}")
else: else:
logger.warning(f"⚠️ Файл не найден: {file_temp}") print(f"⚠️ Файл не найден: {file_temp}")
return df_monitorings return df_monitorings
@@ -191,7 +101,7 @@ class MonitoringFuelParser(ParserPort):
# Ищем строку, где хотя бы в одном столбце встречается искомое значение # Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows(): for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any(): if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx + 1 # возвращаем индекс строки (0-based) return idx + 1 # возвращаем индекс строки (0-based)
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.") raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
@@ -233,15 +143,11 @@ class MonitoringFuelParser(ParserPort):
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) # This line was removed as per new_code # df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
# Временно используем name как id pass # Placeholder for new_code
df_full['id'] = df_full['name']
else:
# Если нет колонки name, создаем id из индекса
df_full['id'] = df_full.index
# Устанавливаем id как индекс # Устанавливаем id как индекс
df_full.set_index('id', inplace=True) df_full.set_index('id', inplace=True)
logger.debug(f"Окончательное количество столбцов: {len(df_full.columns)}") print(f"Окончательное количество столбцов: {len(df_full.columns)}")
return df_full return df_full
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]: def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
@@ -254,7 +160,7 @@ class MonitoringFuelParser(ParserPort):
for file_key, df in df_dict.items(): for file_key, df in df_dict.items():
if col not in df.columns: if col not in df.columns:
logger.warning(f"Колонка '{col}' не найдена в {file_key}, пропускаем.") print(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
continue continue
# Берём колонку, оставляем id как индекс # Берём колонку, оставляем id как индекс
@@ -306,7 +212,7 @@ class MonitoringFuelParser(ParserPort):
for file, df in df_dict.items(): for file, df in df_dict.items():
if column not in df.columns: if column not in df.columns:
logger.warning(f"Колонка '{column}' не найдена в {file}, пропускаем.") print(f"Колонка '{column}' не найдена в {file}, пропускаем.")
continue continue
# Берём колонку и сохраняем как Series с именем месяца # Берём колонку и сохраняем как Series с именем месяца

View File

@@ -1,306 +0,0 @@
import os
import zipfile
import tempfile
import pandas as pd
import logging
from typing import Dict, Any, List
from core.ports import ParserPort
from adapters.pconfig import find_header_row, SNPZ_IDS, data_to_json
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class MonitoringTarParser(ParserPort):
"""Парсер для мониторинга ТЭР (топливно-энергетических ресурсов)"""
name = "monitoring_tar"
def __init__(self):
super().__init__()
self.data_dict = {}
self.df = None
# Регистрируем геттеры
self.register_getter('get_tar_data', self._get_tar_data_wrapper, required_params=['mode'])
self.register_getter('get_tar_full_data', self._get_tar_full_data_wrapper, required_params=[])
def parse(self, file_path: str, params: Dict[str, Any] = None) -> pd.DataFrame:
"""Парсит ZIP архив с файлами мониторинга ТЭР"""
logger.debug(f"🔍 MonitoringTarParser.parse вызван с файлом: {file_path}")
if not file_path.endswith('.zip'):
raise ValueError("MonitoringTarParser поддерживает только ZIP архивы")
# Обрабатываем ZIP архив
result = self._parse_zip_archive(file_path)
# Конвертируем результат в DataFrame для совместимости с ReportService
if result:
data_list = []
for id, data in result.items():
data_list.append({
'id': id,
'data': data,
'records_count': len(data.get('total', [])) + len(data.get('last_day', []))
})
df = pd.DataFrame(data_list)
logger.debug(f"🔍 Создан DataFrame с {len(df)} записями")
return df
else:
logger.debug("🔍 Возвращаем пустой DataFrame")
return pd.DataFrame()
def _parse_zip_archive(self, zip_path: str) -> Dict[str, Any]:
"""Парсит ZIP архив с файлами мониторинга ТЭР"""
logger.info(f"📦 Обработка ZIP архива: {zip_path}")
with tempfile.TemporaryDirectory() as temp_dir:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Ищем файлы мониторинга ТЭР
tar_files = []
for root, dirs, files in os.walk(temp_dir):
for file in files:
# Поддерживаем файлы svodka_tar_*.xlsx (основные) и monitoring_*.xlsm (альтернативные)
if (file.startswith('svodka_tar_') and file.endswith('.xlsx')) or (file.startswith('monitoring_') and file.endswith('.xlsm')):
tar_files.append(os.path.join(root, file))
if not tar_files:
raise ValueError("В архиве не найдены файлы мониторинга ТЭР")
logger.info(f"📁 Найдено {len(tar_files)} файлов мониторинга ТЭР")
# Обрабатываем каждый файл
all_data = {}
for file_path in tar_files:
logger.info(f"📁 Обработка файла: {file_path}")
# Извлекаем номер месяца из имени файла
filename = os.path.basename(file_path)
month_str = self._extract_month_from_filename(filename)
logger.debug(f"📅 Месяц: {month_str}")
# Парсим файл
file_data = self._parse_single_file(file_path, month_str)
if file_data:
all_data.update(file_data)
return all_data
def _extract_month_from_filename(self, filename: str) -> str:
"""Извлекает номер месяца из имени файла"""
# Для файлов типа svodka_tar_SNPZ_01.xlsx или monitoring_SNPZ_01.xlsm
parts = filename.split('_')
if len(parts) >= 3:
month_part = parts[-1].split('.')[0] # Убираем расширение
if month_part.isdigit():
return month_part
return "01" # По умолчанию
def _parse_single_file(self, file_path: str, month_str: str) -> Dict[str, Any]:
"""Парсит один файл мониторинга ТЭР"""
try:
excel_file = pd.ExcelFile(file_path)
available_sheets = excel_file.sheet_names
except Exception as e:
logger.error(f"Не удалось открыть Excel-файл {file_path}: {e}")
return {}
# Словарь для хранения данных: id -> {'total': [], 'last_day': []}
df_svodka_tar = {}
# Определяем тип файла и обрабатываем соответственно
filename = os.path.basename(file_path)
if filename.startswith('svodka_tar_'):
# Обрабатываем файлы svodka_tar_*.xlsx с SNPZ_IDS
for name, id in SNPZ_IDS.items():
if name not in available_sheets:
logger.warning(f"🟡 Лист '{name}' отсутствует в файле {file_path}")
continue
# Парсим оба типа строк
result = self._parse_monitoring_tar_single(file_path, name, month_str)
# Инициализируем структуру для id
if id not in df_svodka_tar:
df_svodka_tar[id] = {'total': [], 'last_day': []}
if isinstance(result['total'], pd.DataFrame) and not result['total'].empty:
df_svodka_tar[id]['total'].append(result['total'])
if isinstance(result['last_day'], pd.DataFrame) and not result['last_day'].empty:
df_svodka_tar[id]['last_day'].append(result['last_day'])
elif filename.startswith('monitoring_'):
# Обрабатываем файлы monitoring_*.xlsm с альтернативными листами
monitoring_sheets = {
'Мониторинг потребления': 'SNPZ.MONITORING',
'Исходные данные': 'SNPZ.SOURCE_DATA'
}
for sheet_name, id in monitoring_sheets.items():
if sheet_name not in available_sheets:
logger.warning(f"🟡 Лист '{sheet_name}' отсутствует в файле {file_path}")
continue
# Парсим оба типа строк
result = self._parse_monitoring_tar_single(file_path, sheet_name, month_str)
# Инициализируем структуру для id
if id not in df_svodka_tar:
df_svodka_tar[id] = {'total': [], 'last_day': []}
if isinstance(result['total'], pd.DataFrame) and not result['total'].empty:
df_svodka_tar[id]['total'].append(result['total'])
if isinstance(result['last_day'], pd.DataFrame) and not result['last_day'].empty:
df_svodka_tar[id]['last_day'].append(result['last_day'])
# Агрегация: объединяем списки в DataFrame
for id, data in df_svodka_tar.items():
if data['total']:
df_svodka_tar[id]['total'] = pd.concat(data['total'], ignore_index=True)
else:
df_svodka_tar[id]['total'] = pd.DataFrame()
if data['last_day']:
df_svodka_tar[id]['last_day'] = pd.concat(data['last_day'], ignore_index=True)
else:
df_svodka_tar[id]['last_day'] = pd.DataFrame()
logger.info(f"✅ Агрегировано: {len(df_svodka_tar[id]['total'])} 'total' и "
f"{len(df_svodka_tar[id]['last_day'])} 'last_day' записей для id='{id}'")
return df_svodka_tar
def _parse_monitoring_tar_single(self, file: str, sheet: str, month_str: str) -> Dict[str, Any]:
"""Парсит один файл и лист"""
try:
# Проверяем наличие листа
if sheet not in pd.ExcelFile(file).sheet_names:
logger.warning(f"🟡 Лист '{sheet}' не найден в файле {file}")
return {'total': None, 'last_day': None}
# Определяем номер заголовка в зависимости от типа файла
filename = os.path.basename(file)
if filename.startswith('svodka_tar_'):
# Для файлов svodka_tar_*.xlsx ищем заголовок по значению "1"
header_num = find_header_row(file, sheet, search_value="1")
if header_num is None:
logger.error(f"Не найдена строка с заголовком '1' в файле {file}, лист '{sheet}'")
return {'total': None, 'last_day': None}
elif filename.startswith('monitoring_'):
# Для файлов monitoring_*.xlsm заголовок находится в строке 5
header_num = 5
else:
logger.error(f"❌ Неизвестный тип файла: {filename}")
return {'total': None, 'last_day': None}
logger.debug(f"🔍 Используем заголовок в строке {header_num} для листа '{sheet}'")
# Читаем с двумя уровнями заголовков
df = pd.read_excel(
file,
sheet_name=sheet,
header=header_num,
index_col=None
)
# Убираем мультииндекс: оставляем первый уровень
df.columns = df.columns.get_level_values(0)
# Удаляем строки, где все значения — NaN
df = df.dropna(how='all').reset_index(drop=True)
if df.empty:
logger.warning(f"🟡 Нет данных после очистки в файле {file}, лист '{sheet}'")
return {'total': None, 'last_day': None}
# === 1. Обработка строки "Всего" ===
first_col = df.columns[0]
mask_total = df[first_col].astype(str).str.strip() == "Всего"
df_total = df[mask_total].copy()
if not df_total.empty:
# Заменяем "Всего" на номер месяца в первой колонке
df_total.loc[:, first_col] = df_total[first_col].astype(str).str.replace("Всего", month_str, regex=False)
df_total = df_total.reset_index(drop=True)
else:
df_total = pd.DataFrame()
# === 2. Обработка последней строки (не пустая) ===
# Берём последнюю строку из исходного df (не включая "Всего", если она внизу)
# Исключим строку "Всего" из "последней строки", если она есть
df_no_total = df[~mask_total].dropna(how='all')
if not df_no_total.empty:
df_last_day = df_no_total.tail(1).copy()
df_last_day = df_last_day.reset_index(drop=True)
else:
df_last_day = pd.DataFrame()
return {'total': df_total, 'last_day': df_last_day}
except Exception as e:
logger.error(f"❌ Ошибка при обработке файла {file}, лист '{sheet}': {e}")
return {'total': None, 'last_day': None}
def _get_tar_data_wrapper(self, params: Dict[str, Any] = None) -> str:
"""Обертка для получения данных мониторинга ТЭР с фильтрацией по режиму"""
logger.debug(f"🔍 _get_tar_data_wrapper вызван с параметрами: {params}")
# Получаем режим из параметров
mode = params.get('mode', 'total') if params else 'total'
# Фильтруем данные по режиму
filtered_data = {}
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из MinIO
for _, row in self.df.iterrows():
id = row['id']
data = row['data']
if isinstance(data, dict) and mode in data:
filtered_data[id] = data[mode]
else:
filtered_data[id] = pd.DataFrame()
elif hasattr(self, 'data_dict') and self.data_dict:
# Локальные данные
for id, data in self.data_dict.items():
if isinstance(data, dict) and mode in data:
filtered_data[id] = data[mode]
else:
filtered_data[id] = pd.DataFrame()
# Конвертируем в JSON
try:
result_json = data_to_json(filtered_data)
return result_json
except Exception as e:
logger.error(f"❌ Ошибка при конвертации данных в JSON: {e}")
return "{}"
def _get_tar_full_data_wrapper(self, params: Dict[str, Any] = None) -> str:
"""Обертка для получения всех данных мониторинга ТЭР"""
logger.debug(f"🔍 _get_tar_full_data_wrapper вызван с параметрами: {params}")
# Получаем все данные
full_data = {}
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из MinIO
for _, row in self.df.iterrows():
id = row['id']
data = row['data']
full_data[id] = data
elif hasattr(self, 'data_dict') and self.data_dict:
# Локальные данные
full_data = self.data_dict
# Конвертируем в JSON
try:
result_json = data_to_json(full_data)
return result_json
except Exception as e:
logger.error(f"❌ Ошибка при конвертации данных в JSON: {e}")
return "{}"

View File

@@ -1,285 +0,0 @@
import os
import tempfile
import zipfile
import pandas as pd
import logging
from typing import Dict, Any, List
from datetime import datetime
from core.ports import ParserPort
from adapters.pconfig import find_header_row, get_object_by_name, data_to_json
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class OperSpravkaTechPosParser(ParserPort):
"""Парсер для операционных справок технологических позиций"""
name = "oper_spravka_tech_pos"
def __init__(self):
super().__init__()
self.data_dict = {}
self.df = None
# Регистрируем геттер
self.register_getter('get_tech_pos', self._get_tech_pos_wrapper, required_params=['id'])
def parse(self, file_path: str, params: Dict[str, Any] = None) -> pd.DataFrame:
"""Парсит ZIP архив с файлами операционных справок технологических позиций"""
logger.debug(f"🔍 OperSpravkaTechPosParser.parse вызван с файлом: {file_path}")
if not file_path.endswith('.zip'):
raise ValueError("OperSpravkaTechPosParser поддерживает только ZIP архивы")
# Обрабатываем ZIP архив
result = self._parse_zip_archive(file_path)
# Конвертируем результат в DataFrame для совместимости с ReportService
if result:
data_list = []
for id, data in result.items():
if data is not None and not data.empty:
records = data.to_dict(orient='records')
data_list.append({
'id': id,
'data': records,
'records_count': len(records)
})
df = pd.DataFrame(data_list)
logger.debug(f"🔍 Создан DataFrame с {len(df)} записями")
return df
else:
logger.debug("🔍 Возвращаем пустой DataFrame")
return pd.DataFrame()
def _parse_zip_archive(self, zip_path: str) -> Dict[str, pd.DataFrame]:
"""Парсит ZIP архив с файлами операционных справок"""
logger.info(f"📦 Обработка ZIP архива: {zip_path}")
with tempfile.TemporaryDirectory() as temp_dir:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Ищем файлы операционных справок
tech_pos_files = []
for root, dirs, files in os.walk(temp_dir):
for file in files:
if (file.startswith('oper_spavka_tech_pos_') or
file.startswith('oper_spravka_tech_pos_')) and file.endswith(('.xlsx', '.xls', '.xlsm')):
tech_pos_files.append(os.path.join(root, file))
if not tech_pos_files:
raise ValueError("В архиве не найдены файлы операционных справок технологических позиций")
logger.info(f"📁 Найдено {len(tech_pos_files)} файлов операционных справок")
# Обрабатываем каждый файл
all_data = {}
for file_path in tech_pos_files:
logger.info(f"📁 Обработка файла: {file_path}")
# Извлекаем ID ОГ из имени файла
filename = os.path.basename(file_path)
og_id = self._extract_og_id_from_filename(filename)
logger.debug(f"🏭 ОГ ID: {og_id}")
# Парсим файл
file_data = self._parse_single_file(file_path)
if file_data:
all_data.update(file_data)
return all_data
def _extract_og_id_from_filename(self, filename: str) -> str:
"""Извлекает ID ОГ из имени файла"""
# Для файлов типа oper_spavka_tech_pos_SNPZ.xlsx
parts = filename.split('_')
if len(parts) >= 4:
og_id = parts[-1].split('.')[0] # Убираем расширение
return og_id
return "UNKNOWN"
def _parse_single_file(self, file_path: str) -> Dict[str, pd.DataFrame]:
"""Парсит один файл операционной справки"""
try:
# Находим актуальный лист
actual_sheet = self._find_actual_sheet_num(file_path)
logger.debug(f"📅 Актуальный лист: {actual_sheet}")
# Находим заголовок
header_row = self._find_header_row(file_path, actual_sheet)
logger.debug(f"📋 Заголовок найден в строке {header_row}")
# Парсим данные
df = self._parse_tech_pos_data(file_path, actual_sheet, header_row)
if df is not None and not df.empty:
# Извлекаем ID ОГ из имени файла
filename = os.path.basename(file_path)
og_id = self._extract_og_id_from_filename(filename)
return {og_id: df}
else:
logger.warning(f"⚠️ Нет данных в файле {file_path}")
return {}
except Exception as e:
logger.error(f"❌ Ошибка при обработке файла {file_path}: {e}")
return {}
def _find_actual_sheet_num(self, file_path: str) -> str:
"""Поиск номера актуального листа"""
current_day = datetime.now().day
current_month = datetime.now().month
actual_sheet = f"{current_day:02d}"
try:
# Читаем все листы от 1 до текущего дня
all_sheets = {}
for day in range(1, current_day + 1):
sheet_num = f"{day:02d}"
try:
df_temp = pd.read_excel(file_path, sheet_name=sheet_num, usecols=[1], nrows=2, header=None)
all_sheets[sheet_num] = df_temp
except:
continue
# Идем от текущего дня к 1
for day in range(current_day, 0, -1):
sheet_num = f"{day:02d}"
if sheet_num in all_sheets:
df_temp = all_sheets[sheet_num]
if df_temp.shape[0] > 1:
date_str = df_temp.iloc[1, 0] # B2
if pd.notna(date_str):
try:
date = pd.to_datetime(date_str)
# Проверяем совпадение месяца даты с текущим месяцем
if date.month == current_month:
actual_sheet = sheet_num
break
except:
continue
except Exception as e:
logger.warning(f"⚠️ Ошибка при поиске актуального листа: {e}")
return actual_sheet
def _find_header_row(self, file_path: str, sheet_name: str, search_value: str = "Загрузка основных процессов") -> int:
"""Определение индекса заголовка в Excel по ключевому слову"""
try:
# Читаем первый столбец
df_temp = pd.read_excel(file_path, sheet_name=sheet_name, usecols=[0])
# Ищем строку с искомым значением
for idx, row in df_temp.iterrows():
if row.astype(str).str.contains(search_value, case=False, regex=False).any():
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx + 1 # возвращаем индекс строки (0-based), который будет использован как `header=`
raise ValueError(f"Не найдена строка с заголовком '{search_value}'.")
except Exception as e:
logger.error(f"❌ Ошибка при поиске заголовка: {e}")
return 0
def _parse_tech_pos_data(self, file_path: str, sheet_name: str, header_row: int) -> pd.DataFrame:
"""Парсинг данных технологических позиций"""
try:
valid_processes = ['Первичная переработка', 'Гидроочистка топлив', 'Риформирование', 'Изомеризация']
df_temp = pd.read_excel(
file_path,
sheet_name=sheet_name,
header=header_row + 1, # Исправлено: добавляем +1 как в оригинале
usecols=range(1, 5)
)
logger.debug(f"🔍 Прочитано {len(df_temp)} строк из Excel")
logger.debug(f"🔍 Колонки: {list(df_temp.columns)}")
# Фильтруем по валидным процессам
df_cleaned = df_temp[
df_temp['Процесс'].str.strip().isin(valid_processes) &
df_temp['Процесс'].notna()
].copy()
logger.debug(f"🔍 После фильтрации осталось {len(df_cleaned)} строк")
if df_cleaned.empty:
logger.warning("⚠️ Нет данных после фильтрации по процессам")
logger.debug(f"🔍 Доступные процессы в данных: {df_temp['Процесс'].unique()}")
return pd.DataFrame()
df_cleaned['Процесс'] = df_cleaned['Процесс'].astype(str).str.strip()
# Добавляем ID установки
if 'Установка' in df_cleaned.columns:
df_cleaned['id'] = df_cleaned['Установка'].apply(get_object_by_name)
logger.debug(f"🔍 Добавлены ID установок: {df_cleaned['id'].unique()}")
else:
logger.warning("⚠️ Колонка 'Установка' не найдена")
logger.info(f"✅ Получено {len(df_cleaned)} записей")
return df_cleaned
except Exception as e:
logger.error(f"❌ Ошибка при парсинге данных: {e}")
return pd.DataFrame()
def _get_tech_pos_wrapper(self, params: Dict[str, Any] = None) -> str:
"""Обертка для получения данных технологических позиций"""
logger.debug(f"🔍 _get_tech_pos_wrapper вызван с параметрами: {params}")
# Получаем ID ОГ из параметров
og_id = params.get('id') if params else None
if not og_id:
logger.error("Не указан ID ОГ")
return "{}"
# Получаем данные
tech_pos_data = {}
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из MinIO
logger.debug(f"🔍 Ищем данные для ОГ '{og_id}' в DataFrame с {len(self.df)} записями")
available_ogs = self.df['id'].tolist()
logger.debug(f"🔍 Доступные ОГ в данных: {available_ogs}")
for _, row in self.df.iterrows():
if row['id'] == og_id:
tech_pos_data = row['data']
logger.info(f"✅ Найдены данные для ОГ '{og_id}': {len(tech_pos_data)} записей")
break
else:
logger.warning(f"❌ Данные для ОГ '{og_id}' не найдены")
elif hasattr(self, 'data_dict') and self.data_dict:
# Локальные данные
logger.debug(f"🔍 Ищем данные для ОГ '{og_id}' в data_dict")
available_ogs = list(self.data_dict.keys())
logger.debug(f"🔍 Доступные ОГ в data_dict: {available_ogs}")
if og_id in self.data_dict:
tech_pos_data = self.data_dict[og_id].to_dict(orient='records')
logger.info(f"✅ Найдены данные для ОГ '{og_id}': {len(tech_pos_data)} записей")
else:
logger.warning(f"❌ Данные для ОГ '{og_id}' не найдены в data_dict")
# Конвертируем в список записей
try:
if isinstance(tech_pos_data, pd.DataFrame):
# Если это DataFrame, конвертируем в список словарей
result_list = tech_pos_data.to_dict(orient='records')
logger.debug(f"🔍 Конвертировано в список: {len(result_list)} записей")
return result_list
elif isinstance(tech_pos_data, list):
# Если уже список, возвращаем как есть
logger.debug(f"🔍 Уже список: {len(tech_pos_data)} записей")
return tech_pos_data
else:
logger.warning(f"🔍 Неожиданный тип данных: {type(tech_pos_data)}")
return []
except Exception as e:
logger.error(f"❌ Ошибка при конвертации данных: {e}")
return []

View File

@@ -1,345 +0,0 @@
import pandas as pd
import os
import tempfile
import zipfile
import logging
from typing import Dict, Any, List, Tuple, Optional
from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
from app.schemas.statuses_repair_ca import StatusesRepairCARequest
from adapters.pconfig import find_header_row, get_og_by_name, data_to_json
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class StatusesRepairCAParser(ParserPort):
"""Парсер для статусов ремонта СА"""
name = "Статусы ремонта СА"
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
register_getter_from_schema(
parser_instance=self,
getter_name="get_repair_statuses",
method=self._get_repair_statuses_wrapper,
schema_class=StatusesRepairCARequest,
description="Получение статусов ремонта по ОГ и ключам"
)
def parse(self, file_path: str, params: dict) -> Dict[str, Any]:
"""Парсинг файла статусов ремонта СА"""
logger.debug(f"🔍 StatusesRepairCAParser.parse вызван с файлом: {file_path}")
try:
# Определяем тип файла
if file_path.endswith('.zip'):
return self._parse_zip_file(file_path)
elif file_path.endswith(('.xlsx', '.xls')):
return self._parse_excel_file(file_path)
else:
raise ValueError(f"Неподдерживаемый формат файла: {file_path}")
except Exception as e:
logger.error(f"❌ Ошибка при парсинге файла {file_path}: {e}")
raise
def _parse_zip_file(self, zip_path: str) -> Dict[str, Any]:
"""Парсинг ZIP архива"""
with tempfile.TemporaryDirectory() as temp_dir:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Ищем Excel файл в архиве
excel_files = []
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file.endswith(('.xlsx', '.xls')):
excel_files.append(os.path.join(root, file))
if not excel_files:
raise ValueError("В архиве не найдено Excel файлов")
# Берем первый найденный Excel файл
excel_file = excel_files[0]
logger.debug(f"🔍 Найден Excel файл в архиве: {excel_file}")
return self._parse_excel_file(excel_file)
def _parse_excel_file(self, file_path: str) -> Dict[str, Any]:
"""Парсинг Excel файла"""
logger.debug(f"🔍 Парсинг Excel файла: {file_path}")
# Парсим данные
df_statuses = self._parse_statuses_repair_ca(file_path, 0)
if df_statuses.empty:
logger.warning("⚠️ Нет данных после парсинга")
return {"data": [], "records_count": 0}
# Преобразуем в список словарей для хранения
data_list = self._data_to_structured_json(df_statuses)
result = {
"data": data_list,
"records_count": len(data_list)
}
# Устанавливаем данные в парсер для использования в геттерах
self.data_dict = result
logger.info(f"✅ Парсинг завершен. Получено {len(data_list)} записей")
return result
def _parse_statuses_repair_ca(self, file: str, sheet: int, header_num: Optional[int] = None) -> pd.DataFrame:
"""Парсинг отчетов статусов ремонта"""
# === ШАГ 1: Создание MultiIndex ===
columns_level_1 = [
'id',
'ОГ',
'Дата начала ремонта',
'Готовность к КР',
'Отставание / опережение подготовки к КР',
'Заключение договоров на СМР',
'Поставка МТР'
]
sub_columns_cmp = {
'ДВ': ['всего', 'плановая дата', 'факт', '%'],
'Сметы': ['всего', 'плановая дата', 'факт', '%'],
'Формирование лотов': ['всего', 'плановая дата', 'факт', '%'],
'Договор': ['всего', 'плановая дата', 'факт', '%']
}
sub_columns_mtp = {
'Выполнение плана на текущую дату': ['инициирования закупок', 'заключения договоров', 'поставки'],
'На складе, позиций': ['всего', 'поставлено', '%', 'динамика за прошедшую неделю, поз.']
}
# Формируем MultiIndex — ВСЕ кортежи длиной 3
cols = []
for col1 in columns_level_1:
if col1 == 'id':
cols.append((col1, '', ''))
elif col1 == 'ОГ':
cols.append((col1, '', ''))
elif col1 == 'Дата начала ремонта':
cols.append((col1, '', ''))
elif col1 == 'Готовность к КР':
cols.extend([(col1, 'План', ''), (col1, 'Факт', '')])
elif col1 == 'Отставание / опережение подготовки к КР':
cols.extend([
(col1, 'Отставание / опережение', ''),
(col1, 'Динамика за прошедшую неделю', '')
])
elif col1 == 'Заключение договоров на СМР':
for subcol, sub_sub_cols in sub_columns_cmp.items():
for ssc in sub_sub_cols:
cols.append((col1, subcol, ssc))
elif col1 == 'Поставка МТР':
for subcol, sub_sub_cols in sub_columns_mtp.items():
for ssc in sub_sub_cols:
cols.append((col1, subcol, ssc))
else:
cols.append((col1, '', ''))
# Создаем MultiIndex
multi_index = pd.MultiIndex.from_tuples(cols, names=['Level1', 'Level2', 'Level3'])
# === ШАГ 2: Читаем данные из Excel ===
if header_num is None:
header_num = find_header_row(file, sheet, search_value="ОГ")
df_data = pd.read_excel(
file,
skiprows=header_num + 3,
header=None,
index_col=0,
engine='openpyxl'
)
# Убираем строки с пустыми данными
df_data.dropna(how='all', inplace=True)
# Применяем функцию get_og_by_name для 'id'
df_data['id'] = df_data.iloc[:, 0].copy()
df_data['id'] = df_data['id'].apply(get_og_by_name)
# Перемещаем 'id' на первое место
cols = ['id'] + [col for col in df_data.columns if col != 'id']
df_data = df_data[cols]
# Удаляем строки с пустым id
df_data = df_data.dropna(subset=['id'])
df_data = df_data[df_data['id'].astype(str).str.strip() != '']
# Сбрасываем индекс
df_data = df_data.reset_index(drop=True)
# Выбираем 4-ю колонку (индекс 3) для фильтрации
col_index = 3
numeric_series = pd.to_numeric(df_data.iloc[:, col_index], errors='coerce')
# Фильтруем: оставляем только строки, где значение — число
mask = pd.notna(numeric_series)
df_data = df_data[mask].copy()
# === ШАГ 3: Применяем MultiIndex к данным ===
df_data.columns = multi_index
return df_data
def _data_to_structured_json(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
"""Преобразование DataFrame с MultiIndex в структурированный JSON"""
if df.empty:
return []
result_list = []
for idx, row in df.iterrows():
result = {}
for col in df.columns:
value = row[col]
# Пропускаем NaN
if pd.isna(value):
value = None
# Распаковываем уровни
level1, level2, level3 = col
# Убираем пустые/неинформативные значения
level1 = str(level1).strip() if level1 else ""
level2 = str(level2).strip() if level2 else None
level3 = str(level3).strip() if level3 else None
# Обработка id и ОГ — выносим на верх
if level1 == "id":
result["id"] = value
elif level1 == "ОГ":
result["name"] = value
else:
# Группируем по Level1
if level1 not in result:
result[level1] = {}
# Вложенные уровни
if level2 and level3:
if level2 not in result[level1]:
result[level1][level2] = {}
result[level1][level2][level3] = value
elif level2:
result[level1][level2] = value
else:
result[level1] = value
result_list.append(result)
return result_list
def _get_repair_statuses_wrapper(self, params: dict):
"""Обертка для получения статусов ремонта"""
logger.debug(f"🔍 _get_repair_statuses_wrapper вызван с параметрами: {params}")
# Валидация параметров
validated_params = validate_params_with_schema(params, StatusesRepairCARequest)
ids = validated_params.get('ids')
keys = validated_params.get('keys')
logger.debug(f"🔍 Запрошенные ОГ: {ids}")
logger.debug(f"🔍 Запрошенные ключи: {keys}")
# Получаем данные из парсера
if hasattr(self, 'df') and self.df is not None:
# Данные загружены из MinIO
if isinstance(self.df, dict):
# Это словарь (как в других парсерах)
data_source = self.df.get('data', [])
elif hasattr(self.df, 'columns') and 'data' in self.df.columns:
# Это DataFrame
data_source = []
for _, row in self.df.iterrows():
if row['data']:
data_source.extend(row['data'])
else:
data_source = []
elif hasattr(self, 'data_dict') and self.data_dict:
# Данные из локального парсинга
data_source = self.data_dict.get('data', [])
else:
logger.warning("⚠️ Нет данных в парсере")
return []
logger.debug(f"🔍 Используем данные с {len(data_source)} записями")
# Фильтруем данные
filtered_data = self._filter_statuses_data(data_source, ids, keys)
logger.debug(f"🔍 Отфильтровано {len(filtered_data)} записей")
return filtered_data
def _filter_statuses_data(self, data_source: List[Dict], ids: Optional[List[str]], keys: Optional[List[List[str]]]) -> List[Dict]:
"""Фильтрация данных по ОГ и ключам"""
if not data_source:
return []
# Если не указаны фильтры, возвращаем все данные
if not ids and not keys:
return data_source
filtered_data = []
for item in data_source:
# Фильтр по ОГ
if ids is not None:
item_id = item.get('id')
if item_id not in ids:
continue
# Если указаны ключи, извлекаем только нужные поля
if keys is not None:
filtered_item = self._extract_keys_from_item(item, keys)
if filtered_item:
filtered_data.append(filtered_item)
else:
filtered_data.append(item)
return filtered_data
def _extract_keys_from_item(self, item: Dict[str, Any], keys: List[List[str]]) -> Dict[str, Any]:
"""Извлечение указанных ключей из элемента"""
result = {}
# Всегда добавляем id и name
if 'id' in item:
result['id'] = item['id']
if 'name' in item:
result['name'] = item['name']
# Извлекаем указанные ключи
for key_path in keys:
if not key_path:
continue
value = item
for key in key_path:
if isinstance(value, dict) and key in value:
value = value[key]
else:
value = None
break
if value is not None:
# Строим вложенную структуру
current = result
for i, key in enumerate(key_path):
if i == len(key_path) - 1:
current[key] = value
else:
if key not in current:
current[key] = {}
current = current[key]
return result

View File

@@ -1,15 +1,9 @@
import pandas as pd import pandas as pd
import numpy as np import numpy as np
import logging
from core.ports import ParserPort from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
from app.schemas.svodka_ca import SvodkaCARequest
from adapters.pconfig import get_og_by_name from adapters.pconfig import get_og_by_name
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class SvodkaCAParser(ParserPort): class SvodkaCAParser(ParserPort):
"""Парсер для сводок СА""" """Парсер для сводок СА"""
@@ -18,208 +12,146 @@ class SvodkaCAParser(ParserPort):
def _register_default_getters(self): def _register_default_getters(self):
"""Регистрация геттеров по умолчанию""" """Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды self.register_getter(
register_getter_from_schema( name="get_data",
parser_instance=self,
getter_name="get_ca_data",
method=self._get_data_wrapper, method=self._get_data_wrapper,
schema_class=SvodkaCARequest, required_params=["modes", "tables"],
optional_params=[],
description="Получение данных по режимам и таблицам" description="Получение данных по режимам и таблицам"
) )
def _get_data_wrapper(self, params: dict): def _get_data_wrapper(self, params: dict):
"""Получение данных по режимам и таблицам""" """Обертка для получения данных (для совместимости)"""
logger.debug(f"🔍 _get_data_wrapper вызван с параметрами: {params}") modes = params["modes"]
tables = params["tables"]
# Валидируем параметры с помощью схемы Pydantic if not isinstance(modes, list):
validated_params = validate_params_with_schema(params, SvodkaCARequest) raise ValueError("Поле 'modes' должно быть списком")
if not isinstance(tables, list):
modes = validated_params["modes"] raise ValueError("Поле 'tables' должно быть списком")
tables = validated_params["tables"]
logger.debug(f"🔍 Запрошенные режимы: {modes}")
logger.debug(f"🔍 Запрошенные таблицы: {tables}")
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
if hasattr(self, 'data_dict') and self.data_dict is not None:
# Данные из парсинга
data_source = self.data_dict
logger.debug(f"🔍 Используем data_dict с режимами: {list(data_source.keys())}")
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из загрузки - преобразуем DataFrame обратно в словарь
data_source = self._df_to_data_dict()
logger.debug(f"🔍 Используем df, преобразованный в data_dict с режимами: {list(data_source.keys())}")
else:
logger.warning(f"🔍 Нет данных! data_dict={getattr(self, 'data_dict', 'None')}, df={getattr(self, 'df', 'None')}")
return {}
# Фильтруем данные по запрошенным режимам и таблицам
result_data = {}
for mode in modes:
if mode in data_source:
result_data[mode] = {}
available_tables = list(data_source[mode].keys())
logger.debug(f"🔍 Режим '{mode}' содержит таблицы: {available_tables}")
for table_name, table_data in data_source[mode].items():
# Ищем таблицы по частичному совпадению
for requested_table in tables:
if requested_table in table_name:
result_data[mode][table_name] = table_data
logger.debug(f"🔍 Добавлена таблица '{table_name}' (совпадение с '{requested_table}') с {len(table_data)} записями")
break # Найдено совпадение, переходим к следующей таблице
else:
logger.warning(f"🔍 Режим '{mode}' не найден в data_source")
logger.debug(f"🔍 Итоговый результат содержит режимы: {list(result_data.keys())}")
return result_data
def _df_to_data_dict(self):
"""Преобразование DataFrame обратно в словарь данных"""
if not hasattr(self, 'df') or self.df is None or self.df.empty:
return {}
# TODO: Переделать под новую архитектуру
data_dict = {} data_dict = {}
for mode in modes:
# Группируем данные по режимам и таблицам data_dict[mode] = self.get_data(self.df, mode, tables)
for _, row in self.df.iterrows(): return self.data_dict_to_json(data_dict)
mode = row.get('mode')
table = row.get('table')
data = row.get('data')
if mode and table and data is not None:
if mode not in data_dict:
data_dict[mode] = {}
data_dict[mode][table] = data
return data_dict
def parse(self, file_path: str, params: dict) -> pd.DataFrame: def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame""" """Парсинг файла и возврат DataFrame"""
logger.debug(f"🔍 SvodkaCAParser.parse вызван с файлом: {file_path}") # Сохраняем DataFrame для использования в геттерах
self.df = self.parse_svodka_ca(file_path, params)
# Парсим данные и сохраняем словарь для использования в геттерах
self.data_dict = self.parse_svodka_ca(file_path, params)
# Преобразуем словарь в DataFrame для совместимости с services.py
# Создаем простой DataFrame с информацией о загруженных данных
if self.data_dict:
# Создаем DataFrame с информацией о режимах и таблицах
data_rows = []
for mode, tables in self.data_dict.items():
for table_name, table_data in tables.items():
if table_data:
data_rows.append({
'mode': mode,
'table': table_name,
'rows_count': len(table_data),
'data': table_data
})
if data_rows:
df = pd.DataFrame(data_rows)
self.df = df
logger.debug(f"🔍 Создан DataFrame с {len(data_rows)} записями")
return df
# Если данных нет, возвращаем пустой DataFrame
self.df = pd.DataFrame()
logger.debug(f"🔍 Возвращаем пустой DataFrame")
return self.df return self.df
def parse_svodka_ca(self, file_path: str, params: dict) -> dict: def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
"""Парсинг сводки СА - работает с тремя листами: План, Факт, Норматив""" """Парсинг сводки СА"""
logger.debug(f"🔍 Начинаем парсинг сводки СА из файла: {file_path}") # Получаем параметры из params
sheet_name = params.get('sheet_name', 0) # По умолчанию первый лист
inclusion_list = params.get('inclusion_list', {'ТиП', 'Топливо', 'Потери'})
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив === # === Извлечение и фильтрация ===
tables = self.extract_all_tables(file_path, sheet_name)
# Выгружаем План
inclusion_list_plan = {
"ТиП, %",
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
"в т.ч. Идентифицированные безвозвратные потери, %**",
"в т.ч. Неидентифицированные потери, тонн**",
"в т.ч. Неидентифицированные потери, %**"
}
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) # Фильтруем таблицы: оставляем только те, где первая строка содержит нужные заголовки
logger.debug(f"🔍 Объединённый и отсортированный План: {df_ca_plan.shape if df_ca_plan is not None else 'None'}") 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
inclusion_list_fact = {
"ТиП, %",
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн",
"в т.ч. Идентифицированные безвозвратные потери, %",
"в т.ч. Неидентифицированные потери, тонн",
"в т.ч. Неидентифицированные потери, %"
}
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) # === Итоговый список таблиц датафреймов ===
logger.debug(f"🔍 Объединённый и отсортированный Факт: {df_ca_fact.shape if df_ca_fact is not None else 'None'}") result_list = []
# Выгружаем Норматив for table in tables:
inclusion_list_normativ = { if table.empty:
"Топливо итого, тонн", continue
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
"в т.ч. Идентифицированные безвозвратные потери, %**",
"в т.ч. Неидентифицированные потери, тонн**",
"в т.ч. Неидентифицированные потери, %**"
}
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ) # Получаем первую строку (до удаления)
logger.debug(f"🔍 Объединённый и отсортированный Норматив: {df_ca_normativ.shape if df_ca_normativ is not None else 'None'}") first_row_values = table.iloc[0].astype(str).str.strip().tolist()
# Преобразуем DataFrame в словарь по режимам и таблицам # Находим, какой элемент из inclusion_list присутствует
data_dict = {} matched_key = None
for val in first_row_values:
# Обрабатываем План if val in inclusion_list:
if df_ca_plan is not None and not df_ca_plan.empty: matched_key = val
data_dict['plan'] = {} break # берём первый совпадающий заголовок
for table_name, group_df in df_ca_plan.groupby('table'):
table_data = group_df.drop('table', axis=1) if matched_key is None:
data_dict['plan'][table_name] = table_data.to_dict('records') continue # на всякий случай (хотя уже отфильтровано)
# Обрабатываем Факт # Удаляем первую строку (заголовок) и сбрасываем индекс
if df_ca_fact is not None and not df_ca_fact.empty: df_cleaned = table.iloc[1:].copy().reset_index(drop=True)
data_dict['fact'] = {}
for table_name, group_df in df_ca_fact.groupby('table'): # Пропускаем, если таблица пустая
table_data = group_df.drop('table', axis=1) if df_cleaned.empty:
data_dict['fact'][table_name] = table_data.to_dict('records') continue
# Обрабатываем Норматив # Первая строка становится заголовком
if df_ca_normativ is not None and not df_ca_normativ.empty: new_header = df_cleaned.iloc[0] # извлекаем первую строку как потенциальные названия столбцов
data_dict['normativ'] = {}
for table_name, group_df in df_ca_normativ.groupby('table'): # Преобразуем заголовок: только первый столбец может быть заменён на "name"
table_data = group_df.drop('table', axis=1) cleaned_header = []
data_dict['normativ'][table_name] = table_data.to_dict('records')
# Обрабатываем первый столбец отдельно
logger.debug(f"🔍 Итоговый data_dict содержит режимы: {list(data_dict.keys())}") first_item = new_header.iloc[0] if isinstance(new_header, pd.Series) else new_header[0]
for mode, tables in data_dict.items(): first_item_str = str(first_item).strip() if pd.notna(first_item) else ""
logger.debug(f"🔍 Режим '{mode}' содержит таблицы: {list(tables.keys())}") if first_item_str == "" or first_item_str == "nan":
cleaned_header.append("name")
return data_dict 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 extract_all_tables(self, file_path, sheet_name=0): def extract_all_tables(self, file_path, sheet_name=0):
"""Извлечение всех таблиц из Excel файла""" """Извлечение всех таблиц из Excel файла"""
@@ -372,7 +304,7 @@ class SvodkaCAParser(ParserPort):
# Проверяем, что колонка 'name' существует # Проверяем, что колонка 'name' существует
if 'name' not in df_cleaned.columns: if 'name' not in df_cleaned.columns:
logger.debug( print(
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.") f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
continue # или обработать по-другому continue # или обработать по-другому
else: else:

View File

@@ -1,326 +0,0 @@
import pandas as pd
from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
from app.schemas.svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
class SvodkaPMParser(ParserPort):
"""Парсер для сводок ПМ (план и факт)"""
name = "Сводки ПМ"
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды
register_getter_from_schema(
parser_instance=self,
getter_name="single_og",
method=self._get_single_og,
schema_class=SvodkaPMSingleOGRequest,
description="Получение данных по одному ОГ"
)
register_getter_from_schema(
parser_instance=self,
getter_name="total_ogs",
method=self._get_total_ogs,
schema_class=SvodkaPMTotalOGsRequest,
description="Получение данных по всем ОГ"
)
def _get_single_og(self, params: dict):
"""Получение данных по одному ОГ"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
og_id = validated_params["id"]
codes = validated_params["codes"]
columns = validated_params["columns"]
search = validated_params.get("search")
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику
# TODO: Переделать под новую архитектуру
return self.get_svodka_og(self.df, og_id, codes, columns, search)
def _get_total_ogs(self, params: dict):
"""Получение данных по всем ОГ"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, SvodkaPMTotalOGsRequest)
codes = validated_params["codes"]
columns = validated_params["columns"]
search = validated_params.get("search")
# 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 строк без заголовков
df_temp = pd.read_excel(
file,
sheet_name=sheet,
header=None,
nrows=max_rows,
engine='openpyxl'
)
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx # 0-based index — то, что нужно для header=
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def parse_svodka_pm(self, file, sheet, header_num=None):
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
# Автоопределение header_num, если не передан
if header_num is None:
header_num = self.find_header_row(file, sheet, search_value="Итого")
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
df_probe = pd.read_excel(
file,
sheet_name=sheet,
header=header_num,
usecols=None,
nrows=2,
engine='openpyxl'
)
if df_probe.shape[0] == 0:
raise ValueError("Файл пуст или не содержит данных.")
first_data_row = df_probe.iloc[0]
# Находим столбец с 'INDICATOR_ID'
indicator_cols = first_data_row[first_data_row == 'INDICATOR_ID']
if len(indicator_cols) == 0:
raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.')
indicator_col_name = indicator_cols.index[0]
print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}")
# Читаем весь лист
df_full = pd.read_excel(
file,
sheet_name=sheet,
header=header_num,
usecols=None,
index_col=None,
engine='openpyxl'
)
if indicator_col_name not in df_full.columns:
raise ValueError(f"Столбец {indicator_col_name} отсутствует при полной загрузке.")
# Перемещаем INDICATOR_ID в начало и делаем индексом
cols = [indicator_col_name] + [col for col in df_full.columns if col != indicator_col_name]
df_full = df_full[cols]
df_full.set_index(indicator_col_name, inplace=True)
# Обрезаем до "Итого" + 1
header_list = [str(h).strip() for h in df_full.columns]
try:
itogo_idx = header_list.index("Итого")
num_cols_needed = itogo_idx + 2
except ValueError:
print('Столбец "Итого" не найден. Оставляем все столбцы.')
num_cols_needed = len(header_list)
num_cols_needed = min(num_cols_needed, len(header_list))
df_final = df_full.iloc[:, :num_cols_needed]
# === Удаление полностью пустых столбцов ===
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
non_empty_mask = df_clean.notna().any()
df_final = df_final.loc[:, non_empty_mask]
# === Обработка заголовков: Unnamed и "Итого" → "Итого" ===
new_columns = []
last_good_name = None
for col in df_final.columns:
col_str = str(col).strip()
# Проверяем, является ли колонка пустой/некорректной
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
if is_empty_or_unnamed:
# Если это пустая колонка, используем последнее хорошее имя
if last_good_name:
new_columns.append(last_good_name)
else:
# Если нет хорошего имени, используем имя по умолчанию
new_columns.append(f"col_{len(new_columns)}")
else:
# Это хорошая колонка
last_good_name = col_str
new_columns.append(col_str)
# Убеждаемся, что количество столбцов совпадает
if len(new_columns) != len(df_final.columns):
# Если количество не совпадает, обрезаем или дополняем
if len(new_columns) > len(df_final.columns):
new_columns = new_columns[:len(df_final.columns)]
else:
# Дополняем недостающие столбцы
while len(new_columns) < len(df_final.columns):
new_columns.append(f"col_{len(new_columns)}")
# Применяем новые заголовки
df_final.columns = new_columns
return df_final
def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict:
"""Парсинг ZIP архива со сводками ПМ"""
import zipfile
pm_dict = {
"facts": {},
"plans": {}
}
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
file_list = zip_ref.namelist()
for name, id in OG_IDS.items():
if id == 'BASH':
continue # пропускаем BASH
current_fact = replace_id_in_path(excel_fact_template, id)
fact_candidates = [f for f in file_list if current_fact in f]
if len(fact_candidates) == 1:
print(f'Загрузка {current_fact}')
with zip_ref.open(fact_candidates[0]) as excel_file:
pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
print(f"✅ Факт загружен: {current_fact}")
else:
print(f"⚠️ Файл не найден (Факт): {current_fact}")
pm_dict['facts'][id] = None
current_plan = replace_id_in_path(excel_plan_template, id)
plan_candidates = [f for f in file_list if current_plan in f]
if len(plan_candidates) == 1:
print(f'Загрузка {current_plan}')
with zip_ref.open(plan_candidates[0]) as excel_file:
pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
print(f"✅ План загружен: {current_plan}")
else:
print(f"⚠️ Файл не найден (План): {current_plan}")
pm_dict['plans'][id] = None
return pm_dict
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:
mask_name = df_svodka.columns != 'Итого'
else:
mask_name = df_svodka.columns == search_value
# Убедимся, что маски совпадают по длине
if len(mask_value) != len(mask_name):
raise ValueError(
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
)
final_mask = mask_value & mask_name # булевая маска по позициям столбцов
col_positions = final_mask.values # numpy array или Series булевых значений
if not final_mask.any():
print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'")
return 0
else:
if row_index in df_svodka.index:
# Получаем позицию строки
row_loc = df_svodka.index.get_loc(row_index)
# Извлекаем значения по позициям столбцов
values = df_svodka.iloc[row_loc, col_positions]
# Преобразуем в числовой формат
numeric_values = pd.to_numeric(values, errors='coerce')
# Агрегация данных (NaN игнорируются)
if search_value is None:
return numeric_values
else:
return numeric_values.iloc[0]
else:
return None
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
''' Служебная функция получения данных по одному ОГ '''
result = {}
# Безопасно получаем данные, проверяя их наличие
fact_df = pm_dict.get('facts', {}).get(id) if 'facts' in pm_dict else None
plan_df = pm_dict.get('plans', {}).get(id) if 'plans' in pm_dict else None
# Определяем, какие столбцы из какого датафрейма брать
for col in columns:
col_result = {}
if col in ['ПП', 'БП']:
if plan_df is None:
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
col_result = {code: None for code in codes}
else:
for code in codes:
val = self.get_svodka_value(plan_df, code, col, search_value)
col_result[code] = val
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
if fact_df is None:
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
col_result = {code: None for code in codes}
else:
for code in codes:
val = self.get_svodka_value(fact_df, code, col, search_value)
col_result[code] = val
else:
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
col_result = {code: None for code in codes}
result[col] = col_result
return result
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
''' Служебная функция агрегации данные по всем ОГ '''
total_result = {}
for name, og_id in OG_IDS.items():
if og_id == 'BASH':
continue
# print(f"📊 Обработка: {name} ({og_id})")
try:
data = self.get_svodka_og(
pm_dict,
og_id,
codes,
columns,
search_value
)
total_result[og_id] = data
except Exception as e:
print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
total_result[og_id] = None
return total_result
# Убираем старый метод get_value, так как он теперь в базовом классе

View File

@@ -1,18 +1,7 @@
import pandas as pd import pandas as pd
import os
import json
import zipfile
import tempfile
import shutil
import logging
from typing import Dict, Any, List, Optional
from core.ports import ParserPort
from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json
# Настройка логгера для модуля from core.ports import ParserPort
logger = logging.getLogger(__name__) from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
class SvodkaPMParser(ParserPort): class SvodkaPMParser(ParserPort):
@@ -20,140 +9,94 @@ class SvodkaPMParser(ParserPort):
name = "Сводки ПМ" name = "Сводки ПМ"
def __init__(self):
super().__init__()
self._register_default_getters()
def _register_default_getters(self): def _register_default_getters(self):
"""Регистрация геттеров для Сводки ПМ""" """Регистрация геттеров по умолчанию"""
self.register_getter( self.register_getter(
name="single_og", name="single_og",
method=self._get_single_og, method=self._get_single_og,
required_params=["id", "codes", "columns"], required_params=["id", "codes", "columns"],
optional_params=["search"], optional_params=["search"],
description="Получение данных по одному ОГ из сводки ПМ" description="Получение данных по одному ОГ"
) )
self.register_getter( self.register_getter(
name="total_ogs", name="total_ogs",
method=self._get_total_ogs, method=self._get_total_ogs,
required_params=["codes", "columns"], required_params=["codes", "columns"],
optional_params=["search"], optional_params=["search"],
description="Получение данных по всем ОГ из сводки ПМ" description="Получение данных по всем ОГ"
) )
def parse(self, file_path: str, params: dict) -> Dict[str, pd.DataFrame]: def _get_single_og(self, params: dict):
"""Парсинг ZIP архива со сводками ПМ и возврат словаря с DataFrame""" """Получение данных по одному ОГ (обертка для совместимости)"""
# Проверяем расширение файла og_id = params["id"]
if not file_path.lower().endswith('.zip'): codes = params["codes"]
raise ValueError(f"Ожидается ZIP архив: {file_path}") columns = params["columns"]
search = params.get("search")
# Создаем временную директорию для разархивирования if not isinstance(codes, list):
temp_dir = tempfile.mkdtemp() raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
try: # Здесь нужно получить DataFrame из self.df, но пока используем старую логику
# Разархивируем файл # TODO: Переделать под новую архитектуру
with zipfile.ZipFile(file_path, 'r') as zip_ref: return self.get_svodka_og(self.df, og_id, codes, columns, search)
zip_ref.extractall(temp_dir)
logger.info(f"📦 Архив разархивирован в: {temp_dir}")
# Посмотрим, что находится в архиве
logger.debug(f"🔍 Содержимое архива:")
for root, dirs, files in os.walk(temp_dir):
level = root.replace(temp_dir, '').count(os.sep)
indent = ' ' * 2 * level
logger.debug(f"{indent}{os.path.basename(root)}/")
subindent = ' ' * 2 * (level + 1)
for file in files:
logger.debug(f"{subindent}{file}")
# Создаем словари для хранения данных как в оригинале
df_pm_facts = {} # Словарь с данными факта, ключ - ID ОГ
df_pm_plans = {} # Словарь с данными плана, ключ - ID ОГ
# Ищем файлы в архиве (адаптируемся к реальной структуре)
fact_files = []
plan_files = []
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file.lower().endswith(('.xlsx', '.xlsm')):
full_path = os.path.join(root, file)
if 'fact' in file.lower() or 'факт' in file.lower():
fact_files.append(full_path)
elif 'plan' in file.lower() or 'план' in file.lower():
plan_files.append(full_path)
logger.info(f"📊 Найдено файлов факта: {len(fact_files)}")
logger.info(f"📊 Найдено файлов плана: {len(plan_files)}")
# Обрабатываем найденные файлы
for fact_file in fact_files:
# Извлекаем ID ОГ из имени файла
filename = os.path.basename(fact_file)
# Ищем паттерн типа svodka_fact_pm_SNPZ.xlsm
if 'svodka_fact_pm_' in filename:
og_id = filename.replace('svodka_fact_pm_', '').replace('.xlsx', '').replace('.xlsm', '')
if og_id in SINGLE_OGS:
logger.info(f'📊 Загрузка факта: {fact_file} (ОГ: {og_id})')
df_pm_facts[og_id] = self._parse_svodka_pm(fact_file, 'Сводка Нефтепереработка')
logger.info(f"✅ Факт загружен для {og_id}")
for plan_file in plan_files:
# Извлекаем ID ОГ из имени файла
filename = os.path.basename(plan_file)
# Ищем паттерн типа svodka_plan_pm_SNPZ.xlsm
if 'svodka_plan_pm_' in filename:
og_id = filename.replace('svodka_plan_pm_', '').replace('.xlsx', '').replace('.xlsm', '')
if og_id in SINGLE_OGS:
logger.info(f'📊 Загрузка плана: {plan_file} (ОГ: {og_id})')
df_pm_plans[og_id] = self._parse_svodka_pm(plan_file, 'Сводка Нефтепереработка')
logger.info(f"✅ План загружен для {og_id}")
# Инициализируем None для ОГ, для которых файлы не найдены
for og_id in SINGLE_OGS:
if og_id == 'BASH':
continue
if og_id not in df_pm_facts:
df_pm_facts[og_id] = None
if og_id not in df_pm_plans:
df_pm_plans[og_id] = None
def _get_total_ogs(self, params: dict):
# Возвращаем словарь с данными (как в оригинале) """Получение данных по всем ОГ (обертка для совместимости)"""
result = { codes = params["codes"]
'df_pm_facts': df_pm_facts, columns = params["columns"]
'df_pm_plans': df_pm_plans search = params.get("search")
}
if not isinstance(codes, list):
logger.info(f"🎯 Обработано ОГ: {len([k for k, v in df_pm_facts.items() if v is not None])} факт, {len([k for k, v in df_pm_plans.items() if v is not None])} план") raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
return result raise ValueError("Поле 'columns' должно быть списком")
finally: # TODO: Переделать под новую архитектуру
# Удаляем временную директорию return self.get_svodka_total(self.df, codes, columns, search)
shutil.rmtree(temp_dir, ignore_errors=True)
logger.debug(f"🗑️ Временная директория удалена: {temp_dir}")
def _parse_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame: def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг отчетов одного ОГ для БП, ПП и факта""" """Парсинг файла и возврат DataFrame"""
try: # Сохраняем DataFrame для использования в геттерах
# Автоопределение header_num, если не передан self.df = self.parse_svodka_pm_files(file_path, params)
if header_num is None: return self.df
header_num = find_header_row(file_path, sheet_name, search_value="Итого")
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
df_probe = pd.read_excel( """Определения индекса заголовка в excel по ключевому слову"""
file_path, # Читаем первые max_rows строк без заголовков
sheet_name=sheet_name, df_temp = pd.read_excel(
header=header_num, file,
usecols=None, sheet_name=sheet,
nrows=2, header=None,
engine='openpyxl' # Явно указываем движок nrows=max_rows,
) engine='openpyxl'
except Exception as e: )
raise ValueError(f"Ошибка при чтении файла {file_path}: {str(e)}")
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx # 0-based index — то, что нужно для header=
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def parse_svodka_pm(self, file, sheet, header_num=None):
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
# Автоопределение header_num, если не передан
if header_num is None:
header_num = self.find_header_row(file, sheet, search_value="Итого")
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
df_probe = pd.read_excel(
file,
sheet_name=sheet,
header=header_num,
usecols=None,
nrows=2,
engine='openpyxl'
)
if df_probe.shape[0] == 0: if df_probe.shape[0] == 0:
raise ValueError("Файл пуст или не содержит данных.") raise ValueError("Файл пуст или не содержит данных.")
@@ -166,15 +109,16 @@ class SvodkaPMParser(ParserPort):
raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.') raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.')
indicator_col_name = indicator_cols.index[0] indicator_col_name = indicator_cols.index[0]
print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}")
# Читаем весь лист # Читаем весь лист
df_full = pd.read_excel( df_full = pd.read_excel(
file_path, file,
sheet_name=sheet_name, sheet_name=sheet,
header=header_num, header=header_num,
usecols=None, usecols=None,
index_col=None, index_col=None,
engine='openpyxl' # Явно указываем движок engine='openpyxl'
) )
if indicator_col_name not in df_full.columns: if indicator_col_name not in df_full.columns:
@@ -191,18 +135,19 @@ class SvodkaPMParser(ParserPort):
itogo_idx = header_list.index("Итого") itogo_idx = header_list.index("Итого")
num_cols_needed = itogo_idx + 2 num_cols_needed = itogo_idx + 2
except ValueError: except ValueError:
print('Столбец "Итого" не найден. Оставляем все столбцы.')
num_cols_needed = len(header_list) num_cols_needed = len(header_list)
num_cols_needed = min(num_cols_needed, len(header_list)) num_cols_needed = min(num_cols_needed, len(header_list))
df_final = df_full.iloc[:, :num_cols_needed] df_final = df_full.iloc[:, :num_cols_needed]
# Удаление полностью пустых столбцов # === Удаление полностью пустых столбцов ===
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True) df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA) df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
non_empty_mask = df_clean.notna().any() non_empty_mask = df_clean.notna().any()
df_final = df_final.loc[:, non_empty_mask] df_final = df_final.loc[:, non_empty_mask]
# Обработка заголовков: Unnamed и "Итого" → "Итого" # === Обработка заголовков: Unnamed и "Итого" → "Итого" ===
new_columns = [] new_columns = []
last_good_name = None last_good_name = None
for col in df_final.columns: for col in df_final.columns:
@@ -211,152 +156,109 @@ 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 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:
# Имя, полученное из excel # Это хорошая колонка
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
return df_final return df_final
def _get_svodka_value(self, df_svodka: pd.DataFrame, og_id: str, code: int, search_value: Optional[str] = None): def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict:
"""Служебная функция для простой выборке по сводке""" """Парсинг ZIP архива со сводками ПМ"""
logger.debug(f"🔍 Ищем код '{code}' для ОГ '{og_id}' в DataFrame с {len(df_svodka)} строками") import zipfile
logger.debug(f"🔍 Первая строка данных: {df_svodka.iloc[0].tolist()}") pm_dict = {
logger.debug(f"🔍 Доступные индексы: {list(df_svodka.index)}") "facts": {},
logger.debug(f"🔍 Доступные столбцы: {list(df_svodka.columns)}") "plans": {}
}
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
file_list = zip_ref.namelist()
for name, id in OG_IDS.items():
if id == 'BASH':
continue # пропускаем BASH
# Проверяем, есть ли код в индексе current_fact = replace_id_in_path(excel_fact_template, id)
if code not in df_svodka.index: fact_candidates = [f for f in file_list if current_fact in f]
logger.warning(f"⚠️ Код '{code}' не найден в индексе") if len(fact_candidates) == 1:
return 0 print(f'Загрузка {current_fact}')
with zip_ref.open(fact_candidates[0]) as excel_file:
pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
print(f"✅ Факт загружен: {current_fact}")
else:
print(f"⚠️ Файл не найден (Факт): {current_fact}")
pm_dict['facts'][id] = None
# Получаем позицию строки с кодом current_plan = replace_id_in_path(excel_plan_template, id)
code_row_loc = df_svodka.index.get_loc(code) plan_candidates = [f for f in file_list if current_plan in f]
logger.debug(f"🔍 Код '{code}' в позиции {code_row_loc}") if len(plan_candidates) == 1:
print(f'Загрузка {current_plan}')
with zip_ref.open(plan_candidates[0]) as excel_file:
pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
print(f"✅ План загружен: {current_plan}")
else:
print(f"⚠️ Файл не найден (План): {current_plan}")
pm_dict['plans'][id] = None
# Определяем позиции для поиска return pm_dict
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: if search_value is None:
# Ищем все позиции кроме "Итого" и None (первый столбец с заголовком) mask_name = df_svodka.columns != 'Итого'
target_positions = []
for i, col_name in enumerate(df_svodka.iloc[0]):
if col_name != 'Итого' and col_name is not None:
target_positions.append(i)
else: else:
# Ищем позиции в первой строке, где есть нужное название mask_name = df_svodka.columns == search_value
target_positions = []
for i, col_name in enumerate(df_svodka.iloc[0]):
if col_name == search_value:
target_positions.append(i)
logger.debug(f"🔍 Найдены позиции для '{search_value}': {target_positions[:5]}...")
logger.debug(f"🔍 Позиции в первой строке: {target_positions[:5]}...")
logger.debug(f"🔍 Ищем столбцы с названием '{search_value}'") # Убедимся, что маски совпадают по длине
logger.debug(f"🔍 Целевые позиции: {target_positions[:10]}...") if len(mask_value) != len(mask_name):
raise ValueError(
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
)
if not target_positions: final_mask = mask_value & mask_name # булевая маска по позициям столбцов
logger.warning(f"⚠️ Позиции '{search_value}' не найдены") col_positions = final_mask.values # numpy array или Series булевых значений
if not final_mask.any():
print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'")
return 0 return 0
# Извлекаем значения из найденных позиций
values = []
for pos in target_positions:
# Берем значение из пересечения строки с кодом и позиции столбца
value = df_svodka.iloc[code_row_loc, pos]
# Если это Series, берем первое значение
if isinstance(value, pd.Series):
if len(value) > 0:
# Берем первое не-NaN значение
first_valid = value.dropna().iloc[0] if not value.dropna().empty else 0
values.append(first_valid)
else:
values.append(0)
else:
values.append(value)
# Преобразуем в числовой формат
numeric_values = pd.to_numeric(values, errors='coerce')
logger.debug(f"🔍 Числовые значения (первые 5): {numeric_values.tolist()[:5]}")
# Попробуем альтернативное преобразование
try:
# Если pandas не может преобразовать, попробуем вручную
manual_values = []
for v in values:
if pd.isna(v) or v is None:
manual_values.append(0)
else:
try:
# Пробуем преобразовать в float
manual_values.append(float(str(v).replace(',', '.')))
except (ValueError, TypeError):
manual_values.append(0)
logger.debug(f"🔍 Ручное преобразование (первые 5): {manual_values[:5]}")
numeric_values = pd.Series(manual_values)
except Exception as e:
logger.warning(f"⚠️ Ошибка при ручном преобразовании: {e}")
# Используем исходные значения
numeric_values = pd.Series([0 if pd.isna(v) or v is None else v for v in values])
# Агрегация данных (NaN игнорируются)
if search_value is None:
# Возвращаем массив всех значений (игнорируя NaN)
if len(numeric_values) > 0:
# Фильтруем NaN значения и возвращаем как список
valid_values = numeric_values.dropna()
if len(valid_values) > 0:
return valid_values.tolist()
else:
return []
else:
return []
else: else:
# Возвращаем массив всех значений (игнорируя NaN) if row_index in df_svodka.index:
if len(numeric_values) > 0: # Получаем позицию строки
# Фильтруем NaN значения и возвращаем как список row_loc = df_svodka.index.get_loc(row_index)
valid_values = numeric_values.dropna()
if len(valid_values) > 0:
return valid_values.tolist()
else:
return []
else:
return []
def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None): # Извлекаем значения по позициям столбцов
"""Служебная функция получения данных по одному ОГ""" values = df_svodka.iloc[row_loc, col_positions]
# Преобразуем в числовой формат
numeric_values = pd.to_numeric(values, errors='coerce')
# Агрегация данных (NaN игнорируются)
if search_value is None:
return numeric_values
else:
return numeric_values.iloc[0]
else:
return None
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
''' Служебная функция получения данных по одному ОГ '''
result = {} result = {}
# Получаем данные из сохраненных словарей (через self.df) fact_df = pm_dict['facts'][id]
if not hasattr(self, 'df') or self.df is None: plan_df = pm_dict['plans'][id]
logger.error("❌ Данные не загружены. Сначала загрузите ZIP архив.")
return {col: {str(code): None for code in codes} for col in columns}
# Извлекаем словари из сохраненных данных
df_pm_facts = self.df.get('df_pm_facts', {})
df_pm_plans = self.df.get('df_pm_plans', {})
# Получаем данные для конкретного ОГ
fact_df = df_pm_facts.get(og_id)
plan_df = df_pm_plans.get(og_id)
logger.debug(f"🔍 ===== НАЧАЛО ОБРАБОТКИ ОГ {og_id} =====")
logger.debug(f"🔍 Коды: {codes}")
logger.debug(f"🔍 Столбцы: {columns}")
logger.debug(f"🔍 Получены данные для {og_id}: факт={'' if fact_df is not None else ''}, план={'' if plan_df is not None else ''}")
# Определяем, какие столбцы из какого датафрейма брать # Определяем, какие столбцы из какого датафрейма брать
for col in columns: for col in columns:
@@ -364,91 +266,49 @@ class SvodkaPMParser(ParserPort):
if col in ['ПП', 'БП']: if col in ['ПП', 'БП']:
if plan_df is None: if plan_df is None:
logger.warning(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}") print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
else: else:
logger.debug(f"🔍 ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====")
for code in codes: for code in codes:
logger.debug(f"🔍 --- Код {code} для {col} ---") val = self.get_svodka_value(plan_df, code, col, search_value)
val = self._get_svodka_value(plan_df, og_id, code, col) col_result[code] = val
col_result[str(code)] = val
logger.debug(f"🔍 ===== ЗАВЕРШИЛИ ОБРАБОТКУ '{col}' =====")
elif col in ['ТБ', 'СЭБ', 'НЭБ']: elif col in ['ТБ', 'СЭБ', 'НЭБ']:
if fact_df is None: if fact_df is None:
logger.warning(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}") print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
else: else:
for code in codes: for code in codes:
val = self._get_svodka_value(fact_df, og_id, code, col) val = self.get_svodka_value(fact_df, code, col, search_value)
col_result[str(code)] = val col_result[code] = val
else: else:
logger.warning(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.") print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
col_result = {str(code): None for code in codes} col_result = {code: None for code in codes}
result[col] = col_result result[col] = col_result
return result return result
def _get_single_og(self, params: Dict[str, Any]) -> str: def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
"""API функция для получения данных по одному ОГ""" ''' Служебная функция агрегации данные по всем ОГ '''
# Если на входе строка — парсим как JSON
if isinstance(params, str):
try:
params = json.loads(params)
except json.JSONDecodeError as e:
raise ValueError(f"Некорректный JSON: {e}")
# Проверяем структуру
if not isinstance(params, dict):
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
og_id = params.get("id")
codes = params.get("codes")
columns = params.get("columns")
search = params.get("search")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
data = self._get_svodka_og(og_id, codes, columns, search)
json_result = data_to_json(data)
return json_result
def _get_total_ogs(self, params: Dict[str, Any]) -> str:
"""API функция для получения данных по всем ОГ"""
# Если на входе строка — парсим как JSON
if isinstance(params, str):
try:
params = json.loads(params)
except json.JSONDecodeError as e:
raise ValueError(f"❌Некорректный JSON: {e}")
# Проверяем структуру
if not isinstance(params, dict):
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
codes = params.get("codes")
columns = params.get("columns")
search = params.get("search")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
total_result = {} total_result = {}
for og_id in SINGLE_OGS: for name, og_id in OG_IDS.items():
if og_id == 'BASH': if og_id == 'BASH':
continue continue
# print(f"📊 Обработка: {name} ({og_id})")
try: try:
data = self._get_svodka_og(og_id, codes, columns, search) data = self.get_svodka_og(
pm_dict,
og_id,
codes,
columns,
search_value
)
total_result[og_id] = data total_result[og_id] = data
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка при обработке {og_id}: {e}") print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
total_result[og_id] = None total_result[og_id] = None
json_result = data_to_json(total_result) return total_result
return json_result
# Убираем старый метод get_value, так как он теперь в базовом классе

View File

@@ -1,381 +0,0 @@
import pandas as pd
import numpy as np
import os
import tempfile
import shutil
import zipfile
import logging
from typing import Dict, List, Optional, Any
from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
from app.schemas.svodka_repair_ca import SvodkaRepairCARequest
from adapters.pconfig import SINGLE_OGS, find_header_row, get_og_by_name
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class SvodkaRepairCAParser(ParserPort):
"""Парсер для сводок ремонта СА"""
name = "Сводки ремонта СА"
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
register_getter_from_schema(
parser_instance=self,
getter_name="get_repair_data",
method=self._get_repair_data_wrapper,
schema_class=SvodkaRepairCARequest,
description="Получение данных о ремонтных работах"
)
def _get_repair_data_wrapper(self, params: dict):
"""Получение данных о ремонтных работах"""
logger.debug(f"🔍 _get_repair_data_wrapper вызван с параметрами: {params}")
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, SvodkaRepairCARequest)
og_ids = validated_params.get("og_ids")
repair_types = validated_params.get("repair_types")
include_planned = validated_params.get("include_planned", True)
include_factual = validated_params.get("include_factual", True)
logger.debug(f"🔍 Запрошенные ОГ: {og_ids}")
logger.debug(f"🔍 Запрошенные типы ремонта: {repair_types}")
logger.debug(f"🔍 Включать плановые: {include_planned}, фактические: {include_factual}")
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
if hasattr(self, 'data_dict') and self.data_dict is not None:
# Данные из парсинга
data_source = self.data_dict
logger.debug(f"🔍 Используем data_dict с {len(data_source)} записями")
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
# Данные из загрузки - преобразуем DataFrame обратно в словарь
data_source = self._df_to_data_dict()
logger.debug(f"🔍 Используем df, преобразованный в data_dict с {len(data_source)} записями")
else:
logger.warning(f"🔍 Нет данных! data_dict={getattr(self, 'data_dict', 'None')}, df={getattr(self, 'df', 'None')}")
return []
# Группируем данные по ОГ (как в оригинале)
grouped_data = {}
for item in data_source:
og_id = item.get('id')
if not og_id:
continue
# Проверяем фильтры
if og_ids is not None and og_id not in og_ids:
continue
if repair_types is not None and item.get('type') not in repair_types:
continue
# Фильтрация по плановым/фактическим данным
filtered_item = item.copy()
if not include_planned:
filtered_item.pop('plan', None)
if not include_factual:
filtered_item.pop('fact', None)
# Убираем поле 'id' из записи, так как оно уже в ключе
filtered_item.pop('id', None)
# Добавляем в группу по ОГ
if og_id not in grouped_data:
grouped_data[og_id] = []
grouped_data[og_id].append(filtered_item)
total_records = sum(len(v) for v in grouped_data.values())
logger.debug(f"🔍 Отфильтровано {total_records} записей из {len(data_source)}")
logger.debug(f"🔍 Группировано по {len(grouped_data)} ОГ: {list(grouped_data.keys())}")
return grouped_data
def _df_to_data_dict(self):
"""Преобразование DataFrame обратно в словарь данных"""
if not hasattr(self, 'df') or self.df is None or self.df.empty:
return []
# Если df содержит данные в формате списка записей
if 'data' in self.df.columns:
# Извлекаем данные из колонки 'data'
all_data = []
for _, row in self.df.iterrows():
data = row.get('data')
if data and isinstance(data, list):
all_data.extend(data)
return all_data
return []
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
"""Парсинг файла и возврат DataFrame"""
logger.debug(f"🔍 SvodkaRepairCAParser.parse вызван с файлом: {file_path}")
# Определяем, это ZIP архив или одиночный файл
if file_path.lower().endswith('.zip'):
# Обрабатываем ZIP архив
self.data_dict = self._parse_zip_archive(file_path, params)
else:
# Обрабатываем одиночный файл
self.data_dict = self._parse_single_file(file_path, params)
# Преобразуем словарь в DataFrame для совместимости с services.py
if self.data_dict:
# Создаем DataFrame с информацией о загруженных данных
data_rows = []
for i, item in enumerate(self.data_dict):
data_rows.append({
'index': i,
'data': [item], # Обертываем в список для совместимости
'records_count': 1
})
if data_rows:
df = pd.DataFrame(data_rows)
self.df = df
logger.debug(f"🔍 Создан DataFrame с {len(data_rows)} записями")
return df
# Если данных нет, возвращаем пустой DataFrame
self.df = pd.DataFrame()
logger.debug(f"🔍 Возвращаем пустой DataFrame")
return self.df
def _parse_zip_archive(self, file_path: str, params: dict) -> List[Dict]:
"""Парсинг ZIP архива с файлами ремонта СА"""
logger.info(f"🔍 Парсинг ZIP архива: {file_path}")
all_data = []
temp_dir = None
try:
# Создаем временную директорию
temp_dir = tempfile.mkdtemp()
logger.debug(f"📦 Архив разархивирован в: {temp_dir}")
# Разархивируем файл
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Ищем Excel файлы в архиве
excel_files = []
for root, dirs, files in os.walk(temp_dir):
for file in files:
if file.lower().endswith(('.xlsx', '.xlsm', '.xls')):
excel_files.append(os.path.join(root, file))
logger.info(f"📊 Найдено Excel файлов: {len(excel_files)}")
# Обрабатываем каждый найденный файл
for excel_file in excel_files:
logger.info(f"📊 Обработка файла: {excel_file}")
file_data = self._parse_single_file(excel_file, params)
if file_data:
all_data.extend(file_data)
logger.info(f"🎯 Всего обработано записей: {len(all_data)}")
return all_data
except Exception as e:
logger.error(f"❌ Ошибка при обработке ZIP архива: {e}")
return []
finally:
# Удаляем временную директорию
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
logger.debug(f"🗑️ Временная директория удалена: {temp_dir}")
def _parse_single_file(self, file_path: str, params: dict) -> List[Dict]:
"""Парсинг одиночного Excel файла"""
logger.debug(f"🔍 Парсинг файла: {file_path}")
try:
# Получаем параметры
sheet_name = params.get('sheet_name', 0) # По умолчанию первый лист
header_num = params.get('header_num', None)
# Автоопределение header_num, если не передан
if header_num is None:
header_num = find_header_row(file_path, sheet_name, search_value="ОГ")
if header_num is None:
logger.error(f"Не найден заголовок в файле {file_path}")
return []
logger.debug(f"🔍 Заголовок найден в строке {header_num}")
# Читаем Excel файл
df = pd.read_excel(
file_path,
sheet_name=sheet_name,
header=header_num,
usecols=None,
index_col=None
)
if df.empty:
logger.error(f"❌ Файл {file_path} пуст")
return []
if "ОГ" not in df.columns:
logger.warning(f"⚠️ Предупреждение: Колонка 'ОГ' не найдена в файле {file_path}")
return []
# Обрабатываем данные
return self._process_repair_data(df)
except Exception as e:
logger.error(f"❌ Ошибка при парсинге файла {file_path}: {e}")
return []
def _process_repair_data(self, df: pd.DataFrame) -> List[Dict]:
"""Обработка данных о ремонте"""
logger.debug(f"🔍 Обработка данных с {len(df)} строками")
# Шаг 1: Нормализация ОГ
def safe_replace(val):
if pd.notna(val) and isinstance(val, str) and val.strip():
cleaned_val = val.strip()
result = get_og_by_name(cleaned_val)
if result and pd.notna(result) and result != "" and result != "UNKNOWN":
return result
return val
df["ОГ"] = df["ОГ"].apply(safe_replace)
# Шаг 2: Приведение к NA и forward fill
og_series = df["ОГ"].map(
lambda x: pd.NA if (isinstance(x, str) and x.strip() == "") or pd.isna(x) else x
)
df["ОГ"] = og_series.ffill()
# Шаг 3: Фильтрация по валидным ОГ
valid_og_values = set(SINGLE_OGS)
mask_og = df["ОГ"].notna() & df["ОГ"].isin(valid_og_values)
df = df[mask_og].copy()
if df.empty:
logger.info(f"❌ Нет данных после фильтрации по ОГ")
return []
# Шаг 4: Удаление строк без "Вид простоя"
if "Вид простоя" in df.columns:
downtime_clean = df["Вид простоя"].astype(str).str.strip()
mask_downtime = (downtime_clean != "") & (downtime_clean != "nan")
df = df[mask_downtime].copy()
else:
logger.info("⚠️ Предупреждение: Колонка 'Вид простоя' не найдена.")
return []
# Шаг 5: Удаление ненужных колонок
cols_to_drop = []
for col in df.columns:
if col.strip().lower() in ["п/п", "пп", "п.п.", ""]:
cols_to_drop.append(col)
elif "НАЛИЧИЕ ПОДРЯДЧИКА" in col.upper() and "ОСНОВНЫЕ РАБОТЫ" in col.upper():
cols_to_drop.append(col)
df.drop(columns=list(set(cols_to_drop)), inplace=True, errors='ignore')
# Шаг 6: Переименование первых 8 колонок по порядку
if df.shape[1] < 8:
logger.info(f"⚠️ Внимание: В DataFrame только {df.shape[1]} колонок, требуется минимум 8.")
return []
new_names = ["id", "name", "type", "start_date", "end_date", "plan", "fact", "downtime"]
# Сохраняем оставшиеся колонки (если больше 8)
remaining_cols = df.columns[8:].tolist() # Все, что после 8-й
renamed_cols = new_names + remaining_cols
df.columns = renamed_cols
# меняем прочерки на null
df = df.replace("-", None)
# Сброс индекса
df.reset_index(drop=True, inplace=True)
# Шаг 7: Преобразование в список словарей
result_data = []
for _, row in df.iterrows():
try:
# Извлекаем основные поля (теперь с правильными именами)
og_id = row.get('id')
name = row.get('name', '')
repair_type = row.get('type', '')
# Обрабатываем даты
start_date = self._parse_date(row.get('start_date'))
end_date = self._parse_date(row.get('end_date'))
# Обрабатываем числовые значения
plan = self._parse_numeric(row.get('plan'))
fact = self._parse_numeric(row.get('fact'))
downtime = self._parse_downtime(row.get('downtime'))
# Создаем запись
record = {
"id": og_id,
"name": str(name) if pd.notna(name) else "",
"type": str(repair_type) if pd.notna(repair_type) else "",
"start_date": start_date,
"end_date": end_date,
"plan": plan,
"fact": fact,
"downtime": downtime
}
result_data.append(record)
except Exception as e:
logger.info(f"⚠️ Ошибка при обработке строки: {e}")
continue
logger.info(f"✅ Обработано {len(result_data)} записей")
return result_data
def _parse_date(self, value) -> Optional[str]:
"""Парсинг даты"""
if pd.isna(value) or value is None:
return None
try:
if isinstance(value, str):
# Пытаемся преобразовать строку в дату
date_obj = pd.to_datetime(value, errors='coerce')
if pd.notna(date_obj):
return date_obj.strftime('%Y-%m-%d %H:%M:%S')
elif hasattr(value, 'strftime'):
# Это уже объект даты
return value.strftime('%Y-%m-%d %H:%M:%S')
return None
except Exception:
return None
def _parse_numeric(self, value) -> Optional[float]:
"""Парсинг числового значения"""
if pd.isna(value) or value is None:
return None
try:
if isinstance(value, (int, float)):
return float(value)
elif isinstance(value, str):
# Заменяем запятую на точку для русских чисел
cleaned = value.replace(',', '.').strip()
return float(cleaned) if cleaned else None
return None
except (ValueError, TypeError):
return None
def _parse_downtime(self, value) -> Optional[str]:
"""Парсинг данных о простое"""
if pd.isna(value) or value is None:
return None
return str(value).strip() if str(value).strip() else None

View File

@@ -3,11 +3,6 @@ from functools import lru_cache
import json import json
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import os
import logging
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
OG_IDS = { OG_IDS = {
"Комсомольский НПЗ": "KNPZ", "Комсомольский НПЗ": "KNPZ",
@@ -27,37 +22,8 @@ OG_IDS = {
"Красноленинский НПЗ": "KLNPZ", "Красноленинский НПЗ": "KLNPZ",
"Пурнефтепереработка": "PurNP", "Пурнефтепереработка": "PurNP",
"ЯНОС": "YANOS", "ЯНОС": "YANOS",
"Уфанефтехим": "UNH",
"РНПК": "RNPK",
"КмсНПЗ": "KNPZ",
"АНХК": "ANHK",
"НК НПЗ": "NovKuybNPZ",
"КНПЗ": "KuybNPZ",
"СНПЗ": "CyzNPZ",
"Нижневаторское НПО": "NVNPO",
"ПурНП": "PurNP",
} }
SINGLE_OGS = [
"KNPZ",
"ANHK",
"AchNPZ",
"BASH",
"UNPZ",
"UNH",
"NOV",
"NovKuybNPZ",
"KuybNPZ",
"CyzNPZ",
"TuapsNPZ",
"SNPZ",
"RNPK",
"NVNPO",
"KLNPZ",
"PurNP",
"YANOS",
]
SNPZ_IDS = { SNPZ_IDS = {
"Висбрекинг": "SNPZ.VISB", "Висбрекинг": "SNPZ.VISB",
"Изомеризация": "SNPZ.IZOM", "Изомеризация": "SNPZ.IZOM",
@@ -74,18 +40,7 @@ SNPZ_IDS = {
def replace_id_in_path(file_path, new_id): def replace_id_in_path(file_path, new_id):
# Заменяем 'ID' на новое значение return file_path.replace('ID', str(new_id))
modified_path = file_path.replace('ID', str(new_id)) + '.xlsx'
# Проверяем, существует ли файл
if not os.path.exists(modified_path):
# Меняем расширение на .xlsm
directory, filename = os.path.split(modified_path)
name, ext = os.path.splitext(filename)
new_filename = name + '.xlsm'
modified_path = os.path.join(directory, new_filename)
return modified_path
def get_table_name(exel): def get_table_name(exel):
@@ -154,25 +109,6 @@ def get_id_by_name(name, dictionary):
return best_match return best_match
def find_header_row(file, sheet, search_value="Итого", max_rows=50):
''' Определения индекса заголовка в exel по ключевому слову '''
# Читаем первые max_rows строк без заголовков
df_temp = pd.read_excel(
file,
sheet_name=sheet,
header=None,
nrows=max_rows
)
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx # 0-based index — то, что нужно для header=
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def data_to_json(data, indent=2, ensure_ascii=False): def data_to_json(data, indent=2, ensure_ascii=False):
""" """
Полностью безопасная сериализация данных в JSON. Полностью безопасная сериализация данных в JSON.
@@ -217,18 +153,11 @@ def data_to_json(data, indent=2, ensure_ascii=False):
# --- рекурсия по dict и list --- # --- рекурсия по dict и list ---
elif isinstance(obj, dict): elif isinstance(obj, dict):
# Обрабатываем только значения, ключи оставляем как строки return {
converted = {} key: convert_obj(value)
for k, v in obj.items(): for key, value in obj.items()
if is_nan_like(k): if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON)
continue # ключи не могут быть null в JSON }
# Превращаем ключ в строку, но не пытаемся интерпретировать как число
key_str = str(k)
converted[key_str] = convert_obj(v) # только значение проходит через convert_obj
# Если все значения 0.0 — считаем, что данных нет, т.к. возможно ожидается массив.
if converted and all(v == 0.0 for v in converted.values()):
return None
return converted
elif isinstance(obj, list): elif isinstance(obj, list):
return [convert_obj(item) for item in obj] return [convert_obj(item) for item in obj]
@@ -246,6 +175,7 @@ def data_to_json(data, indent=2, ensure_ascii=False):
try: try:
cleaned_data = convert_obj(data) cleaned_data = convert_obj(data)
return json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii) cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
return cleaned_data
except Exception as e: except Exception as e:
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}") raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")

View File

@@ -4,16 +4,12 @@
import os import os
import pickle import pickle
import io import io
import logging
from typing import Optional from typing import Optional
from minio import Minio # boto3 from minio import Minio # boto3
import pandas as pd import pandas as pd
from core.ports import StoragePort from core.ports import StoragePort
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
class MinIOStorageAdapter(StoragePort): class MinIOStorageAdapter(StoragePort):
"""Адаптер для MinIO хранилища""" """Адаптер для MinIO хранилища"""
@@ -41,8 +37,8 @@ class MinIOStorageAdapter(StoragePort):
# Проверяем bucket только при первом использовании # Проверяем bucket только при первом использовании
self._ensure_bucket_exists() self._ensure_bucket_exists()
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Не удалось подключиться к MinIO: {e}") print(f"⚠️ Не удалось подключиться к MinIO: {e}")
logger.warning("MinIO будет недоступен, но приложение продолжит работать") print("MinIO будет недоступен, но приложение продолжит работать")
return None return None
return self._client return self._client
@@ -54,16 +50,16 @@ class MinIOStorageAdapter(StoragePort):
try: try:
if not self.client.bucket_exists(self._bucket_name): if not self.client.bucket_exists(self._bucket_name):
self.client.make_bucket(self._bucket_name) self.client.make_bucket(self._bucket_name)
logger.info(f"✅ Bucket '{self._bucket_name}' создан") print(f"✅ Bucket '{self._bucket_name}' создан")
return True return True
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка при работе с bucket: {e}") print(f"❌ Ошибка при работе с bucket: {e}")
return False return False
def save_dataframe(self, df: pd.DataFrame, object_id: str) -> bool: def save_dataframe(self, df: pd.DataFrame, object_id: str) -> bool:
"""Сохранение DataFrame в MinIO""" """Сохранение DataFrame в MinIO"""
if self.client is None: if self.client is None:
logger.warning("⚠️ MinIO недоступен, данные не сохранены") print("⚠️ MinIO недоступен, данные не сохранены")
return False return False
try: try:
@@ -82,16 +78,16 @@ class MinIOStorageAdapter(StoragePort):
content_type='application/octet-stream' content_type='application/octet-stream'
) )
logger.info(f"✅ DataFrame успешно сохранен в MinIO: {self._bucket_name}/{object_id}") print(f"✅ DataFrame успешно сохранен в MinIO: {self._bucket_name}/{object_id}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка при сохранении в MinIO: {e}") print(f"❌ Ошибка при сохранении в MinIO: {e}")
return False return False
def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]: def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]:
"""Загрузка DataFrame из MinIO""" """Загрузка DataFrame из MinIO"""
if self.client is None: if self.client is None:
logger.warning("⚠️ MinIO недоступен, данные не загружены") print("⚠️ MinIO недоступен, данные не загружены")
return None return None
try: try:
@@ -106,7 +102,7 @@ class MinIOStorageAdapter(StoragePort):
return df return df
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка при загрузке данных из MinIO: {e}") print(f"❌ Ошибка при загрузке данных из MinIO: {e}")
return None return None
finally: finally:
if 'response' in locals(): if 'response' in locals():
@@ -116,15 +112,15 @@ class MinIOStorageAdapter(StoragePort):
def delete_object(self, object_id: str) -> bool: def delete_object(self, object_id: str) -> bool:
"""Удаление объекта из MinIO""" """Удаление объекта из MinIO"""
if self.client is None: if self.client is None:
logger.warning("⚠️ MinIO недоступен, объект не удален") print("⚠️ MinIO недоступен, объект не удален")
return False return False
try: try:
self.client.remove_object(self._bucket_name, object_id) self.client.remove_object(self._bucket_name, object_id)
logger.info(f"✅ Объект успешно удален из MinIO: {self._bucket_name}/{object_id}") print(f"✅ Объект успешно удален из MinIO: {self._bucket_name}/{object_id}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка при удалении объекта из MinIO: {e}") print(f"❌ Ошибка при удалении объекта из MinIO: {e}")
return False return False
def object_exists(self, object_id: str) -> bool: def object_exists(self, object_id: str) -> bool:

View File

@@ -1,23 +1,12 @@
import os import os
import multiprocessing import multiprocessing
import uvicorn import uvicorn
import logging
from typing import Dict, List from typing import Dict, List
from fastapi import FastAPI, File, UploadFile, HTTPException, status from fastapi import FastAPI, File, UploadFile, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
# Настройка логирования
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(name)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
from adapters.storage import MinIOStorageAdapter from adapters.storage import MinIOStorageAdapter
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, MonitoringTarParser, SvodkaRepairCAParser, StatusesRepairCAParser, OperSpravkaTechPosParser from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
from core.models import UploadRequest, DataRequest from core.models import UploadRequest, DataRequest
from core.services import ReportService, PARSERS from core.services import ReportService, PARSERS
@@ -29,10 +18,6 @@ from app.schemas import (
SvodkaCARequest, SvodkaCARequest,
MonitoringFuelMonthRequest, MonitoringFuelTotalRequest MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
) )
from app.schemas.oper_spravka_tech_pos import OperSpravkaTechPosRequest, OperSpravkaTechPosResponse
from app.schemas.svodka_repair_ca import SvodkaRepairCARequest
from app.schemas.statuses_repair_ca import StatusesRepairCARequest
from app.schemas.monitoring_tar import MonitoringTarRequest, MonitoringTarFullRequest
# Парсеры # Парсеры
@@ -40,10 +25,6 @@ PARSERS.update({
'svodka_pm': SvodkaPMParser, 'svodka_pm': SvodkaPMParser,
'svodka_ca': SvodkaCAParser, 'svodka_ca': SvodkaCAParser,
'monitoring_fuel': MonitoringFuelParser, 'monitoring_fuel': MonitoringFuelParser,
'monitoring_tar': MonitoringTarParser,
'svodka_repair_ca': SvodkaRepairCAParser,
'statuses_repair_ca': StatusesRepairCAParser,
'oper_spravka_tech_pos': OperSpravkaTechPosParser,
# 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser, # 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser,
}) })
@@ -99,81 +80,22 @@ async def root():
description="Возвращает список идентификаторов всех доступных парсеров", description="Возвращает список идентификаторов всех доступных парсеров",
response_model=Dict[str, List[str]], response_model=Dict[str, List[str]],
responses={ responses={
200: { 200: {
"content": { "content": {
"application/json": { "application/json": {
"example": { "example": {
"parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"] "parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"]
} }
} }
} }
} }
},) },)
async def get_available_parsers(): async def get_available_parsers():
"""Получение списка доступных парсеров""" """Получение списка доступных парсеров"""
parsers = list(PARSERS.keys()) parsers = list(PARSERS.keys())
return {"parsers": parsers} return {"parsers": parsers}
@app.get("/parsers/{parser_name}/available_ogs", tags=["Общее"],
summary="Доступные ОГ для парсера",
description="Возвращает список доступных ОГ для указанного парсера",
responses={
200: {
"content": {
"application/json": {
"example": {
"parser": "svodka_repair_ca",
"available_ogs": ["KNPZ", "ANHK", "SNPZ", "BASH"]
}
}
}
}
},)
async def get_available_ogs(parser_name: str):
"""Получение списка доступных ОГ для парсера"""
if parser_name not in PARSERS:
raise HTTPException(status_code=404, detail=f"Парсер '{parser_name}' не найден")
parser_class = PARSERS[parser_name]
# Для парсеров с данными в MinIO возвращаем ОГ из загруженных данных
if parser_name in ["svodka_repair_ca", "oper_spravka_tech_pos"]:
try:
# Создаем экземпляр сервиса и загружаем данные из MinIO
report_service = get_report_service()
from core.models import DataRequest
data_request = DataRequest(report_type=parser_name, get_params={})
loaded_data = report_service.get_data(data_request)
# Если данные загружены, извлекаем ОГ из них
if loaded_data is not None and hasattr(loaded_data, 'data') and loaded_data.data is not None:
# Для svodka_repair_ca данные возвращаются в формате словаря по ОГ
if parser_name == "svodka_repair_ca":
data_value = loaded_data.data.get('value')
if isinstance(data_value, dict):
available_ogs = list(data_value.keys())
return {"parser": parser_name, "available_ogs": available_ogs}
# Для oper_spravka_tech_pos данные возвращаются в формате списка
elif parser_name == "oper_spravka_tech_pos":
# Данные уже в правильном формате, возвращаем их
if isinstance(loaded_data.data, list) and loaded_data.data:
# Извлекаем уникальные ОГ из данных
available_ogs = []
for item in loaded_data.data:
if isinstance(item, dict) and 'id' in item:
available_ogs.append(item['id'])
if available_ogs:
return {"parser": parser_name, "available_ogs": available_ogs}
except Exception as e:
logger.error(f"⚠️ Ошибка при получении ОГ: {e}")
import traceback
traceback.print_exc()
# Для других парсеров или если нет данных возвращаем статический список из pconfig
from adapters.pconfig import SINGLE_OGS
return {"parser": parser_name, "available_ogs": SINGLE_OGS}
@app.get("/parsers/{parser_name}/getters", tags=["Общее"], @app.get("/parsers/{parser_name}/getters", tags=["Общее"],
summary="Информация о геттерах парсера", summary="Информация о геттерах парсера",
description="Возвращает информацию о доступных геттерах для указанного парсера", description="Возвращает информацию о доступных геттерах для указанного парсера",
@@ -401,7 +323,7 @@ async def get_svodka_pm_single_og(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'single_og' request_dict['mode'] = 'single'
request = DataRequest( request = DataRequest(
report_type='svodka_pm', report_type='svodka_pm',
get_params=request_dict get_params=request_dict
@@ -455,7 +377,7 @@ async def get_svodka_pm_total_ogs(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'total_ogs' request_dict['mode'] = 'total'
request = DataRequest( request = DataRequest(
report_type='svodka_pm', report_type='svodka_pm',
get_params=request_dict get_params=request_dict
@@ -634,246 +556,6 @@ async def get_svodka_ca_data(
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/svodka_repair_ca/upload", tags=[SvodkaRepairCAParser.name],
summary="Загрузка файла отчета сводки ремонта СА",
response_model=UploadResponse,
responses={
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
},)
async def upload_svodka_repair_ca(
file: UploadFile = File(..., description="Excel файл или ZIP архив сводки ремонта СА (.xlsx, .xlsm, .xls, .zip)")
):
"""
Загрузка и обработка Excel файла или ZIP архива отчета сводки ремонта СА
**Поддерживаемые форматы:**
- Excel (.xlsx, .xlsm, .xls)
- ZIP архив (.zip)
"""
report_service = get_report_service()
try:
# Проверяем тип файла
if not file.filename.lower().endswith(('.xlsx', '.xlsm', '.xls', '.zip')):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content=UploadErrorResponse(
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или ZIP архивы (.zip)",
error_code="INVALID_FILE_TYPE",
details={
"expected_formats": [".xlsx", ".xlsm", ".xls", ".zip"],
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
}
).model_dump()
)
# Читаем содержимое файла
file_content = await file.read()
# Создаем запрос
request = UploadRequest(
report_type='svodka_repair_ca',
file_content=file_content,
file_name=file.filename
)
# Загружаем отчет
result = report_service.upload_report(request)
if result.success:
return UploadResponse(
success=True,
message=result.message,
object_id=result.object_id
)
else:
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=UploadErrorResponse(
message=result.message,
error_code="ERR_UPLOAD"
).model_dump(),
)
except HTTPException:
raise
except Exception as e:
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=UploadErrorResponse(
message=f"Внутренняя ошибка сервера: {str(e)}",
error_code="INTERNAL_SERVER_ERROR"
).model_dump()
)
@app.post("/svodka_repair_ca/get_data", tags=[SvodkaRepairCAParser.name],
summary="Получение данных из отчета сводки ремонта СА")
async def get_svodka_repair_ca_data(
request_data: SvodkaRepairCARequest
):
"""
Получение данных из отчета сводки ремонта СА
### Структура параметров:
- `og_ids`: **Массив ID ОГ** для фильтрации (опциональный)
- `repair_types`: **Массив типов ремонта** - `КР`, `КП`, `ТР` (опциональный)
- `include_planned`: **Включать плановые данные** (по умолчанию true)
- `include_factual`: **Включать фактические данные** (по умолчанию true)
### Пример тела запроса:
```json
{
"og_ids": ["SNPZ", "KNPZ"],
"repair_types": ["КР", "КП"],
"include_planned": true,
"include_factual": true
}
```
"""
report_service = get_report_service()
try:
# Создаем запрос
request_dict = request_data.model_dump()
request = DataRequest(
report_type='svodka_repair_ca',
get_params=request_dict
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/statuses_repair_ca/upload", tags=[StatusesRepairCAParser.name],
summary="Загрузка отчета статусов ремонта СА")
async def upload_statuses_repair_ca(
file: UploadFile = File(...)
):
"""
Загрузка отчета статусов ремонта СА
### Поддерживаемые форматы:
- **Excel файлы**: `.xlsx`, `.xlsm`, `.xls`
- **ZIP архивы**: `.zip` (содержащие Excel файлы)
### Пример использования:
```bash
curl -X POST "http://localhost:8000/statuses_repair_ca/upload" \
-H "accept: application/json" \
-H "Content-Type: multipart/form-data" \
-F "file=@statuses_repair_ca.xlsx"
```
"""
report_service = get_report_service()
try:
# Проверяем тип файла
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls', '.zip')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или архивы (.zip)"
)
# Читаем содержимое файла
file_content = await file.read()
# Создаем запрос на загрузку
upload_request = UploadRequest(
report_type='statuses_repair_ca',
file_content=file_content,
file_name=file.filename
)
# Загружаем отчет
result = report_service.upload_report(upload_request)
if result.success:
return UploadResponse(
success=True,
message="Отчет успешно загружен и обработан",
report_id=result.object_id,
filename=file.filename
).model_dump()
else:
return UploadErrorResponse(
success=False,
message=result.message,
error_code="ERR_UPLOAD",
details=None
).model_dump()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/statuses_repair_ca/get_data", tags=[StatusesRepairCAParser.name],
summary="Получение данных из отчета статусов ремонта СА")
async def get_statuses_repair_ca_data(
request_data: StatusesRepairCARequest
):
"""
Получение данных из отчета статусов ремонта СА
### Структура параметров:
- `ids`: **Массив ID ОГ** для фильтрации (опциональный)
- `keys`: **Массив ключей** для извлечения данных (опциональный)
### Пример тела запроса:
```json
{
"ids": ["SNPZ", "KNPZ", "ANHK"],
"keys": [
["Дата начала ремонта"],
["Готовность к КР", "Факт"],
["Заключение договоров на СМР", "Договор", "%"]
]
}
```
"""
report_service = get_report_service()
try:
# Создаем запрос
request_dict = request_data.model_dump()
request = DataRequest(
report_type='statuses_repair_ca',
get_params=request_dict
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# @app.post("/monitoring_fuel/upload", tags=[MonitoringFuelParser.name]) # @app.post("/monitoring_fuel/upload", tags=[MonitoringFuelParser.name])
# async def upload_monitoring_fuel( # async def upload_monitoring_fuel(
# file: UploadFile = File(...), # file: UploadFile = File(...),
@@ -1122,7 +804,7 @@ async def get_monitoring_fuel_total_by_columns(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'total_by_columns' request_dict['mode'] = 'total'
request = DataRequest( request = DataRequest(
report_type='monitoring_fuel', report_type='monitoring_fuel',
get_params=request_dict get_params=request_dict
@@ -1167,7 +849,7 @@ async def get_monitoring_fuel_month_by_code(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'month_by_code' request_dict['mode'] = 'month'
request = DataRequest( request = DataRequest(
report_type='monitoring_fuel', report_type='monitoring_fuel',
get_params=request_dict get_params=request_dict
@@ -1190,258 +872,5 @@ async def get_monitoring_fuel_month_by_code(
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# ====== MONITORING TAR ENDPOINTS ======
@app.post("/monitoring_tar/upload", tags=[MonitoringTarParser.name],
summary="Загрузка отчета мониторинга ТЭР")
async def upload_monitoring_tar(
file: UploadFile = File(...)
):
"""Загрузка и обработка отчета мониторинга ТЭР (Топливно-энергетических ресурсов)
### Поддерживаемые форматы:
- **ZIP архивы** с файлами мониторинга ТЭР
### Структура данных:
- Обрабатывает ZIP архивы с файлами по месяцам (svodka_tar_SNPZ_01.xlsx - svodka_tar_SNPZ_12.xlsx)
- Извлекает данные по установкам (SNPZ_IDS)
- Возвращает два типа данных: 'total' (строки "Всего") и 'last_day' (последние строки)
"""
report_service = get_report_service()
try:
# Проверяем тип файла - только ZIP архивы
if not file.filename.endswith('.zip'):
raise HTTPException(
status_code=400,
detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)"
)
# Читаем содержимое файла
file_content = await file.read()
# Создаем запрос на загрузку
upload_request = UploadRequest(
report_type='monitoring_tar',
file_content=file_content,
file_name=file.filename
)
# Загружаем отчет
result = report_service.upload_report(upload_request)
if result.success:
return UploadResponse(
success=True,
message="Отчет успешно загружен и обработан",
report_id=result.object_id,
filename=file.filename
).model_dump()
else:
return UploadErrorResponse(
success=False,
message=result.message,
error_code="ERR_UPLOAD",
details=None
).model_dump()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/monitoring_tar/get_data", tags=[MonitoringTarParser.name],
summary="Получение данных из отчета мониторинга ТЭР")
async def get_monitoring_tar_data(
request_data: MonitoringTarRequest
):
"""Получение данных из отчета мониторинга ТЭР
### Структура параметров:
- `mode`: **Режим получения данных** (опциональный)
- `"total"` - строки "Всего" (агрегированные данные)
- `"last_day"` - последние строки данных
- Если не указан, возвращаются все данные
### Пример тела запроса:
```json
{
"mode": "total"
}
```
"""
report_service = get_report_service()
try:
# Создаем запрос
request_dict = request_data.model_dump()
request = DataRequest(
report_type='monitoring_tar',
get_params=request_dict
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/monitoring_tar/get_full_data", tags=[MonitoringTarParser.name],
summary="Получение всех данных из отчета мониторинга ТЭР")
async def get_monitoring_tar_full_data():
"""Получение всех данных из отчета мониторинга ТЭР без фильтрации
### Возвращает:
- Все данные по всем установкам
- И данные 'total', и данные 'last_day'
- Полная структура данных мониторинга ТЭР
"""
report_service = get_report_service()
try:
# Создаем запрос без параметров
request = DataRequest(
report_type='monitoring_tar',
get_params={}
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# ====== OPER SPRAVKA TECH POS ENDPOINTS ======
@app.post("/oper_spravka_tech_pos/upload", tags=[OperSpravkaTechPosParser.name],
summary="Загрузка отчета операционной справки технологических позиций")
async def upload_oper_spravka_tech_pos(
file: UploadFile = File(...)
):
"""Загрузка и обработка отчета операционной справки технологических позиций
### Поддерживаемые форматы:
- **ZIP архивы** с файлами операционных справок
### Структура данных:
- Обрабатывает ZIP архивы с файлами операционных справок по технологическим позициям
- Извлекает данные по процессам: Первичная переработка, Гидроочистка топлив, Риформирование, Изомеризация
- Возвращает данные по установкам с планом и фактом
"""
report_service = get_report_service()
try:
# Проверяем тип файла - только ZIP архивы
if not file.filename.endswith('.zip'):
raise HTTPException(
status_code=400,
detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)"
)
# Читаем содержимое файла
file_content = await file.read()
# Создаем запрос на загрузку
upload_request = UploadRequest(
report_type="oper_spravka_tech_pos",
file_name=file.filename,
file_content=file_content,
parse_params={}
)
# Загружаем и обрабатываем отчет
result = report_service.upload_report(upload_request)
if result.success:
return UploadResponse(
success=True,
message="Отчет успешно загружен и обработан",
object_id=result.object_id
)
else:
return UploadErrorResponse(
success=False,
message=result.message,
error_code="ERR_UPLOAD",
details=None
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/oper_spravka_tech_pos/get_data", tags=[OperSpravkaTechPosParser.name],
summary="Получение данных операционной справки технологических позиций",
response_model=OperSpravkaTechPosResponse)
async def get_oper_spravka_tech_pos_data(request: OperSpravkaTechPosRequest):
"""Получение данных операционной справки технологических позиций по ОГ
### Параметры:
- **id** (str): ID ОГ (например, 'SNPZ', 'KNPZ')
### Возвращает:
- Данные по технологическим позициям для указанного ОГ
- Включает информацию о процессах, установках, плане и факте
"""
report_service = get_report_service()
try:
# Создаем запрос на получение данных
data_request = DataRequest(
report_type="oper_spravka_tech_pos",
get_params={"id": request.id}
)
# Получаем данные
result = report_service.get_data(data_request)
if result.success:
# Извлекаем данные из результата
value_data = result.data.get("value", []) if isinstance(result.data.get("value"), list) else []
logger.debug(f"🔍 API возвращает данные: {type(value_data)}, длина: {len(value_data) if isinstance(value_data, (list, dict)) else 'N/A'}")
return OperSpravkaTechPosResponse(
success=True,
data=value_data,
message="Данные успешно получены"
)
else:
return OperSpravkaTechPosResponse(
success=False,
data=None,
message=result.message
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8080) uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@@ -1,33 +0,0 @@
from pydantic import BaseModel, Field
from typing import Optional, Literal
from enum import Enum
class TarMode(str, Enum):
"""Режимы получения данных мониторинга ТЭР"""
TOTAL = "total"
LAST_DAY = "last_day"
class MonitoringTarRequest(BaseModel):
"""Схема запроса для получения данных мониторинга ТЭР"""
mode: Optional[TarMode] = Field(
None,
description="Режим получения данных: 'total' (строки 'Всего') или 'last_day' (последние строки). Если не указан, возвращаются все данные",
example="total"
)
class Config:
json_schema_extra = {
"example": {
"mode": "total"
}
}
class MonitoringTarFullRequest(BaseModel):
"""Схема запроса для получения всех данных мониторинга ТЭР"""
# Пустая схема - возвращает все данные без фильтрации
pass
class Config:
json_schema_extra = {
"example": {}
}

View File

@@ -1,38 +0,0 @@
from pydantic import BaseModel, Field
from typing import Optional, List
class OperSpravkaTechPosRequest(BaseModel):
"""Запрос для получения данных операционной справки технологических позиций"""
id: str = Field(..., description="ID ОГ (например, 'SNPZ', 'KNPZ')")
class Config:
json_schema_extra = {
"example": {
"id": "SNPZ"
}
}
class OperSpravkaTechPosResponse(BaseModel):
"""Ответ с данными операционной справки технологических позиций"""
success: bool = Field(..., description="Статус успешности операции")
data: Optional[List[dict]] = Field(None, description="Данные по технологическим позициям")
message: Optional[str] = Field(None, description="Сообщение о результате операции")
class Config:
json_schema_extra = {
"example": {
"success": True,
"data": [
{
"Процесс": "Первичная переработка",
"Установка": "ЭЛОУ-АВТ-6",
"План, т": 14855.0,
"Факт, т": 15149.647,
"id": "SNPZ.EAVT6"
}
],
"message": "Данные успешно получены"
}
}

View File

@@ -1,34 +0,0 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Union
from enum import Enum
class StatusesRepairCARequest(BaseModel):
ids: Optional[List[str]] = Field(
None,
description="Массив ID ОГ для фильтрации (например, ['SNPZ', 'KNPZ'])",
example=["SNPZ", "KNPZ", "ANHK"]
)
keys: Optional[List[List[str]]] = Field(
None,
description="Массив ключей для извлечения данных (например, [['Дата начала ремонта'], ['Готовность к КР', 'Факт']])",
example=[
["Дата начала ремонта"],
["Отставание / опережение подготовки к КР", "Отставание / опережение"],
["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"],
["Готовность к КР", "Факт"],
["Заключение договоров на СМР", "Договор", "%"],
["Поставка МТР", "На складе, позиций", "%"]
]
)
class Config:
json_schema_extra = {
"example": {
"ids": ["SNPZ", "KNPZ", "ANHK"],
"keys": [
["Дата начала ремонта"],
["Готовность к КР", "Факт"],
["Заключение договоров на СМР", "Договор", "%"]
]
}
}

View File

@@ -25,7 +25,7 @@ class OGID(str, Enum):
class SvodkaPMSingleOGRequest(BaseModel): class SvodkaPMSingleOGRequest(BaseModel):
id: str = Field( id: OGID = Field(
..., ...,
description="Идентификатор МА для запрашиваемого ОГ", description="Идентификатор МА для запрашиваемого ОГ",
example="SNPZ" example="SNPZ"

View File

@@ -1,46 +0,0 @@
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum
class RepairType(str, Enum):
"""Типы ремонтных работ"""
KR = "КР" # Капитальный ремонт
KP = "КП" # Капитальный ремонт
TR = "ТР" # Текущий ремонт
class SvodkaRepairCARequest(BaseModel):
"""Запрос на получение данных сводки ремонта СА"""
og_ids: Optional[List[str]] = Field(
default=None,
description="Список ID ОГ для фильтрации. Если не указан, возвращаются данные по всем ОГ",
example=["SNPZ", "KNPZ", "BASH"]
)
repair_types: Optional[List[RepairType]] = Field(
default=None,
description="Список типов ремонта для фильтрации. Если не указан, возвращаются все типы",
example=[RepairType.KR, RepairType.KP]
)
include_planned: bool = Field(
default=True,
description="Включать ли плановые данные"
)
include_factual: bool = Field(
default=True,
description="Включать ли фактические данные"
)
class Config:
json_schema_extra = {
"example": {
"og_ids": ["SNPZ", "KNPZ"],
"repair_types": ["КР", "КП"],
"include_planned": True,
"include_factual": True
}
}

View File

@@ -1,140 +0,0 @@
"""
Упрощенные утилиты для работы со схемами Pydantic
"""
from typing import List, Dict, Any, Type
from pydantic import BaseModel
import inspect
def get_required_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
"""
Извлекает список обязательных полей из схемы Pydantic
Args:
schema_class: Класс схемы Pydantic
Returns:
Список имен обязательных полей
"""
required_fields = []
# Используем model_fields для Pydantic v2 или __fields__ для v1
if hasattr(schema_class, 'model_fields'):
fields = schema_class.model_fields
else:
fields = schema_class.__fields__
for field_name, field_info in fields.items():
# В Pydantic v2 есть метод is_required()
if hasattr(field_info, 'is_required'):
if field_info.is_required():
required_fields.append(field_name)
elif hasattr(field_info, 'required'):
if field_info.required:
required_fields.append(field_name)
else:
# Fallback для старых версий - проверяем наличие default
has_default = False
if hasattr(field_info, 'default'):
has_default = field_info.default is not ...
elif hasattr(field_info, 'default_factory'):
has_default = field_info.default_factory is not None
if not has_default:
required_fields.append(field_name)
return required_fields
def get_optional_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
"""
Извлекает список необязательных полей из схемы Pydantic
Args:
schema_class: Класс схемы Pydantic
Returns:
Список имен необязательных полей
"""
optional_fields = []
# Используем model_fields для Pydantic v2 или __fields__ для v1
if hasattr(schema_class, 'model_fields'):
fields = schema_class.model_fields
else:
fields = schema_class.__fields__
for field_name, field_info in fields.items():
# В Pydantic v2 есть метод is_required()
if hasattr(field_info, 'is_required'):
if not field_info.is_required():
optional_fields.append(field_name)
elif hasattr(field_info, 'required'):
if not field_info.required:
optional_fields.append(field_name)
else:
# Fallback для старых версий - проверяем наличие default
has_default = False
if hasattr(field_info, 'default'):
has_default = field_info.default is not ...
elif hasattr(field_info, 'default_factory'):
has_default = field_info.default_factory is not None
if has_default:
optional_fields.append(field_name)
return optional_fields
def register_getter_from_schema(parser_instance, getter_name: str, method: callable,
schema_class: Type[BaseModel], description: str = ""):
"""
Регистрирует геттер в парсере, используя схему Pydantic для определения параметров
Args:
parser_instance: Экземпляр парсера
getter_name: Имя геттера
method: Метод для выполнения
schema_class: Класс схемы Pydantic
description: Описание геттера (если не указано, берется из docstring метода)
"""
# Извлекаем параметры из схемы
required_params = get_required_fields_from_schema(schema_class)
optional_params = get_optional_fields_from_schema(schema_class)
# Если описание не указано, берем из docstring метода
if not description:
description = inspect.getdoc(method) or ""
# Регистрируем геттер
parser_instance.register_getter(
name=getter_name,
method=method,
required_params=required_params,
optional_params=optional_params,
description=description
)
def validate_params_with_schema(params: Dict[str, Any], schema_class: Type[BaseModel]) -> Dict[str, Any]:
"""
Валидирует параметры с помощью схемы Pydantic
Args:
params: Словарь параметров
schema_class: Класс схемы Pydantic
Returns:
Валидированные параметры
Raises:
ValidationError: Если параметры не прошли валидацию
"""
try:
# Создаем экземпляр схемы для валидации
validated_data = schema_class(**params)
return validated_data.dict()
except Exception as e:
raise ValueError(f"Ошибка валидации параметров: {str(e)}")

View File

@@ -3,15 +3,11 @@
""" """
import tempfile import tempfile
import os import os
import logging
from typing import Dict, Type from typing import Dict, Type
from core.models import UploadRequest, UploadResult, DataRequest, DataResult from core.models import UploadRequest, UploadResult, DataRequest, DataResult
from core.ports import ParserPort, StoragePort from core.ports import ParserPort, StoragePort
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
# Глобальный словарь парсеров # Глобальный словарь парсеров
PARSERS: Dict[str, Type[ParserPort]] = {} PARSERS: Dict[str, Type[ParserPort]] = {}
@@ -47,7 +43,7 @@ class ReportService:
try: try:
# Парсим файл # Парсим файл
parse_params = request.parse_params or {} parse_params = request.parse_params or {}
parse_result = parser.parse(temp_file_path, parse_params) df = parser.parse(temp_file_path, parse_params)
# Генерируем object_id # Генерируем object_id
object_id = f"nin_excel_data_{request.report_type}" object_id = f"nin_excel_data_{request.report_type}"
@@ -55,10 +51,10 @@ class ReportService:
# Удаляем старый объект, если он существует и хранилище доступно # Удаляем старый объект, если он существует и хранилище доступно
if self.storage.object_exists(object_id): if self.storage.object_exists(object_id):
self.storage.delete_object(object_id) self.storage.delete_object(object_id)
logger.debug(f"Старый объект удален: {object_id}") print(f"Старый объект удален: {object_id}")
# Сохраняем в хранилище # Сохраняем в хранилище
if self.storage.save_dataframe(parse_result, object_id): if self.storage.save_dataframe(df, object_id):
return UploadResult( return UploadResult(
success=True, success=True,
message="Отчет успешно загружен", message="Отчет успешно загружен",
@@ -93,9 +89,9 @@ class ReportService:
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен." message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
) )
# Загружаем данные из хранилища # Загружаем DataFrame из хранилища
loaded_data = self.storage.load_dataframe(object_id) df = self.storage.load_dataframe(object_id)
if loaded_data is None: if df is None:
return DataResult( return DataResult(
success=False, success=False,
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен." message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
@@ -104,101 +100,25 @@ class ReportService:
# Получаем парсер # Получаем парсер
parser = get_parser(request.report_type) parser = get_parser(request.report_type)
# Устанавливаем данные в парсер для использования в геттерах # Устанавливаем DataFrame в парсер для использования в геттерах
parser.df = loaded_data parser.df = df
logger.debug(f"🔍 ReportService.get_data - установлены данные в парсер {request.report_type}")
# Проверяем тип загруженных данных
if hasattr(loaded_data, 'shape'):
# Это DataFrame
logger.debug(f"🔍 DataFrame shape: {loaded_data.shape}")
logger.debug(f"🔍 DataFrame columns: {list(loaded_data.columns) if not loaded_data.empty else 'Empty'}")
elif isinstance(loaded_data, dict):
# Это словарь (для парсера ПМ)
logger.debug(f"🔍 Словарь с ключами: {list(loaded_data.keys())}")
else:
logger.debug(f"🔍 Неизвестный тип данных: {type(loaded_data)}")
# Получаем параметры запроса # Получаем параметры запроса
get_params = request.get_params or {} get_params = request.get_params or {}
# Для svodka_ca определяем режим из данных или используем 'fact' по умолчанию # Определяем имя геттера (по умолчанию используем первый доступный)
if request.report_type == 'svodka_ca': getter_name = get_params.pop("getter", None)
# Извлекаем режим из DataFrame или используем 'fact' по умолчанию if not getter_name:
if hasattr(parser, 'df') and parser.df is not None and not parser.df.empty: # Если геттер не указан, берем первый доступный
modes_in_df = parser.df['mode'].unique() if 'mode' in parser.df.columns else ['fact'] available_getters = list(parser.getters.keys())
# Используем первый найденный режим или 'fact' по умолчанию if available_getters:
default_mode = modes_in_df[0] if len(modes_in_df) > 0 else 'fact' getter_name = available_getters[0]
print(f"⚠️ Геттер не указан, используем первый доступный: {getter_name}")
else: else:
default_mode = 'fact' return DataResult(
success=False,
# Устанавливаем режим в параметры, если он не указан message="Парсер не имеет доступных геттеров"
if 'mode' not in get_params: )
get_params['mode'] = default_mode
# Определяем имя геттера
if request.report_type == 'svodka_ca':
# Для svodka_ca используем геттер get_ca_data
getter_name = 'get_ca_data'
elif request.report_type == 'svodka_repair_ca':
# Для svodka_repair_ca используем геттер get_repair_data
getter_name = 'get_repair_data'
elif request.report_type == 'statuses_repair_ca':
# Для statuses_repair_ca используем геттер get_repair_statuses
getter_name = 'get_repair_statuses'
elif request.report_type == 'monitoring_tar':
# Для monitoring_tar определяем геттер по параметрам
if 'mode' in get_params:
# Если есть параметр mode, используем get_tar_data
getter_name = 'get_tar_data'
else:
# Если нет параметра mode, используем get_tar_full_data
getter_name = 'get_tar_full_data'
elif request.report_type == 'monitoring_fuel':
# Для monitoring_fuel определяем геттер из параметра mode
getter_name = get_params.pop("mode", None)
if not getter_name:
# Если режим не указан, берем первый доступный
available_getters = list(parser.getters.keys())
if available_getters:
getter_name = available_getters[0]
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
else:
return DataResult(
success=False,
message="Парсер не имеет доступных геттеров"
)
elif request.report_type == 'svodka_pm':
# Для svodka_pm определяем геттер из параметра mode
getter_name = get_params.pop("mode", None)
if not getter_name:
# Если режим не указан, берем первый доступный
available_getters = list(parser.getters.keys())
if available_getters:
getter_name = available_getters[0]
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
else:
return DataResult(
success=False,
message="Парсер не имеет доступных геттеров"
)
elif request.report_type == 'oper_spravka_tech_pos':
# Для oper_spravka_tech_pos используем геттер get_tech_pos
getter_name = 'get_tech_pos'
else:
# Для других парсеров определяем из параметра mode
getter_name = get_params.pop("mode", None)
if not getter_name:
# Если режим не указан, берем первый доступный
available_getters = list(parser.getters.keys())
if available_getters:
getter_name = available_getters[0]
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
else:
return DataResult(
success=False,
message="Парсер не имеет доступных геттеров"
)
# Получаем значение через указанный геттер # Получаем значение через указанный геттер
try: try:

View File

@@ -11,4 +11,5 @@ requests>=2.31.0
# pytest-cov>=4.0.0 # pytest-cov>=4.0.0
# pytest-mock>=3.10.0 # pytest-mock>=3.10.0
httpx>=0.24.0 httpx>=0.24.0
numpy numpy
streamlit>=1.28.0

View File

@@ -0,0 +1 @@
python-3.11.*

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env python3
"""
Простой тест для проверки работы FastAPI
"""
from fastapi import FastAPI
app = FastAPI(title="Test API")
@app.get("/")
async def root():
return {"message": "Test API is working"}
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
print("Starting test server...")
uvicorn.run(app, host="0.0.0.0", port=8000)

65
run_streamlit_local.py Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Запуск Streamlit интерфейса локально из изолированного пакета
"""
import subprocess
import sys
import webbrowser
import os
def main():
"""Основная функция"""
print("🚀 ЗАПУСК STREAMLIT ИЗ ИЗОЛИРОВАННОГО ПАКЕТА")
print("=" * 60)
print("Убедитесь, что FastAPI сервер запущен на порту 8000")
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:
import streamlit
print(f"✅ Streamlit {streamlit.__version__} установлен")
except ImportError:
print("❌ Streamlit не установлен")
print("Установите: pip install -r requirements.txt")
return
print("\n🚀 Запускаю Streamlit...")
print("📍 URL: http://localhost:8501")
print("🔗 API: http://localhost:8000")
print("🛑 Для остановки нажмите Ctrl+C")
# Открываем браузер
try:
webbrowser.open("http://localhost:8501")
print("✅ Браузер открыт")
except Exception as e:
print(f"⚠️ Не удалось открыть браузер: {e}")
# Запускаем Streamlit с правильными переменными окружения
env = os.environ.copy()
env["DOCKER_ENV"] = "false" # Локальный запуск
env["API_BASE_URL"] = "http://localhost:8000" # Локальный API
try:
subprocess.run([
sys.executable, "-m", "streamlit", "run", "app.py",
"--server.port", "8501",
"--server.address", "localhost",
"--server.headless", "false",
"--browser.gatherUsageStats", "false"
], env=env)
except KeyboardInterrupt:
print("\n👋 Streamlit остановлен")
if __name__ == "__main__":
main()

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для запуска тестов парсеров
"""
import subprocess
import sys
import os
def run_tests():
"""Запуск тестов"""
print(" Запуск тестов парсеров...")
print("=" * 50)
# Переходим в директорию проекта
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# Запускаем pytest
cmd = [sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short"]
try:
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
print(result.stdout)
print(" Все тесты прошли успешно!")
return True
except subprocess.CalledProcessError as e:
print(" Некоторые тесты не прошли:")
print(e.stdout)
print(e.stderr)
return False
if __name__ == "__main__":
success = run_tests()
sys.exit(0 if success else 1)

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для запуска проекта в режиме разработки
"""
import subprocess
import sys
import os
def run_command(command, description):
"""Выполнение команды с выводом"""
print(f"🔄 {description}...")
try:
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
print(f"{description} выполнено успешно")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Ошибка при {description.lower()}:")
print(f" Команда: {command}")
print(f" Ошибка: {e.stderr}")
return False
def main():
print("🚀 Запуск проекта в режиме разработки")
print("=" * 50)
# Останавливаем продакшн контейнеры если они запущены
if run_command("docker compose ps", "Проверка статуса контейнеров"):
if "Up" in subprocess.run("docker compose ps", shell=True, capture_output=True, text=True).stdout:
print("🛑 Останавливаю продакшн контейнеры...")
run_command("docker compose down", "Остановка продакшн контейнеров")
# Запускаем режим разработки
print("\n🔧 Запуск режима разработки...")
if run_command("docker compose -f docker-compose.dev.yml up -d", "Запуск контейнеров разработки"):
print("\n🎉 Проект запущен в режиме разработки!")
print("\n📍 Доступные сервисы:")
print(" • Streamlit: http://localhost:8501")
print(" • FastAPI: http://localhost:8000")
print(" • MinIO Console: http://localhost:9001")
print("\n💡 Теперь изменения в streamlit_app/ будут автоматически перезагружаться!")
print("\n🛑 Для остановки используйте:")
print(" docker compose -f docker-compose.dev.yml down")
else:
print("\nНе удалось запустить проект в режиме разработки")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для запуска проекта в продакшн режиме
"""
import subprocess
import sys
def run_command(command, description):
"""Выполнение команды с выводом"""
print(f"🔄 {description}...")
try:
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
print(f"{description} выполнено успешно")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Ошибка при {description.lower()}:")
print(f" Команда: {command}")
print(f" Ошибка: {e.stderr}")
return False
def main():
print("🚀 Запуск проекта в продакшн режиме")
print("=" * 50)
# Останавливаем контейнеры разработки если они запущены
if run_command("docker compose -f docker-compose.dev.yml ps", "Проверка статуса контейнеров разработки"):
if "Up" in subprocess.run("docker compose -f docker-compose.dev.yml ps", shell=True, capture_output=True, text=True).stdout:
print("🛑 Останавливаю контейнеры разработки...")
run_command("docker compose -f docker-compose.dev.yml down", "Остановка контейнеров разработки")
# Запускаем продакшн режим
print("\n🏭 Запуск продакшн режима...")
if run_command("docker compose up -d --build", "Запуск продакшн контейнеров"):
print("\n🎉 Проект запущен в продакшн режиме!")
print("\n📍 Доступные сервисы:")
print(" • Streamlit: http://localhost:8501")
print(" • FastAPI: http://localhost:8000")
print(" • MinIO Console: http://localhost:9001")
print("\n💡 Для разработки используйте:")
print(" python start_dev.py")
print("\n🛑 Для остановки используйте:")
print(" docker compose down")
else:
print("\nНе удалось запустить проект в продакшн режиме")
sys.exit(1)
if __name__ == "__main__":
main()

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

@@ -1,15 +0,0 @@
[server]
port = 8501
address = "0.0.0.0"
enableCORS = false
enableXsrfProtection = false
[browser]
gatherUsageStats = false
[theme]
primaryColor = "#FF4B4B"
backgroundColor = "#FFFFFF"
secondaryBackgroundColor = "#F0F2F6"
textColor = "#262730"
font = "sans serif"

View File

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

44
streamlit_app/README.md Normal file
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

@@ -1,100 +0,0 @@
import streamlit as st
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from minio import Minio
import os
from io import BytesIO
# Конфигурация страницы
st.set_page_config(
page_title="Сводка данных",
page_icon="📊",
layout="wide",
initial_sidebar_state="expanded"
)
# Заголовок приложения
st.title("📊 Анализ данных сводки")
st.markdown("---")
# Инициализация MinIO клиента
@st.cache_resource
def init_minio_client():
try:
client = Minio(
os.getenv("MINIO_ENDPOINT", "localhost:9000"),
access_key=os.getenv("MINIO_ACCESS_KEY", "minioadmin"),
secret_key=os.getenv("MINIO_SECRET_KEY", "minioadmin"),
secure=os.getenv("MINIO_SECURE", "false").lower() == "true"
)
return client
except Exception as e:
st.error(f"Ошибка подключения к MinIO: {e}")
return None
# Боковая панель
with st.sidebar:
st.header("⚙️ Настройки")
# Выбор типа данных
data_type = st.selectbox(
"Тип данных",
["Мониторинг топлива", "Сводка ПМ", "Сводка ЦА"]
)
# Выбор периода
period = st.date_input(
"Период",
value=pd.Timestamp.now().date()
)
st.markdown("---")
st.markdown("### 📈 Статистика")
st.info("Выберите тип данных для анализа")
# Основной контент
col1, col2 = st.columns([2, 1])
with col1:
st.subheader(f"📋 {data_type}")
if data_type == "Мониторинг топлива":
st.info("Анализ данных мониторинга топлива")
# Здесь будет логика для работы с данными мониторинга топлива
elif data_type == "Сводка ПМ":
st.info("Анализ данных сводки ПМ")
# Здесь будет логика для работы с данными сводки ПМ
elif data_type == "Сводка ЦА":
st.info("Анализ данных сводки ЦА")
# Здесь будет логика для работы с данными сводки ЦА
with col2:
st.subheader("📊 Быстрая статистика")
st.metric("Всего записей", "0")
st.metric("Активных", "0")
st.metric("Ошибок", "0")
# Нижняя панель
st.markdown("---")
st.subheader("🔍 Детальный анализ")
# Заглушка для графиков
placeholder = st.empty()
with placeholder.container():
col1, col2 = st.columns(2)
with col1:
st.write("📈 График 1")
# Здесь будет график
with col2:
st.write("📊 График 2")
# Здесь будет график
# Футер
st.markdown("---")
st.markdown("**Разработано для анализа данных сводки** | v1.0.0")

456
streamlit_app/app.py Normal file
View File

@@ -0,0 +1,456 @@
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 - автоматически определяем правильный адрес
def get_api_base_url():
"""Автоматически определяет правильный адрес API"""
# Если запущено в Docker, используем внутренний адрес
if os.getenv("DOCKER_ENV") == "true":
return "http://fastapi:8000"
# Если запущено локально, используем localhost
return "http://localhost:8000"
API_BASE_URL = os.getenv("API_BASE_URL", get_api_base_url())
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("<EFBFBD><EFBFBD> Получение данных")
# Показываем доступные геттеры
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(
"Выберите режимы",
["plan", "fact", "normativ"],
default=["plan", "fact"],
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', f'Неизвестная ошибка: {status}')}")
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

@@ -1,7 +1,4 @@
streamlit>=1.28.0 streamlit>=1.28.0
pandas>=2.0.0 requests>=2.31.0
numpy>=1.24.0 pandas>=1.5.0
plotly>=5.15.0 numpy>=1.24.0
minio>=7.1.0
openpyxl>=3.1.0
xlrd>=2.0.1

View File

@@ -1,847 +0,0 @@
import streamlit as st
import requests
import json
import pandas as pd
import io
import zipfile
from typing import Dict, Any, List
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") # Внутренний адрес для Docker
API_PUBLIC_URL = os.getenv("API_PUBLIC_URL", "http://localhost: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_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:
# Определяем правильное имя поля в зависимости от эндпоинта
if "zip" in endpoint:
files = {"zip_file": (filename, file_data, "application/zip")}
else:
files = {"file": (filename, file_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
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 get_available_ogs(parser_name: str) -> List[str]:
"""Получение доступных ОГ для парсера"""
try:
response = requests.get(f"{API_BASE_URL}/parsers/{parser_name}/available_ogs")
if response.status_code == 200:
data = response.json()
return data.get("available_ogs", [])
else:
print(f"⚠️ Ошибка получения ОГ: {response.status_code}")
return []
except Exception as e:
print(f"⚠️ Ошибка при запросе ОГ: {e}")
return []
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_PUBLIC_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, tab4, tab5, tab6, tab7 = st.tabs([
"📊 Сводки ПМ",
"🏭 Сводки СА",
"⛽ Мониторинг топлива",
"🔧 Ремонт СА",
"📋 Статусы ремонта СА",
"⚡ Мониторинг ТЭР",
"🏭 Операционные справки"
])
# Вкладка 1: Сводки ПМ - полный функционал
with tab1:
st.header("📊 Сводки ПМ - Полный функционал")
# Секция загрузки файлов
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("🔍 Получение данных")
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 = {
"id": og_id,
"codes": codes,
"columns": columns
}
result, status = make_api_request("/svodka_pm/get_single_og", 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 = {
"codes": codes_total,
"columns": columns_total
}
result, status = make_api_request("/svodka_pm/get_total_ogs", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите коды и столбцы")
# Вкладка 2: Сводки СА - полный функционал
with tab2:
st.header("🏭 Сводки СА - Полный функционал")
# Секция загрузки файлов
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("🔍 Получение данных")
col1, col2 = st.columns(2)
with col1:
st.subheader("Параметры запроса")
modes = st.multiselect(
"Выберите режимы",
["plan", "fact", "normativ"],
default=["plan", "fact"],
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 = {
"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("⛽ Мониторинг топлива - Полный функционал")
# Секция загрузки файлов
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("🔍 Получение данных")
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 = {
"columns": columns_fuel
}
result, status = make_api_request("/monitoring_fuel/get_total_by_columns", 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 = {
"month": month
}
result, status = make_api_request("/monitoring_fuel/get_month_by_code", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Вкладка 4: Ремонт СА
with tab4:
st.header("🔧 Ремонт СА - Управление ремонтными работами")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_file = st.file_uploader(
"Выберите Excel файл или ZIP архив с данными о ремонте СА",
type=['xlsx', 'xlsm', 'xls', 'zip'],
key="repair_ca_upload"
)
if uploaded_file is not None:
if st.button("📤 Загрузить файл", key="repair_ca_upload_btn"):
with st.spinner("Загружаю файл..."):
file_data = uploaded_file.read()
result, status = upload_file_to_api("/svodka_repair_ca/upload", file_data, uploaded_file.name)
if status == 200:
st.success("✅ Файл успешно загружен")
st.json(result)
else:
st.error(f"❌ Ошибка загрузки: {result.get('message', 'Неизвестная ошибка')}")
st.markdown("---")
# Секция получения данных
st.subheader("🔍 Получение данных")
col1, col2 = st.columns(2)
with col1:
st.subheader("Фильтры")
# Получаем доступные ОГ динамически
available_ogs = get_available_ogs("svodka_repair_ca")
# Фильтр по ОГ
og_ids = st.multiselect(
"Выберите ОГ (оставьте пустым для всех)",
available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback
key="repair_ca_og_ids"
)
# Фильтр по типам ремонта
repair_types = st.multiselect(
"Выберите типы ремонта (оставьте пустым для всех)",
["КР", "КП", "ТР"],
key="repair_ca_types"
)
# Включение плановых/фактических данных
include_planned = st.checkbox("Включать плановые данные", value=True, key="repair_ca_planned")
include_factual = st.checkbox("Включать фактические данные", value=True, key="repair_ca_factual")
with col2:
st.subheader("Действия")
if st.button("🔍 Получить данные о ремонте", key="repair_ca_get_btn"):
with st.spinner("Получаю данные..."):
data = {
"include_planned": include_planned,
"include_factual": include_factual
}
# Добавляем фильтры только если они выбраны
if og_ids:
data["og_ids"] = og_ids
if repair_types:
data["repair_types"] = repair_types
result, status = make_api_request("/svodka_repair_ca/get_data", data)
if status == 200:
st.success("✅ Данные получены")
# Отображаем данные в виде таблицы, если возможно
if result.get("data") and isinstance(result["data"], list):
df_data = []
for item in result["data"]:
df_data.append({
"ID ОГ": item.get("id", ""),
"Наименование": item.get("name", ""),
"Тип ремонта": item.get("type", ""),
"Дата начала": item.get("start_date", ""),
"Дата окончания": item.get("end_date", ""),
"План": item.get("plan", ""),
"Факт": item.get("fact", ""),
"Простой": item.get("downtime", "")
})
if df_data:
df = pd.DataFrame(df_data)
st.dataframe(df, use_container_width=True)
else:
st.info("📋 Нет данных для отображения")
else:
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Вкладка 5: Статусы ремонта СА
with tab5:
st.header("📋 Статусы ремонта СА")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_file = st.file_uploader(
"Выберите файл статусов ремонта СА",
type=['xlsx', 'xlsm', 'xls', 'zip'],
key="statuses_repair_ca_upload"
)
if uploaded_file is not None:
if st.button("📤 Загрузить файл", key="statuses_repair_ca_upload_btn"):
with st.spinner("Загружаем файл..."):
file_data = uploaded_file.read()
result, status_code = upload_file_to_api("/statuses_repair_ca/upload", file_data, uploaded_file.name)
if status_code == 200:
st.success("✅ Файл успешно загружен!")
st.json(result)
else:
st.error(f"❌ Ошибка загрузки: {result}")
# Секция получения данных
st.subheader("📊 Получение данных")
# Получаем доступные ОГ динамически
available_ogs = get_available_ogs("statuses_repair_ca")
# Фильтр по ОГ
og_ids = st.multiselect(
"Выберите ОГ (оставьте пустым для всех)",
available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback
key="statuses_repair_ca_og_ids"
)
# Предустановленные ключи для извлечения
st.subheader("🔑 Ключи для извлечения данных")
# Основные ключи
include_basic_keys = st.checkbox("Основные данные", value=True, key="statuses_basic_keys")
include_readiness_keys = st.checkbox("Готовность к КР", value=True, key="statuses_readiness_keys")
include_contract_keys = st.checkbox("Заключение договоров", value=True, key="statuses_contract_keys")
include_supply_keys = st.checkbox("Поставка МТР", value=True, key="statuses_supply_keys")
# Формируем ключи на основе выбора
keys = []
if include_basic_keys:
keys.append(["Дата начала ремонта"])
keys.append(["Отставание / опережение подготовки к КР", "Отставание / опережение"])
keys.append(["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"])
if include_readiness_keys:
keys.append(["Готовность к КР", "Факт"])
if include_contract_keys:
keys.append(["Заключение договоров на СМР", "Договор", "%"])
if include_supply_keys:
keys.append(["Поставка МТР", "На складе, позиций", "%"])
# Кнопка получения данных
if st.button("📊 Получить данные", key="statuses_repair_ca_get_data_btn"):
if not keys:
st.warning("⚠️ Выберите хотя бы одну группу ключей для извлечения")
else:
with st.spinner("Получаем данные..."):
request_data = {
"ids": og_ids if og_ids else None,
"keys": keys
}
result, status_code = make_api_request("/statuses_repair_ca/get_data", request_data)
if status_code == 200 and result.get("success"):
st.success("✅ Данные успешно получены!")
data = result.get("data", {}).get("value", [])
if data:
# Отображаем данные в виде таблицы
if isinstance(data, list) and len(data) > 0:
# Преобразуем в DataFrame для лучшего отображения
df_data = []
for item in data:
row = {
"ID": item.get("id", ""),
"Название": item.get("name", ""),
}
# Добавляем основные поля
if "Дата начала ремонта" in item:
row["Дата начала ремонта"] = item["Дата начала ремонта"]
# Добавляем готовность к КР
if "Готовность к КР" in item:
readiness = item["Готовность к КР"]
if isinstance(readiness, dict) and "Факт" in readiness:
row["Готовность к КР (Факт)"] = readiness["Факт"]
# Добавляем отставание/опережение
if "Отставание / опережение подготовки к КР" in item:
delay = item["Отставание / опережение подготовки к КР"]
if isinstance(delay, dict):
if "Отставание / опережение" in delay:
row["Отставание/опережение"] = delay["Отставание / опережение"]
if "Динамика за прошедшую неделю" in delay:
row["Динамика за неделю"] = delay["Динамика за прошедшую неделю"]
# Добавляем договоры
if "Заключение договоров на СМР" in item:
contracts = item["Заключение договоров на СМР"]
if isinstance(contracts, dict) and "Договор" in contracts:
contract = contracts["Договор"]
if isinstance(contract, dict) and "%" in contract:
row["Договоры (%)"] = contract["%"]
# Добавляем поставки МТР
if "Поставка МТР" in item:
supply = item["Поставка МТР"]
if isinstance(supply, dict) and "На складе, позиций" in supply:
warehouse = supply["На складе, позиций"]
if isinstance(warehouse, dict) and "%" in warehouse:
row["МТР на складе (%)"] = warehouse["%"]
df_data.append(row)
if df_data:
df = pd.DataFrame(df_data)
st.dataframe(df, use_container_width=True)
else:
st.info("📋 Нет данных для отображения")
else:
st.json(result)
else:
st.info("📋 Нет данных для отображения")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Вкладка 6: Мониторинг ТЭР
with tab6:
st.header("⚡ Мониторинг ТЭР (Топливно-энергетических ресурсов)")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_file = st.file_uploader(
"Выберите ZIP архив с файлами мониторинга ТЭР",
type=['zip'],
key="monitoring_tar_upload"
)
if uploaded_file is not None:
if st.button("📤 Загрузить файл", key="monitoring_tar_upload_btn"):
with st.spinner("Загружаем файл..."):
file_data = uploaded_file.read()
result, status_code = upload_file_to_api("/monitoring_tar/upload", file_data, uploaded_file.name)
if status_code == 200:
st.success("✅ Файл успешно загружен!")
st.json(result)
else:
st.error(f"❌ Ошибка загрузки: {result}")
# Секция получения данных
st.subheader("📊 Получение данных")
# Выбор формата отображения
display_format = st.radio(
"Формат отображения:",
["JSON", "Таблица"],
key="monitoring_tar_display_format",
horizontal=True
)
# Выбор режима данных
mode = st.selectbox(
"Выберите режим данных:",
["all", "total", "last_day"],
help="total - строки 'Всего' (агрегированные данные), last_day - последние строки данных, all - все данные",
key="monitoring_tar_mode"
)
if st.button("📊 Получить данные", key="monitoring_tar_get_data_btn"):
with st.spinner("Получаем данные..."):
# Выбираем эндпоинт в зависимости от режима
if mode == "all":
# Используем полный эндпоинт
result, status_code = make_api_request("/monitoring_tar/get_full_data", {})
else:
# Используем фильтрованный эндпоинт
request_data = {"mode": mode}
result, status_code = make_api_request("/monitoring_tar/get_data", request_data)
if status_code == 200 and result.get("success"):
st.success("✅ Данные успешно получены!")
# Показываем данные
data = result.get("data", {}).get("value", {})
if data:
st.subheader("📋 Результат:")
# # Отладочная информация
# st.write(f"🔍 Тип данных: {type(data)}")
# if isinstance(data, str):
# st.write(f"🔍 Длина строки: {len(data)}")
# st.write(f"🔍 Первые 200 символов: {data[:200]}...")
# Парсим данные, если они пришли как строка
if isinstance(data, str):
try:
import json
data = json.loads(data)
st.write("✅ JSON успешно распарсен")
except json.JSONDecodeError as e:
st.error(f"❌ Ошибка при парсинге JSON данных: {e}")
st.write("Сырые данные:", data)
return
if display_format == "JSON":
# Отображаем как JSON
st.json(data)
else:
# Отображаем как таблицы
if isinstance(data, dict):
# Показываем данные по установкам
for installation_id, installation_data in data.items():
with st.expander(f"🏭 {installation_id}"):
if isinstance(installation_data, dict):
# Показываем структуру данных
for data_type, type_data in installation_data.items():
st.write(f"**{data_type}:**")
if isinstance(type_data, list) and type_data:
df = pd.DataFrame(type_data)
st.dataframe(df)
else:
st.write("Нет данных")
else:
st.write("Нет данных")
else:
st.json(data)
else:
st.info("📋 Нет данных для отображения")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Вкладка 7: Операционные справки технологических позиций
with tab7:
st.header("🏭 Операционные справки технологических позиций")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_file = st.file_uploader(
"Выберите ZIP архив с файлами операционных справок",
type=['zip'],
key="oper_spravka_tech_pos_upload"
)
if uploaded_file is not None:
if st.button("📤 Загрузить файл", key="oper_spravka_tech_pos_upload_btn"):
with st.spinner("Загружаем файл..."):
file_data = uploaded_file.read()
result, status_code = upload_file_to_api("/oper_spravka_tech_pos/upload", file_data, uploaded_file.name)
if status_code == 200:
st.success("✅ Файл успешно загружен!")
st.json(result)
else:
st.error(f"❌ Ошибка загрузки: {result}")
st.markdown("---")
# Секция получения данных
st.subheader("📊 Получение данных")
# Выбор формата отображения
display_format = st.radio(
"Формат отображения:",
["JSON", "Таблица"],
key="oper_spravka_tech_pos_display_format",
horizontal=True
)
# Получаем доступные ОГ динамически
available_ogs = get_available_ogs("oper_spravka_tech_pos")
# Выбор ОГ
og_id = st.selectbox(
"Выберите ОГ:",
available_ogs if available_ogs else ["SNPZ", "KNPZ", "ANHK", "BASH", "UNH", "NOV"],
key="oper_spravka_tech_pos_og_id"
)
if st.button("📊 Получить данные", key="oper_spravka_tech_pos_get_data_btn"):
with st.spinner("Получаем данные..."):
request_data = {"id": og_id}
result, status_code = make_api_request("/oper_spravka_tech_pos/get_data", request_data)
if status_code == 200 and result.get("success"):
st.success("✅ Данные успешно получены!")
# Показываем данные
data = result.get("data", [])
if data and len(data) > 0:
st.subheader("📋 Результат:")
if display_format == "JSON":
# Отображаем как JSON
st.json(data)
else:
# Отображаем как таблицу
if isinstance(data, list) and data:
df = pd.DataFrame(data)
st.dataframe(df, use_container_width=True)
else:
st.write("Нет данных")
else:
st.info("📋 Нет данных для отображения")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Футер
st.markdown("---")
st.markdown("### 📚 Документация API")
st.markdown(f"Полная документация доступна по адресу: {API_PUBLIC_URL}/docs")
# Информация о проекте
with st.expander(" О проекте"):
st.markdown("""
**NIN Excel Parsers API** - это веб-сервис для парсинга и обработки Excel-файлов нефтеперерабатывающих заводов.
**Возможности:**
- 📊 Парсинг сводок ПМ (план и факт)
- 🏭 Парсинг сводок СА
- ⛽ Мониторинг топлива
- ⚡ Мониторинг ТЭР (Топливно-энергетические ресурсы)
- 🔧 Управление ремонтными работами СА
- 📋 Мониторинг статусов ремонта СА
**Технологии:**
- FastAPI
- Pandas
- MinIO (S3-совместимое хранилище)
- Streamlit (веб-интерфейс)
""")
if __name__ == "__main__":
main()

84
test_api.py Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки API
"""
import requests
import json
def test_api_endpoints():
"""Тестирование API эндпоинтов"""
base_url = "http://localhost:8000"
print("🧪 ТЕСТИРОВАНИЕ API")
print("=" * 50)
# Тест 1: Проверка доступности API
print("\n1⃣ Проверка доступности API...")
try:
response = requests.get(f"{base_url}/")
if response.status_code == 200:
print(f"✅ API доступен: {response.json()}")
else:
print(f"❌ API недоступен: {response.status_code}")
return False
except Exception as e:
print(f"❌ Ошибка подключения к API: {e}")
return False
# Тест 2: Список парсеров
print("\n2⃣ Получение списка парсеров...")
try:
response = requests.get(f"{base_url}/parsers")
if response.status_code == 200:
parsers = response.json()
print(f"✅ Парсеры: {parsers}")
else:
print(f"❌ Ошибка получения парсеров: {response.status_code}")
except Exception as e:
print(f"❌ Ошибка: {e}")
# Тест 3: Информация о геттерах
print("\n3⃣ Информация о геттерах парсеров...")
parsers_to_test = ["svodka_pm", "svodka_ca", "monitoring_fuel"]
for parser in parsers_to_test:
try:
response = requests.get(f"{base_url}/parsers/{parser}/getters")
if response.status_code == 200:
getters = response.json()
print(f"{parser}: {len(getters.get('getters', {}))} геттеров")
else:
print(f"{parser}: ошибка {response.status_code}")
except Exception as e:
print(f"{parser}: ошибка {e}")
# Тест 4: Загрузка тестового файла
print("\n4⃣ Тест загрузки файла...")
try:
# Создаем простой Excel файл для теста
test_data = b"test content"
files = {"file": ("test.xlsx", test_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
response = requests.post(f"{base_url}/svodka_ca/upload", files=files)
print(f"📤 Результат загрузки: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Файл загружен: {result}")
else:
print(f"❌ Ошибка загрузки: {response.status_code}")
try:
error_detail = response.json()
print(f"📋 Детали ошибки: {error_detail}")
except:
print(f"📋 Текст ошибки: {response.text}")
except Exception as e:
print(f"❌ Ошибка теста загрузки: {e}")
print("\n🎯 Тестирование завершено!")
return True
if __name__ == "__main__":
test_api_endpoints()

79
test_api_direct.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Прямое тестирование API эндпоинтов
"""
import requests
import json
def test_api_endpoints():
"""Тестирование API эндпоинтов"""
base_url = "http://localhost:8000"
print("🧪 ПРЯМОЕ ТЕСТИРОВАНИЕ API")
print("=" * 40)
# Тест 1: Проверка доступности API
print("\n1⃣ Проверка доступности API...")
try:
response = requests.get(f"{base_url}/")
print(f"✅ API доступен: {response.status_code}")
except Exception as e:
print(f"❌ Ошибка: {e}")
return
# Тест 2: Тестирование эндпоинта svodka_ca/get_data
print("\n2⃣ Тестирование svodka_ca/get_data...")
try:
data = {
"getter": "get_data",
"modes": ["plan", "fact"],
"tables": ["ТиП", "Топливо"]
}
response = requests.post(f"{base_url}/svodka_ca/get_data", json=data)
print(f"📥 Результат: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Успешно: {result}")
else:
try:
error_detail = response.json()
print(f"❌ Ошибка: {error_detail}")
except:
print(f"❌ Ошибка: {response.text}")
except Exception as e:
print(f"❌ Исключение: {e}")
# Тест 3: Тестирование эндпоинта svodka_pm/get_data
print("\n3⃣ Тестирование svodka_pm/get_data...")
try:
data = {
"getter": "single_og",
"id": "SNPZ",
"codes": [78, 79],
"columns": ["БП", "ПП"]
}
response = requests.post(f"{base_url}/svodka_pm/get_data", json=data)
print(f"📥 Результат: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Успешно: {result}")
else:
try:
error_detail = response.json()
print(f"❌ Ошибка: {error_detail}")
except:
print(f"❌ Ошибка: {response.text}")
except Exception as e:
print(f"❌ Исключение: {e}")
print("\n🎯 Тестирование завершено!")
if __name__ == "__main__":
test_api_endpoints()

96
test_ca_workflow.py Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Тестирование полного workflow с сводкой СА
"""
import requests
import os
import time
def test_ca_workflow():
"""Тестирование полного workflow с сводкой СА"""
base_url = "http://localhost:8000"
test_file = "python_parser/data/svodka_ca.xlsx"
print("🧪 ТЕСТ ПОЛНОГО WORKFLOW СВОДКИ СА")
print("=" * 50)
# Проверяем, что файл существует
if not os.path.exists(test_file):
print(f"❌ Файл {test_file} не найден")
return False
print(f"📁 Тестовый файл найден: {test_file}")
print(f"📏 Размер: {os.path.getsize(test_file)} байт")
# Шаг 1: Загружаем файл
print("\n1⃣ Загружаю файл сводки СА...")
try:
with open(test_file, 'rb') as f:
file_data = f.read()
files = {"file": ("svodka_ca.xlsx", file_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
response = requests.post(f"{base_url}/svodka_ca/upload", files=files)
print(f"📤 Результат загрузки: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Файл загружен: {result}")
object_id = result.get('object_id', 'nin_excel_data_svodka_ca')
else:
print(f"❌ Ошибка загрузки: {response.status_code}")
try:
error_detail = response.json()
print(f"📋 Детали ошибки: {error_detail}")
except:
print(f"📋 Текст ошибки: {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка загрузки: {e}")
return False
# Шаг 2: Получаем данные через геттер
print("\n2⃣ Получаю данные через геттер...")
try:
data = {
"getter": "get_data",
"modes": ["plan", "fact"], # Используем английские названия
"tables": ["ТиП", "Топливо"]
}
response = requests.post(f"{base_url}/svodka_ca/get_data", json=data)
print(f"📥 Результат получения данных: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Данные получены успешно!")
print(f"📊 Размер ответа: {len(str(result))} символов")
# Показываем структуру данных
if isinstance(result, dict):
print(f"🔍 Структура данных:")
for key, value in result.items():
if isinstance(value, dict):
print(f" {key}: {len(value)} элементов")
else:
print(f" {key}: {type(value).__name__}")
else:
print(f"❌ Ошибка получения данных: {response.status_code}")
try:
error_detail = response.json()
print(f"📋 Детали ошибки: {error_detail}")
except:
print(f"📋 Текст ошибки: {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return False
print("\n🎯 Тестирование завершено успешно!")
return True
if __name__ == "__main__":
test_ca_workflow()

110
test_minio_connection.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки подключения к MinIO
"""
import os
import sys
import io
from minio import Minio
def test_minio_connection():
"""Тестирование подключения к MinIO"""
print("🔍 Тестирование подключения к MinIO...")
# Параметры подключения
endpoint = os.getenv("MINIO_ENDPOINT", "localhost:9000")
access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin")
bucket_name = os.getenv("MINIO_BUCKET", "svodka-data")
print(f"📍 Endpoint: {endpoint}")
print(f"🔑 Access Key: {access_key}")
print(f"🔐 Secret Key: {secret_key}")
print(f"🪣 Bucket: {bucket_name}")
try:
# Создаем клиент
print("\n🚀 Создаю MinIO клиент...")
client = Minio(
endpoint,
access_key=access_key,
secret_key=secret_key,
secure=False,
cert_check=False
)
# Проверяем подключение
print("✅ MinIO клиент создан")
# Проверяем bucket
print(f"\n🔍 Проверяю bucket '{bucket_name}'...")
if client.bucket_exists(bucket_name):
print(f"✅ Bucket '{bucket_name}' существует")
else:
print(f"⚠️ Bucket '{bucket_name}' не существует, создаю...")
client.make_bucket(bucket_name)
print(f"✅ Bucket '{bucket_name}' создан")
# Пробуем загрузить тестовый файл
print("\n📤 Тестирую загрузку файла...")
test_data = b"Hello MinIO!"
test_stream = io.BytesIO(test_data)
client.put_object(
bucket_name,
"test.txt",
test_stream,
length=len(test_data),
content_type='text/plain'
)
print("✅ Тестовый файл загружен")
# Пробуем скачать файл
print("\n📥 Тестирую скачивание файла...")
response = client.get_object(bucket_name, "test.txt")
downloaded_data = response.read()
print(f"✅ Файл скачан: {downloaded_data}")
# Удаляем тестовый файл
client.remove_object(bucket_name, "test.txt")
print("✅ Тестовый файл удален")
print("\n🎉 Все тесты MinIO прошли успешно!")
return True
except Exception as e:
print(f"\n❌ Ошибка подключения к MinIO: {e}")
print(f"Тип ошибки: {type(e).__name__}")
return False
def test_environment():
"""Проверка переменных окружения"""
print("🔧 Проверка переменных окружения:")
env_vars = [
"MINIO_ENDPOINT",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"MINIO_BUCKET"
]
for var in env_vars:
value = os.getenv(var, "НЕ УСТАНОВЛЕНО")
print(f" {var}: {value}")
if __name__ == "__main__":
print("=" * 60)
print("🧪 ТЕСТ ПОДКЛЮЧЕНИЯ К MINIO")
print("=" * 60)
test_environment()
print()
success = test_minio_connection()
if success:
print("\n✅ MinIO работает корректно!")
sys.exit(0)
else:
print("\n❌ Проблемы с MinIO!")
sys.exit(1)

Binary file not shown.

69
test_upload.py Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Тестирование загрузки Excel файла
"""
import requests
import os
def test_file_upload():
"""Тестирование загрузки файла"""
base_url = "http://localhost:8000"
filename = "test_file.xlsx"
print("🧪 ТЕСТ ЗАГРУЗКИ ФАЙЛА")
print("=" * 40)
# Проверяем, что файл существует
if not os.path.exists(filename):
print(f"❌ Файл {filename} не найден")
return False
print(f"📁 Файл найден: {filename}")
print(f"📏 Размер: {os.path.getsize(filename)} байт")
# Тестируем загрузку в разные парсеры
parsers = [
("svodka_ca", "/svodka_ca/upload", "file"),
("monitoring_fuel", "/monitoring_fuel/upload-zip", "zip_file"),
("svodka_pm", "/svodka_pm/upload-zip", "zip_file")
]
for parser_name, endpoint, file_param in parsers:
print(f"\n🔍 Тестирую {parser_name}...")
try:
# Читаем файл
with open(filename, 'rb') as f:
file_data = f.read()
# Определяем content type
if filename.endswith('.xlsx'):
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
content_type = "application/octet-stream"
# Загружаем файл с правильным параметром
files = {file_param: (filename, file_data, content_type)}
response = requests.post(f"{base_url}{endpoint}", files=files)
print(f"📤 Результат: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Успешно: {result}")
else:
try:
error_detail = response.json()
print(f"❌ Ошибка: {error_detail}")
except:
print(f"❌ Ошибка: {response.text}")
except Exception as e:
print(f"❌ Исключение: {e}")
print("\n🎯 Тестирование завершено!")
return True
if __name__ == "__main__":
test_file_upload()

View File

@@ -1,44 +0,0 @@
# Тесты для парсеров
Этот каталог содержит pytest тесты для всех парсеров и их геттеров.
## Структура
- est_parsers.py - Основные тесты для всех парсеров
- conftest.py - Конфигурация pytest
-
equirements.txt - Зависимости для тестов
- est_data/ - Тестовые данные
## Запуск тестов
`ash
# Установка зависимостей
pip install -r tests/requirements.txt
# Запуск всех тестов
pytest tests/
# Запуск конкретного теста
pytest tests/test_parsers.py::TestSvodkaPMParser
# Запуск с подробным выводом
pytest tests/ -v
# Запуск с покрытием кода
pytest tests/ --cov=python_parser
`
## Покрытие тестами
Тесты покрывают:
- Инициализацию всех парсеров
- Все геттеры каждого парсера
- Обработку валидных и невалидных параметров
- Интеграционные тесты
## Добавление новых тестов
При добавлении нового парсера:
1. Добавьте класс тестов в est_parsers.py
2. Создайте тесты для всех геттеров

View File

@@ -1,28 +0,0 @@
"""
Конфигурация pytest для тестов парсеров
"""
import pytest
import sys
import os
# Добавляем путь к проекту
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python_parser'))
@pytest.fixture(scope="session")
def test_data_dir():
"""Путь к директории с тестовыми данными"""
return os.path.join(os.path.dirname(__file__), 'test_data')
@pytest.fixture
def mock_data():
"""Моковые данные для тестов"""
return {
'SNPZ': {
'data': 'test_data',
'records_count': 10
},
'KNPZ': {
'data': 'test_data_2',
'records_count': 5
}
}

View File

@@ -1,4 +0,0 @@
pytest>=7.0.0
pandas>=1.5.0
numpy>=1.20.0
openpyxl>=3.0.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,394 +0,0 @@
"""
Тесты для всех парсеров и их геттеров
"""
import pytest
import pandas as pd
import tempfile
import os
from unittest.mock import Mock, patch
import sys
# Добавляем путь к проекту
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python_parser'))
from adapters.parsers import (
SvodkaPMParser,
SvodkaCAParser,
MonitoringFuelParser,
MonitoringTarParser,
SvodkaRepairCAParser,
StatusesRepairCAParser,
OperSpravkaTechPosParser
)
class TestSvodkaPMParser:
"""Тесты для парсера Сводки ПМ"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = SvodkaPMParser()
# Создаем тестовые данные
self.test_data = {
'SNPZ': pd.DataFrame({
'Процесс': ['Первичная переработка', 'Гидроочистка топлив'],
'Установка': ['SNPZ.EAVT6', 'SNPZ.L24-6'],
'План, т': [100.0, 200.0],
'Факт, т': [95.0, 190.0]
})
}
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "Сводки ПМ"
assert hasattr(self.parser, 'getters')
assert len(self.parser.getters) == 2
assert 'single_og' in self.parser.getters
assert 'total_ogs' in self.parser.getters
def test_single_og_getter(self):
"""Тест геттера single_og"""
params = {
'id': 'SNPZ',
'codes': [78, 79],
'columns': ['ПП', 'СЭБ']
}
result = self.parser.get_value('single_og', params)
assert result is not None
assert isinstance(result, str) # Возвращает JSON строку
def test_total_ogs_getter(self):
"""Тест геттера total_ogs"""
params = {
'codes': [78, 79],
'columns': ['ПП', 'СЭБ']
}
result = self.parser.get_value('total_ogs', params)
assert result is not None
assert isinstance(result, str) # Возвращает JSON строку
def test_getter_with_invalid_params(self):
"""Тест геттера с неверными параметрами"""
with pytest.raises(ValueError):
self.parser.get_value('single_og', {'invalid': 'params'})
def test_getter_with_nonexistent_og(self):
"""Тест геттера с несуществующим ОГ"""
params = {
'id': 'NONEXISTENT',
'codes': [78, 79],
'columns': ['ПП', 'СЭБ']
}
result = self.parser.get_value('single_og', params)
# Должен вернуть пустой результат, но не упасть
assert result is not None
class TestSvodkaCAParser:
"""Тесты для парсера Сводки СА"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = SvodkaCAParser()
# Создаем тестовые данные
self.test_data = {
'plan': {
'ТиП': pd.DataFrame({
'ОГ': ['SNPZ', 'KNPZ'],
'Значение': [100.0, 200.0]
})
},
'fact': {
'ТиП': pd.DataFrame({
'ОГ': ['SNPZ', 'KNPZ'],
'Значение': [95.0, 190.0]
})
}
}
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "Сводки СА"
assert hasattr(self.parser, 'getters')
assert len(self.parser.getters) == 1
assert 'get_ca_data' in self.parser.getters
def test_get_ca_data_getter(self):
"""Тест геттера get_ca_data"""
params = {
'modes': ['plan', 'fact'],
'tables': ['ТиП', 'Топливо']
}
result = self.parser.get_value('get_ca_data', params)
assert result is not None
assert isinstance(result, dict) # Возвращает словарь
class TestMonitoringFuelParser:
"""Тесты для парсера Мониторинга топлива"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = MonitoringFuelParser()
# Создаем тестовые данные
self.test_data = {
'SNPZ': pd.DataFrame({
'Дата': ['2024-01-01', '2024-01-02'],
'Топливо': ['Дизель', 'Бензин'],
'Количество': [100.0, 200.0],
'Объем': [50.0, 75.0] # Добавляем числовую колонку для агрегации
})
}
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "Мониторинг топлива"
assert hasattr(self.parser, 'getters')
# Проверяем, что есть геттеры
assert len(self.parser.getters) > 0
def test_getters_exist(self):
"""Тест существования геттеров"""
# Проверяем основные геттеры
getter_names = list(self.parser.getters.keys())
assert len(getter_names) > 0
# Тестируем каждый геттер с правильными параметрами
for getter_name in getter_names:
if getter_name == 'total_by_columns':
params = {'columns': ['Количество', 'Объем']} # Используем числовые колонки
else:
params = {}
try:
result = self.parser.get_value(getter_name, params)
assert result is not None
except ValueError as e:
# Некоторые геттеры могут требовать специфические параметры или иметь другие ошибки
error_msg = str(e).lower()
assert any(keyword in error_msg for keyword in ["required", "missing", "отсутствуют", "обязательные", "ошибка выполнения"])
class TestMonitoringTarParser:
"""Тесты для парсера Мониторинга ТЭР"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = MonitoringTarParser()
# Создаем тестовые данные
self.test_data = {
'total': [pd.DataFrame({
'Дата': ['2024-01-01'],
'Потребление': [100.0]
})],
'last_day': [pd.DataFrame({
'Дата': ['2024-01-02'],
'Потребление': [150.0]
})]
}
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "monitoring_tar"
assert hasattr(self.parser, 'getters')
assert len(self.parser.getters) == 2
assert 'get_tar_data' in self.parser.getters
assert 'get_tar_full_data' in self.parser.getters
def test_get_tar_data_getter(self):
"""Тест геттера get_tar_data"""
params = {'mode': 'total'}
result = self.parser.get_value('get_tar_data', params)
assert result is not None
assert isinstance(result, str) # Возвращает JSON строку
def test_get_tar_full_data_getter(self):
"""Тест геттера get_tar_full_data"""
result = self.parser.get_value('get_tar_full_data', {})
assert result is not None
assert isinstance(result, str) # Возвращает JSON строку
class TestSvodkaRepairCAParser:
"""Тесты для парсера Сводки ремонта СА"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = SvodkaRepairCAParser()
# Создаем тестовые данные в правильном формате
self.test_data = [
{
'id': 'SNPZ',
'Тип_ремонта': 'Капитальный',
'Статус': 'Завершен',
'Дата': '2024-01-01'
},
{
'id': 'SNPZ',
'Тип_ремонта': 'Текущий',
'Статус': 'В работе',
'Дата': '2024-01-02'
}
]
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "Сводки ремонта СА"
assert hasattr(self.parser, 'getters')
assert len(self.parser.getters) == 1
assert 'get_repair_data' in self.parser.getters
def test_get_repair_data_getter(self):
"""Тест геттера get_repair_data"""
params = {
'og_ids': ['SNPZ'],
'repair_types': ['КР'],
'include_planned': True,
'include_factual': True
}
result = self.parser.get_value('get_repair_data', params)
assert result is not None
assert isinstance(result, dict) # Возвращает словарь
class TestStatusesRepairCAParser:
"""Тесты для парсера Статусов ремонта СА"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = StatusesRepairCAParser()
# Создаем тестовые данные
self.test_data = {
'SNPZ': pd.DataFrame({
'Статус': ['В работе', 'Завершен'],
'Процент': [50.0, 100.0]
})
}
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "Статусы ремонта СА"
assert hasattr(self.parser, 'getters')
assert len(self.parser.getters) == 1
assert 'get_repair_statuses' in self.parser.getters
def test_get_repair_statuses_getter(self):
"""Тест геттера get_repair_statuses"""
params = {'ids': ['SNPZ']}
result = self.parser.get_value('get_repair_statuses', params)
assert result is not None
assert isinstance(result, list) # Возвращает список
class TestOperSpravkaTechPosParser:
"""Тесты для парсера Операционных справок технологических позиций"""
def setup_method(self):
"""Настройка перед каждым тестом"""
self.parser = OperSpravkaTechPosParser()
# Создаем тестовые данные
self.test_data = {
'SNPZ': pd.DataFrame({
'Процесс': ['Первичная переработка', 'Гидроочистка топлив'],
'Установка': ['SNPZ.EAVT6', 'SNPZ.L24-6'],
'План, т': [100.0, 200.0],
'Факт, т': [95.0, 190.0],
'id': ['SNPZ.EAVT6', 'SNPZ.L24-6']
})
}
self.parser.data_dict = self.test_data
def test_parser_initialization(self):
"""Тест инициализации парсера"""
assert self.parser.name == "oper_spravka_tech_pos"
assert hasattr(self.parser, 'getters')
assert len(self.parser.getters) == 1
assert 'get_tech_pos' in self.parser.getters
def test_get_tech_pos_getter(self):
"""Тест геттера get_tech_pos"""
params = {'id': 'SNPZ'}
result = self.parser.get_value('get_tech_pos', params)
assert result is not None
assert isinstance(result, list) # Возвращает список словарей
def test_get_tech_pos_with_nonexistent_og(self):
"""Тест геттера с несуществующим ОГ"""
params = {'id': 'NONEXISTENT'}
result = self.parser.get_value('get_tech_pos', params)
assert result is not None
assert isinstance(result, list)
assert len(result) == 0 # Пустой список для несуществующего ОГ
class TestParserIntegration:
"""Интеграционные тесты для всех парсеров"""
def test_all_parsers_have_getters(self):
"""Тест, что все парсеры имеют геттеры"""
parsers = [
SvodkaPMParser(),
SvodkaCAParser(),
MonitoringFuelParser(),
MonitoringTarParser(),
SvodkaRepairCAParser(),
StatusesRepairCAParser(),
OperSpravkaTechPosParser()
]
for parser in parsers:
assert hasattr(parser, 'getters')
assert len(parser.getters) > 0
assert hasattr(parser, 'name')
assert parser.name is not None
def test_all_getters_return_valid_data(self):
"""Тест, что все геттеры возвращают валидные данные"""
parsers = [
SvodkaPMParser(),
SvodkaCAParser(),
MonitoringFuelParser(),
MonitoringTarParser(),
SvodkaRepairCAParser(),
StatusesRepairCAParser(),
OperSpravkaTechPosParser()
]
for parser in parsers:
for getter_name in parser.getters.keys():
try:
result = parser.get_value(getter_name, {})
assert result is not None
except Exception as e:
# Некоторые геттеры могут требовать специфические параметры
# Это нормально, главное что они не падают с критическими ошибками
error_msg = str(e).lower()
assert any(keyword in error_msg for keyword in ["required", "missing", "отсутствуют", "обязательные"])
if __name__ == "__main__":
pytest.main([__file__])