Compare commits
41 Commits
fix-1
...
9f9adce4f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f9adce4f3 | |||
| 8ee1c816e2 | |||
| 34937ec062 | |||
| 55626490dd | |||
| 1bfe3c0cd8 | |||
| 36f37ffacb | |||
| 6a1f685ee3 | |||
| 2fcee9f065 | |||
| f54a36ab22 | |||
| 2555fd80e0 | |||
| 847441842c | |||
| 00a01e99d7 | |||
| bbbfbbd508 | |||
| 0f3340c899 | |||
| 3c0fce128d | |||
| b5c460bb6f | |||
| 4aca4ed6c6 | |||
| 8ede706a1e | |||
| 1d43ba8c5a | |||
| e22ef647eb | |||
| 51ee5bf73b | |||
| 0a328f9781 | |||
| 1fcb44193d | |||
| 631e58dad7 | |||
| 15d13870f3 | |||
| eb6d23bba8 | |||
| e3077252a8 | |||
| 8ed61a3c0b | |||
| 9c152ebe94 | |||
| b8074765e3 | |||
| 79ab91c700 | |||
| b98be22359 | |||
| fc0b4356da | |||
| 72fe115a99 | |||
| 46a30c32ed | |||
| 5e217c7cce | |||
| 7d2747c8fe | |||
| 513ff3c144 | |||
| a0b6e04d99 | |||
| 47a7344755 | |||
| 456e9935f0 |
175
.gitignore
vendored
Normal file
175
.gitignore
vendored
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Python
|
||||||
|
__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.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.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
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# FastAPI
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
.streamlit/secrets.toml
|
||||||
|
|
||||||
|
# Node.js (if any frontend components)
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
1002
PARSER_DEVELOPMENT_GUIDE.md
Normal file
1002
PARSER_DEVELOPMENT_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
41
QUICK_START.md
Normal file
41
QUICK_START.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 🚀 Быстрый запуск проекта
|
||||||
|
|
||||||
|
## 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 будет скачивать образы и собирать контейнеры, это может занять несколько минут.
|
||||||
117
README.md
Normal file
117
README.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Python Parser CF - Система анализа данных
|
||||||
|
|
||||||
|
Проект состоит из трех основных компонентов:
|
||||||
|
- **python_parser** - FastAPI приложение для парсинга и обработки данных
|
||||||
|
- **streamlit_app** - Streamlit приложение для визуализации и анализа
|
||||||
|
- **minio_data** - хранилище данных MinIO
|
||||||
|
|
||||||
|
## 🚀 Быстрый запуск
|
||||||
|
|
||||||
|
### Предварительные требования
|
||||||
|
- Docker и Docker Compose
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Запуск всех сервисов (продакшн)
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск в режиме разработки
|
||||||
|
```bash
|
||||||
|
# Автоматический запуск
|
||||||
|
python start_dev.py
|
||||||
|
|
||||||
|
# Или вручную
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Режим разработки** позволяет:
|
||||||
|
- Автоматически перезагружать Streamlit при изменении кода
|
||||||
|
- Монтировать исходный код напрямую в контейнер
|
||||||
|
- Видеть изменения без пересборки контейнеров
|
||||||
|
|
||||||
|
### Доступ к сервисам
|
||||||
|
- **FastAPI**: http://localhost:8000
|
||||||
|
- **Streamlit**: http://localhost:8501
|
||||||
|
- **MinIO Console**: http://localhost:9001
|
||||||
|
- **MinIO API**: http://localhost:9000
|
||||||
|
|
||||||
|
### Остановка сервисов
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
python_parser_cf/
|
||||||
|
├── python_parser/ # FastAPI приложение
|
||||||
|
│ ├── app/ # Основной код приложения
|
||||||
|
│ ├── adapters/ # Адаптеры для парсеров
|
||||||
|
│ ├── core/ # Основная бизнес-логика
|
||||||
|
│ ├── data/ # Тестовые данные
|
||||||
|
│ └── Dockerfile # Docker образ для FastAPI
|
||||||
|
├── streamlit_app/ # Streamlit приложение
|
||||||
|
│ ├── streamlit_app.py # Основной файл приложения
|
||||||
|
│ ├── requirements.txt # Зависимости Python
|
||||||
|
│ ├── .streamlit/ # Конфигурация Streamlit
|
||||||
|
│ └── Dockerfile # Docker образ для Streamlit
|
||||||
|
├── minio_data/ # Данные для MinIO
|
||||||
|
├── docker-compose.yml # Конфигурация всех сервисов
|
||||||
|
└── README.md # Документация
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
Все сервисы используют следующие переменные окружения:
|
||||||
|
- `MINIO_ENDPOINT` - адрес MinIO сервера
|
||||||
|
- `MINIO_ACCESS_KEY` - ключ доступа к MinIO
|
||||||
|
- `MINIO_SECRET_KEY` - секретный ключ MinIO
|
||||||
|
- `MINIO_SECURE` - использование SSL/TLS
|
||||||
|
- `MINIO_BUCKET` - имя bucket'а для данных
|
||||||
|
|
||||||
|
### Порты
|
||||||
|
- **8000** - FastAPI
|
||||||
|
- **8501** - Streamlit
|
||||||
|
- **9000** - MinIO API
|
||||||
|
- **9001** - MinIO Console
|
||||||
|
|
||||||
|
## 📊 Использование
|
||||||
|
|
||||||
|
1. **Запустите все сервисы**: `docker-compose up -d`
|
||||||
|
2. **Откройте Streamlit**: http://localhost:8501
|
||||||
|
3. **Выберите тип данных** для анализа
|
||||||
|
4. **Просматривайте результаты** в интерактивном интерфейсе
|
||||||
|
|
||||||
|
## 🛠️ Разработка
|
||||||
|
|
||||||
|
### Режим разработки (рекомендуется)
|
||||||
|
```bash
|
||||||
|
# Запуск режима разработки
|
||||||
|
python start_dev.py
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
# Возврат к продакшн режиму
|
||||||
|
python start_prod.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локальная разработка FastAPI
|
||||||
|
```bash
|
||||||
|
cd python_parser
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локальная разработка Streamlit
|
||||||
|
```bash
|
||||||
|
cd streamlit_app
|
||||||
|
pip install -r requirements.txt
|
||||||
|
streamlit run streamlit_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Лицензия
|
||||||
|
|
||||||
|
Проект разработан для внутреннего использования.
|
||||||
69
docker-compose.dev.yml
Normal file
69
docker-compose.dev.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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
|
||||||
|
"
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Продакшн конфигурация
|
||||||
|
# Для разработки используйте: docker compose -f docker-compose.dev.yml up -d
|
||||||
services:
|
services:
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
@@ -10,11 +12,11 @@ services:
|
|||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- ./minio_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
fastapi:
|
fastapi:
|
||||||
build: .
|
build: ./python_parser
|
||||||
container_name: svodka_fastapi
|
container_name: svodka_fastapi
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -28,5 +30,20 @@ services:
|
|||||||
- minio
|
- minio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
streamlit:
|
||||||
minio_data:
|
build: ./streamlit_app
|
||||||
|
container_name: svodka_streamlit
|
||||||
|
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
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
- fastapi
|
||||||
|
restart: unless-stopped
|
||||||
BIN
monitoring_tar_correct.zip
Normal file
BIN
monitoring_tar_correct.zip
Normal file
Binary file not shown.
BIN
monitoring_tar_test.zip
Normal file
BIN
monitoring_tar_test.zip
Normal file
Binary file not shown.
@@ -1,28 +0,0 @@
|
|||||||
[server]
|
|
||||||
port = 8501
|
|
||||||
address = "localhost"
|
|
||||||
headless = false
|
|
||||||
enableCORS = false
|
|
||||||
enableXsrfProtection = false
|
|
||||||
|
|
||||||
[browser]
|
|
||||||
gatherUsageStats = false
|
|
||||||
serverAddress = "localhost"
|
|
||||||
serverPort = 8501
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
primaryColor = "#FF4B4B"
|
|
||||||
backgroundColor = "#FFFFFF"
|
|
||||||
secondaryBackgroundColor = "#F0F2F6"
|
|
||||||
textColor = "#262730"
|
|
||||||
font = "sans serif"
|
|
||||||
|
|
||||||
[client]
|
|
||||||
showErrorDetails = true
|
|
||||||
caching = true
|
|
||||||
displayEnabled = true
|
|
||||||
|
|
||||||
[runner]
|
|
||||||
magicEnabled = true
|
|
||||||
installTracer = false
|
|
||||||
fixMatplotlib = true
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
web: python /app/run_stand.py
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 🚀 Быстрый старт NIN Excel Parsers API
|
|
||||||
|
|
||||||
## 🐳 Запуск через Docker (рекомендуется)
|
|
||||||
|
|
||||||
### Вариант 1: MinIO + FastAPI в Docker
|
|
||||||
```bash
|
|
||||||
# Запуск всех сервисов
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
curl http://localhost:8000
|
|
||||||
curl http://localhost:9001
|
|
||||||
```
|
|
||||||
|
|
||||||
### Вариант 2: Только MinIO в Docker
|
|
||||||
```bash
|
|
||||||
# Запуск только MinIO
|
|
||||||
docker-compose up -d minio
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
curl http://localhost:9001
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🖥️ Запуск FastAPI локально
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Если MinIO в Docker
|
|
||||||
python run_dev.py
|
|
||||||
|
|
||||||
# Проверка
|
|
||||||
curl http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Запуск Streamlit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# В отдельном терминале
|
|
||||||
python run_streamlit.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🌐 Доступные URL
|
|
||||||
|
|
||||||
- **FastAPI API**: http://localhost:8000
|
|
||||||
- **API документация**: http://localhost:8000/docs
|
|
||||||
- **MinIO консоль**: http://localhost:9001
|
|
||||||
- **Streamlit интерфейс**: http://localhost:8501
|
|
||||||
|
|
||||||
## 🛑 Остановка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Остановка Docker
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Остановка Streamlit
|
|
||||||
# Ctrl+C в терминале
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Диагностика
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка состояния
|
|
||||||
python check_services.py
|
|
||||||
|
|
||||||
# Просмотр логов Docker
|
|
||||||
docker-compose logs
|
|
||||||
```
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# NIN Excel Parsers API
|
|
||||||
|
|
||||||
API для парсинга Excel отчетов нефтеперерабатывающих заводов (НПЗ) с использованием FastAPI и MinIO для хранения данных.
|
|
||||||
|
|
||||||
## 🚀 Быстрый запуск
|
|
||||||
|
|
||||||
### **Вариант 1: Только MinIO в Docker + FastAPI локально**
|
|
||||||
```bash
|
|
||||||
# Запуск MinIO в Docker
|
|
||||||
docker-compose up -d minio
|
|
||||||
|
|
||||||
# Запуск FastAPI локально
|
|
||||||
python run_dev.py
|
|
||||||
|
|
||||||
# В отдельном терминале запуск Streamlit
|
|
||||||
python run_streamlit.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Вариант 2: MinIO + FastAPI в Docker + Streamlit локально**
|
|
||||||
```bash
|
|
||||||
# Запуск MinIO и FastAPI в Docker
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# В отдельном терминале запуск Streamlit
|
|
||||||
python run_streamlit.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Вариант 3: Только MinIO в Docker**
|
|
||||||
```bash
|
|
||||||
# Запуск только MinIO
|
|
||||||
docker-compose up -d minio
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Описание сервисов
|
|
||||||
|
|
||||||
- **MinIO** (порт 9000-9001): S3-совместимое хранилище для данных
|
|
||||||
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
|
|
||||||
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
|
|
||||||
|
|
||||||
## 🔧 Диагностика
|
|
||||||
|
|
||||||
Для проверки состояния всех сервисов:
|
|
||||||
```bash
|
|
||||||
python check_services.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛑 Остановка
|
|
||||||
|
|
||||||
### Остановка Docker сервисов:
|
|
||||||
```bash
|
|
||||||
# Все сервисы
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Только MinIO
|
|
||||||
docker-compose stop minio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Остановка Streamlit:
|
|
||||||
```bash
|
|
||||||
# Нажмите Ctrl+C в терминале с Streamlit
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
python_parser/
|
|
||||||
├── app/ # FastAPI приложение
|
|
||||||
│ ├── main.py # Основной файл приложения
|
|
||||||
│ └── schemas/ # Pydantic схемы
|
|
||||||
├── core/ # Бизнес-логика
|
|
||||||
│ ├── models.py # Модели данных
|
|
||||||
│ ├── ports.py # Интерфейсы (порты)
|
|
||||||
│ └── services.py # Сервисы
|
|
||||||
├── adapters/ # Адаптеры для внешних систем
|
|
||||||
│ ├── storage.py # MinIO адаптер
|
|
||||||
│ └── parsers/ # Парсеры Excel файлов
|
|
||||||
├── data/ # Тестовые данные
|
|
||||||
├── docker-compose.yml # Docker Compose конфигурация
|
|
||||||
├── Dockerfile # Docker образ для FastAPI
|
|
||||||
├── run_dev.py # Запуск FastAPI локально
|
|
||||||
├── run_streamlit.py # Запуск Streamlit
|
|
||||||
└── check_services.py # Диагностика сервисов
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Доступные эндпоинты
|
|
||||||
|
|
||||||
- **GET /** - Информация об API
|
|
||||||
- **GET /docs** - Swagger документация
|
|
||||||
- **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
|
|
||||||
- **POST /svodka_ca/upload-zip** - Загрузка сводок ЦА
|
|
||||||
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
|
|
||||||
- **GET /svodka_pm/data** - Получение данных сводок ПМ
|
|
||||||
- **GET /svodka_ca/data** - Получение данных сводок ЦА
|
|
||||||
- **GET /monitoring_fuel/data** - Получение данных мониторинга топлива
|
|
||||||
|
|
||||||
## 📊 Поддерживаемые типы отчетов
|
|
||||||
|
|
||||||
1. **svodka_pm** - Сводки по переработке нефти (ПМ)
|
|
||||||
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
|
|
||||||
3. **monitoring_fuel** - Мониторинг топлива
|
|
||||||
|
|
||||||
## 🐳 Docker команды
|
|
||||||
|
|
||||||
### Сборка и запуск:
|
|
||||||
```bash
|
|
||||||
# Все сервисы
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# Только MinIO
|
|
||||||
docker-compose up -d minio
|
|
||||||
|
|
||||||
# Только FastAPI (требует MinIO)
|
|
||||||
docker-compose up -d fastapi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Просмотр логов:
|
|
||||||
```bash
|
|
||||||
# Все сервисы
|
|
||||||
docker-compose logs
|
|
||||||
|
|
||||||
# Конкретный сервис
|
|
||||||
docker-compose logs fastapi
|
|
||||||
docker-compose logs minio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Остановка:
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Устранение неполадок
|
|
||||||
|
|
||||||
### Проблема: "Streamlit не может подключиться к FastAPI"
|
|
||||||
|
|
||||||
**Симптомы:**
|
|
||||||
- Streamlit открывается, но показывает "API недоступен по адресу http://localhost:8000"
|
|
||||||
- FastAPI не отвечает на порту 8000
|
|
||||||
|
|
||||||
**Решения:**
|
|
||||||
|
|
||||||
1. **Проверьте порты:**
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
netstat -an | findstr :8000
|
|
||||||
|
|
||||||
# Linux/Mac
|
|
||||||
netstat -an | grep :8000
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Перезапустите FastAPI:**
|
|
||||||
```bash
|
|
||||||
# Остановите текущий процесс (Ctrl+C)
|
|
||||||
python run_dev.py
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Проверьте логи Docker:**
|
|
||||||
```bash
|
|
||||||
docker-compose logs fastapi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проблема: "MinIO недоступен"
|
|
||||||
|
|
||||||
**Решения:**
|
|
||||||
1. Запустите Docker Desktop
|
|
||||||
2. Проверьте статус контейнера: `docker ps`
|
|
||||||
3. Перезапустите MinIO: `docker-compose restart minio`
|
|
||||||
|
|
||||||
### Проблема: "Порт уже занят"
|
|
||||||
|
|
||||||
**Решения:**
|
|
||||||
1. Найдите процесс: `netstat -ano | findstr :8000`
|
|
||||||
2. Остановите процесс: `taskkill /PID <номер_процесса>`
|
|
||||||
3. Или используйте другой порт в конфигурации
|
|
||||||
|
|
||||||
## 🚀 Разработка
|
|
||||||
|
|
||||||
### Добавление нового парсера:
|
|
||||||
|
|
||||||
1. Создайте файл в `adapters/parsers/`
|
|
||||||
2. Реализуйте интерфейс `ParserPort`
|
|
||||||
3. Добавьте в `core/services.py`
|
|
||||||
4. Создайте схемы в `app/schemas/`
|
|
||||||
5. Добавьте эндпоинты в `app/main.py`
|
|
||||||
|
|
||||||
### Тестирование:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск тестов
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Запуск с покрытием
|
|
||||||
pytest --cov=.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Лицензия
|
|
||||||
|
|
||||||
Проект разработан для внутреннего использования НИН.
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# 🚀 Streamlit Demo для NIN Excel Parsers API
|
|
||||||
|
|
||||||
## Описание
|
|
||||||
|
|
||||||
Streamlit приложение для демонстрации работы всех API эндпоинтов NIN Excel Parsers. Предоставляет удобный веб-интерфейс для тестирования функциональности парсеров.
|
|
||||||
|
|
||||||
## Возможности
|
|
||||||
|
|
||||||
- 📤 **Загрузка файлов**: Загрузка ZIP архивов и Excel файлов
|
|
||||||
- 📊 **Сводки ПМ**: Работа с плановыми и фактическими данными
|
|
||||||
- 🏭 **Сводки СА**: Парсинг сводок центрального аппарата
|
|
||||||
- ⛽ **Мониторинг топлива**: Анализ данных по топливу
|
|
||||||
- 📱 **Адаптивный интерфейс**: Удобное использование на всех устройствах
|
|
||||||
|
|
||||||
## Установка и запуск
|
|
||||||
|
|
||||||
### 1. Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Запуск FastAPI сервера
|
|
||||||
|
|
||||||
В одном терминале:
|
|
||||||
```bash
|
|
||||||
python run_dev.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Запуск Streamlit приложения
|
|
||||||
|
|
||||||
В другом терминале:
|
|
||||||
```bash
|
|
||||||
python run_streamlit.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Или напрямую:
|
|
||||||
```bash
|
|
||||||
streamlit run streamlit_app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Открытие в браузере
|
|
||||||
|
|
||||||
Приложение автоматически откроется по адресу: http://localhost:8501
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# URL API сервера
|
|
||||||
export API_BASE_URL="http://localhost:8000"
|
|
||||||
|
|
||||||
# Порт Streamlit
|
|
||||||
export STREAMLIT_PORT="8501"
|
|
||||||
|
|
||||||
# Хост Streamlit
|
|
||||||
export STREAMLIT_HOST="localhost"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Настройки Streamlit
|
|
||||||
|
|
||||||
Файл `.streamlit/config.toml` содержит настройки:
|
|
||||||
- Порт: 8501
|
|
||||||
- Хост: localhost
|
|
||||||
- Тема: Кастомная цветовая схема
|
|
||||||
- Безопасность: Отключены CORS и XSRF для локальной разработки
|
|
||||||
|
|
||||||
## Структура приложения
|
|
||||||
|
|
||||||
### Вкладки
|
|
||||||
|
|
||||||
1. **📤 Загрузка файлов**
|
|
||||||
- Загрузка сводок ПМ (ZIP)
|
|
||||||
- Загрузка мониторинга топлива (ZIP)
|
|
||||||
- Загрузка сводки СА (Excel)
|
|
||||||
|
|
||||||
2. **📊 Сводки ПМ**
|
|
||||||
- Данные по одному ОГ
|
|
||||||
- Данные по всем ОГ
|
|
||||||
- Выбор кодов строк и столбцов
|
|
||||||
|
|
||||||
3. **🏭 Сводки СА**
|
|
||||||
- Выбор режимов (план/факт/норматив)
|
|
||||||
- Выбор таблиц для анализа
|
|
||||||
|
|
||||||
4. **⛽ Мониторинг топлива**
|
|
||||||
- Агрегация по колонкам
|
|
||||||
- Данные за конкретный месяц
|
|
||||||
|
|
||||||
### Боковая панель
|
|
||||||
|
|
||||||
- Информация о сервере (PID, CPU, память)
|
|
||||||
- Список доступных парсеров
|
|
||||||
- Статус подключения к API
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### 1. Загрузка файлов
|
|
||||||
|
|
||||||
1. Выберите соответствующий тип файла
|
|
||||||
2. Нажмите "Загрузить"
|
|
||||||
3. Дождитесь подтверждения загрузки
|
|
||||||
|
|
||||||
### 2. Получение данных
|
|
||||||
|
|
||||||
1. Выберите нужные параметры (ОГ, коды, столбцы)
|
|
||||||
2. Нажмите "Получить данные"
|
|
||||||
3. Результат отобразится в JSON формате
|
|
||||||
|
|
||||||
### 3. Мониторинг
|
|
||||||
|
|
||||||
- Проверяйте статус API в верхней части
|
|
||||||
- Следите за логами операций
|
|
||||||
- Используйте индикаторы загрузки
|
|
||||||
|
|
||||||
## Устранение неполадок
|
|
||||||
|
|
||||||
### API недоступен
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверьте, запущен ли FastAPI сервер
|
|
||||||
curl http://localhost:8000/
|
|
||||||
|
|
||||||
# Проверьте порт
|
|
||||||
netstat -an | grep 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Streamlit не запускается
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверьте версию Python
|
|
||||||
python --version
|
|
||||||
|
|
||||||
# Переустановите Streamlit
|
|
||||||
pip uninstall streamlit
|
|
||||||
pip install streamlit
|
|
||||||
|
|
||||||
# Проверьте порт 8501
|
|
||||||
netstat -an | grep 8501
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ошибки загрузки файлов
|
|
||||||
|
|
||||||
- Убедитесь, что файл соответствует формату
|
|
||||||
- Проверьте размер файла (не более 100MB)
|
|
||||||
- Убедитесь, что MinIO запущен
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
### Добавление новых функций
|
|
||||||
|
|
||||||
1. Создайте новую вкладку в `streamlit_app.py`
|
|
||||||
2. Добавьте соответствующие API вызовы
|
|
||||||
3. Обновите боковую панель при необходимости
|
|
||||||
|
|
||||||
### Кастомизация темы
|
|
||||||
|
|
||||||
Отредактируйте `.streamlit/config.toml`:
|
|
||||||
```toml
|
|
||||||
[theme]
|
|
||||||
primaryColor = "#FF4B4B"
|
|
||||||
backgroundColor = "#FFFFFF"
|
|
||||||
# ... другие цвета
|
|
||||||
```
|
|
||||||
|
|
||||||
### Добавление новых парсеров
|
|
||||||
|
|
||||||
1. Создайте парсер в `adapters/parsers/`
|
|
||||||
2. Добавьте в `main.py`
|
|
||||||
3. Обновите Streamlit интерфейс
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
⚠️ **Внимание**: Приложение настроено для локальной разработки
|
|
||||||
- CORS отключен
|
|
||||||
- XSRF защита отключена
|
|
||||||
- Не используйте в продакшене без дополнительной настройки
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
При возникновении проблем:
|
|
||||||
1. Проверьте логи в терминале
|
|
||||||
2. Убедитесь, что все сервисы запущены
|
|
||||||
3. Проверьте конфигурацию
|
|
||||||
4. Обратитесь к документации API: http://localhost:8000/docs
|
|
||||||
135
python_parser/SCHEMA_INTEGRATION.md
Normal file
135
python_parser/SCHEMA_INTEGRATION.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Интеграция схем 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 схемами и парсерами. Теперь изменения в схемах автоматически отражаются в парсерах, что упрощает поддержку и развитие системы.
|
||||||
Binary file not shown.
BIN
python_parser/adapters/monitoring_tar_test.zip
Normal file
BIN
python_parser/adapters/monitoring_tar_test.zip
Normal file
Binary file not shown.
88
python_parser/adapters/parsers/README_svodka_pm.md
Normal file
88
python_parser/adapters/parsers/README_svodka_pm.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Парсер Сводки ПМ
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Парсер для обработки сводок ПМ (план и факт) с поддержкой множественных геттеров. Наследуется от `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
|
||||||
|
- Валидация параметров выполняется автоматически
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
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'
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +1,251 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import re
|
import re
|
||||||
from typing import Dict
|
import zipfile
|
||||||
|
import logging
|
||||||
|
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, MonitoringFuelSeriesRequest
|
||||||
from adapters.pconfig import data_to_json, get_object_by_name
|
from adapters.pconfig import data_to_json, get_object_by_name
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringFuelParser(ParserPort):
|
class MonitoringFuelParser(ParserPort):
|
||||||
"""Парсер для мониторинга топлива"""
|
"""Парсер для мониторинга топлива"""
|
||||||
|
|
||||||
name = "Мониторинг топлива"
|
name = "Мониторинг топлива"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
# Используем схемы Pydantic как единый источник правды
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="total_by_columns",
|
||||||
|
method=self._get_total_by_columns,
|
||||||
|
schema_class=MonitoringFuelTotalRequest,
|
||||||
|
description="Агрегация данных по колонкам"
|
||||||
|
)
|
||||||
|
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="month_by_code",
|
||||||
|
method=self._get_month_by_code,
|
||||||
|
schema_class=MonitoringFuelMonthRequest,
|
||||||
|
description="Получение данных за конкретный месяц"
|
||||||
|
)
|
||||||
|
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="series_by_id_and_columns",
|
||||||
|
method=self._get_series_by_id_and_columns,
|
||||||
|
schema_class=MonitoringFuelSeriesRequest,
|
||||||
|
description="Получение временного ряда по ID и колонкам"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_total_by_columns(self, params: dict):
|
||||||
|
"""Агрегация данных по колонкам"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, MonitoringFuelTotalRequest)
|
||||||
|
|
||||||
|
columns = validated_params["columns"]
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
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):
|
||||||
|
"""Получение данных за конкретный месяц"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, MonitoringFuelMonthRequest)
|
||||||
|
|
||||||
|
month = validated_params["month"]
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
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 _get_series_by_id_and_columns(self, params: dict):
|
||||||
|
"""Получение временных рядов по колонкам для всех ID"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, MonitoringFuelSeriesRequest)
|
||||||
|
|
||||||
|
columns = validated_params["columns"]
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
# Проверяем, что все колонки существуют хотя бы в одном месяце
|
||||||
|
valid_columns = set()
|
||||||
|
for month_df in data_source.values():
|
||||||
|
valid_columns.update(month_df.columns)
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
if col not in valid_columns:
|
||||||
|
raise ValueError(f"Колонка '{col}' не найдена ни в одном месяце")
|
||||||
|
|
||||||
|
# Подготавливаем результат: словарь id → {col: [значения по месяцам]}
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Обрабатываем месяцы от 01 до 12
|
||||||
|
for month_key in [f"{i:02d}" for i in range(1, 13)]:
|
||||||
|
if month_key not in data_source:
|
||||||
|
logger.warning(f"Месяц '{month_key}' не найден в df_monitorings, пропускаем.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
df = data_source[month_key]
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
if col not in df.columns:
|
||||||
|
continue # Пропускаем, если в этом месяце нет колонки
|
||||||
|
|
||||||
|
for idx, value in df[col].items():
|
||||||
|
if pd.isna(value):
|
||||||
|
continue # Пропускаем NaN
|
||||||
|
|
||||||
|
if idx not in result:
|
||||||
|
result[idx] = {c: [] for c in columns}
|
||||||
|
|
||||||
|
# Добавляем значение в массив для данного ID и колонки
|
||||||
|
if not pd.isna(value) and value != float('inf') and value != float('-inf'):
|
||||||
|
result[idx][col].append(float(value) if isinstance(value, (int, float)) else value)
|
||||||
|
|
||||||
|
# Преобразуем ключи id в строки (для JSON-совместимости)
|
||||||
|
result_str_keys = {str(k): v for k, v in result.items()}
|
||||||
|
return result_str_keys
|
||||||
|
|
||||||
|
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 уже является DataFrame, поэтому используем его напрямую
|
||||||
|
if isinstance(data, pd.DataFrame):
|
||||||
|
data_dict[month] = data
|
||||||
|
else:
|
||||||
|
# Если data не DataFrame, пропускаем
|
||||||
|
logger.warning(f"Данные за месяц {month} не являются DataFrame, пропускаем")
|
||||||
|
|
||||||
|
return data_dict
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
# Парсим данные и сохраняем словарь для использования в геттерах
|
||||||
|
self.data_dict = 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
|
||||||
|
|
||||||
|
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||||||
|
"""Парсинг ZIP архива с файлами мониторинга топлива"""
|
||||||
|
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
|
||||||
|
file_list = zip_ref.namelist()
|
||||||
|
for month in range(1, 13):
|
||||||
|
|
||||||
|
mm = f"{month:02d}"
|
||||||
|
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
|
||||||
|
candidates = [f for f in file_list if file_temp in f]
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
file = candidates[0]
|
||||||
|
|
||||||
|
logger.info(f'Загрузка {file}')
|
||||||
|
with zip_ref.open(file) as excel_file:
|
||||||
|
try:
|
||||||
|
df = self.parse_single(excel_file, 'Мониторинг потребления')
|
||||||
|
df_monitorings[mm] = df
|
||||||
|
|
||||||
|
logger.info(f"✅ Данные за месяц {mm} загружены")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при загрузке файла {file_temp}: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Файл не найден: {file_temp}")
|
||||||
|
|
||||||
|
return df_monitorings
|
||||||
|
|
||||||
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
|
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
|
||||||
"""Определение индекса заголовка в Excel по ключевому слову"""
|
"""Определение индекса заголовка в Excel по ключевому слову"""
|
||||||
# Читаем первые max_rows строк без заголовков
|
# Читаем первые max_rows строк без заголовков
|
||||||
@@ -18,13 +253,14 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
file_path,
|
file_path,
|
||||||
sheet_name=sheet,
|
sheet_name=sheet,
|
||||||
header=None,
|
header=None,
|
||||||
nrows=max_rows
|
nrows=max_rows,
|
||||||
|
engine='openpyxl'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||||
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():
|
||||||
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
logger.debug(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} строках.")
|
||||||
@@ -40,7 +276,8 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
sheet_name=sheet,
|
sheet_name=sheet,
|
||||||
header=header_num,
|
header=header_num,
|
||||||
usecols=None,
|
usecols=None,
|
||||||
index_col=None
|
index_col=None,
|
||||||
|
engine='openpyxl'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Удаление полностью пустых столбцов ===
|
# === Удаление полностью пустых столбцов ===
|
||||||
@@ -63,47 +300,20 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
|
|
||||||
# Проверяем, что колонка 'name' существует
|
# Проверяем, что колонка 'name' существует
|
||||||
if 'name' in df_full.columns:
|
if 'name' in df_full.columns:
|
||||||
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
# Применяем функцию get_object_by_name к каждой строке в колонке 'name'
|
||||||
df_full['id'] = df_full['name'].apply(get_object_by_name)
|
df_full['id'] = df_full['name'].apply(get_object_by_name)
|
||||||
|
# Удаляем строки, где не удалось найти ID
|
||||||
|
df_full = df_full.dropna(subset=['id'])
|
||||||
|
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)
|
||||||
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
logger.debug(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
||||||
return df_full
|
return df_full
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
|
||||||
import zipfile
|
|
||||||
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
|
|
||||||
|
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
|
||||||
|
|
||||||
file_list = zip_ref.namelist()
|
|
||||||
for month in range(1, 13):
|
|
||||||
|
|
||||||
mm = f"{month:02d}"
|
|
||||||
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
|
|
||||||
candidates = [f for f in file_list if file_temp in f]
|
|
||||||
|
|
||||||
if len(candidates) == 1:
|
|
||||||
file = candidates[0]
|
|
||||||
|
|
||||||
print(f'Загрузка {file}')
|
|
||||||
with zip_ref.open(file) as excel_file:
|
|
||||||
try:
|
|
||||||
df = self.parse_single(excel_file, 'Мониторинг потребления')
|
|
||||||
df_monitorings[mm] = df
|
|
||||||
|
|
||||||
print(f"✅ Данные за месяц {mm} загружены")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при загрузке файла {file_temp}: {e}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Файл не найден: {file_temp}")
|
|
||||||
|
|
||||||
return df_monitorings
|
|
||||||
|
|
||||||
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns):
|
|
||||||
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
||||||
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
||||||
means = {} # Для хранения средних
|
means = {} # Для хранения средних
|
||||||
@@ -113,7 +323,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:
|
||||||
print(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
logger.warning(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Берём колонку, оставляем id как индекс
|
# Берём колонку, оставляем id как индекс
|
||||||
@@ -165,7 +375,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:
|
||||||
print(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
logger.warning(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Берём колонку и сохраняем как Series с именем месяца
|
# Берём колонку и сохраняем как Series с именем месяца
|
||||||
@@ -185,22 +395,3 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
total.name = 'mean'
|
total.name = 'mean'
|
||||||
|
|
||||||
return total, df_combined
|
return total, df_combined
|
||||||
|
|
||||||
def get_value(self, df, params):
|
|
||||||
mode = params.get("mode", "total")
|
|
||||||
columns = params.get("columns", None)
|
|
||||||
month = params.get("month", None)
|
|
||||||
data = None
|
|
||||||
if mode == "total":
|
|
||||||
if not columns:
|
|
||||||
raise ValueError("Отсутствуют идентификаторы столбцов")
|
|
||||||
df_means, _ = self.aggregate_by_columns(df, columns)
|
|
||||||
data = df_means.to_dict(orient='index')
|
|
||||||
elif mode == "month":
|
|
||||||
if not month:
|
|
||||||
raise ValueError("Отсутствуют идентификатор месяца")
|
|
||||||
df_month = self.get_month(df, month)
|
|
||||||
data = df_month.to_dict(orient='index')
|
|
||||||
|
|
||||||
json_result = data_to_json(data)
|
|
||||||
return json_result
|
|
||||||
|
|||||||
306
python_parser/adapters/parsers/monitoring_tar.py
Normal file
306
python_parser/adapters/parsers/monitoring_tar.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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 "{}"
|
||||||
285
python_parser/adapters/parsers/oper_spravka_tech_pos.py
Normal file
285
python_parser/adapters/parsers/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
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 []
|
||||||
345
python_parser/adapters/parsers/statuses_repair_ca.py
Normal file
345
python_parser/adapters/parsers/statuses_repair_ca.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
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
|
||||||
@@ -1,18 +1,229 @@
|
|||||||
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):
|
||||||
"""Парсер для сводки СА"""
|
"""Парсер для сводок СА"""
|
||||||
|
|
||||||
name = "Сводка СА"
|
name = "Сводки СА"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
# Используем схемы Pydantic как единый источник правды
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="get_ca_data",
|
||||||
|
method=self._get_data_wrapper,
|
||||||
|
schema_class=SvodkaCARequest,
|
||||||
|
description="Получение данных по режимам и таблицам"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_data_wrapper(self, params: dict):
|
||||||
|
"""Получение данных по режимам и таблицам"""
|
||||||
|
logger.debug(f"🔍 _get_data_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, SvodkaCARequest)
|
||||||
|
|
||||||
|
modes = validated_params["modes"]
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
data_dict = {}
|
||||||
|
|
||||||
|
# Группируем данные по режимам и таблицам
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
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:
|
||||||
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
logger.debug(f"🔍 SvodkaCAParser.parse вызван с файлом: {file_path}")
|
||||||
|
|
||||||
|
# Парсим данные и сохраняем словарь для использования в геттерах
|
||||||
|
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
|
||||||
|
|
||||||
|
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
|
||||||
|
"""Парсинг сводки СА - работает с тремя листами: План, Факт, Норматив"""
|
||||||
|
logger.debug(f"🔍 Начинаем парсинг сводки СА из файла: {file_path}")
|
||||||
|
|
||||||
|
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
|
||||||
|
|
||||||
|
# Выгружаем План
|
||||||
|
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'}")
|
||||||
|
|
||||||
|
# Выгружаем Факт
|
||||||
|
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'}")
|
||||||
|
|
||||||
|
# Выгружаем Норматив
|
||||||
|
inclusion_list_normativ = {
|
||||||
|
"Топливо итого, тонн",
|
||||||
|
"Топливо итого, %",
|
||||||
|
"Топливо на технологию, тонн",
|
||||||
|
"Топливо на технологию, %",
|
||||||
|
"Топливо на энергетику, тонн",
|
||||||
|
"Топливо на энергетику, %",
|
||||||
|
"Потери итого, тонн",
|
||||||
|
"Потери итого, %",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
||||||
|
"в т.ч. Неидентифицированные потери, тонн**",
|
||||||
|
"в т.ч. Неидентифицированные потери, %**"
|
||||||
|
}
|
||||||
|
|
||||||
|
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'}")
|
||||||
|
|
||||||
|
# Преобразуем DataFrame в словарь по режимам и таблицам
|
||||||
|
data_dict = {}
|
||||||
|
|
||||||
|
# Обрабатываем План
|
||||||
|
if df_ca_plan is not None and not df_ca_plan.empty:
|
||||||
|
data_dict['plan'] = {}
|
||||||
|
for table_name, group_df in df_ca_plan.groupby('table'):
|
||||||
|
table_data = group_df.drop('table', axis=1)
|
||||||
|
data_dict['plan'][table_name] = table_data.to_dict('records')
|
||||||
|
|
||||||
|
# Обрабатываем Факт
|
||||||
|
if df_ca_fact is not None and not df_ca_fact.empty:
|
||||||
|
data_dict['fact'] = {}
|
||||||
|
for table_name, group_df in df_ca_fact.groupby('table'):
|
||||||
|
table_data = group_df.drop('table', axis=1)
|
||||||
|
data_dict['fact'][table_name] = table_data.to_dict('records')
|
||||||
|
|
||||||
|
# Обрабатываем Норматив
|
||||||
|
if df_ca_normativ is not None and not df_ca_normativ.empty:
|
||||||
|
data_dict['normativ'] = {}
|
||||||
|
for table_name, group_df in df_ca_normativ.groupby('table'):
|
||||||
|
table_data = group_df.drop('table', axis=1)
|
||||||
|
data_dict['normativ'][table_name] = table_data.to_dict('records')
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Итоговый data_dict содержит режимы: {list(data_dict.keys())}")
|
||||||
|
for mode, tables in data_dict.items():
|
||||||
|
logger.debug(f"🔍 Режим '{mode}' содержит таблицы: {list(tables.keys())}")
|
||||||
|
|
||||||
|
return data_dict
|
||||||
|
|
||||||
def extract_all_tables(self, file_path, sheet_name=0):
|
def extract_all_tables(self, file_path, sheet_name=0):
|
||||||
"""Извлекает все таблицы из Excel файла"""
|
"""Извлечение всех таблиц из Excel файла"""
|
||||||
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
|
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None, engine='openpyxl')
|
||||||
df_filled = df.fillna('')
|
df_filled = df.fillna('')
|
||||||
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
||||||
|
|
||||||
@@ -83,8 +294,8 @@ class SvodkaCAParser(ParserPort):
|
|||||||
return None
|
return None
|
||||||
return name.strip()
|
return name.strip()
|
||||||
|
|
||||||
def parse_sheet(self, file_path, sheet_name, inclusion_list):
|
def parse_sheet(self, file_path: str, sheet_name: str, inclusion_list: set) -> pd.DataFrame:
|
||||||
"""Собственно функция парсинга отчета СА"""
|
"""Парсинг листа Excel"""
|
||||||
# === Извлечение и фильтрация ===
|
# === Извлечение и фильтрация ===
|
||||||
tables = self.extract_all_tables(file_path, sheet_name)
|
tables = self.extract_all_tables(file_path, sheet_name)
|
||||||
|
|
||||||
@@ -161,7 +372,7 @@ class SvodkaCAParser(ParserPort):
|
|||||||
|
|
||||||
# Проверяем, что колонка 'name' существует
|
# Проверяем, что колонка 'name' существует
|
||||||
if 'name' not in df_cleaned.columns:
|
if 'name' not in df_cleaned.columns:
|
||||||
print(
|
logger.debug(
|
||||||
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
|
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
|
||||||
continue # или обработать по-другому
|
continue # или обработать по-другому
|
||||||
else:
|
else:
|
||||||
@@ -190,77 +401,6 @@ class SvodkaCAParser(ParserPort):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
|
||||||
"""Парсинг файла сводки СА"""
|
|
||||||
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
|
|
||||||
# Выгружаем План в df_ca_plan
|
|
||||||
inclusion_list_plan = {
|
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) # ЭТО ДАТАФРЕЙМ ПЛАНА В СВОДКЕ ЦА
|
|
||||||
print(f"\n--- Объединённый и отсортированный План: {df_ca_plan.shape} ---")
|
|
||||||
|
|
||||||
# Выгружаем Факт
|
|
||||||
inclusion_list_fact = {
|
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн",
|
|
||||||
"в т.ч. Неидентифицированные потери, %"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) # ЭТО ДАТАФРЕЙМ ФАКТА В СВОДКЕ ЦА
|
|
||||||
print(f"\n--- Объединённый и отсортированный Факт: {df_ca_fact.shape} ---")
|
|
||||||
|
|
||||||
# Выгружаем План в df_ca_normativ
|
|
||||||
inclusion_list_normativ = {
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ЭТО ДАТАФРЕЙМ НОРМАТИВА В СВОДКЕ ЦА
|
|
||||||
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
|
|
||||||
|
|
||||||
print(f"\n--- Объединённый и отсортированный Норматив: {df_ca_normativ.shape} ---")
|
|
||||||
|
|
||||||
df_dict = {
|
|
||||||
"plan": df_ca_plan,
|
|
||||||
"fact": df_ca_fact,
|
|
||||||
"normativ": df_ca_normativ
|
|
||||||
}
|
|
||||||
return df_dict
|
|
||||||
|
|
||||||
def data_dict_to_json(self, data_dict):
|
def data_dict_to_json(self, data_dict):
|
||||||
''' Служебная функция для парсинга словаря в json. '''
|
''' Служебная функция для парсинга словаря в json. '''
|
||||||
def convert_types(obj):
|
def convert_types(obj):
|
||||||
@@ -308,17 +448,3 @@ class SvodkaCAParser(ParserPort):
|
|||||||
filtered_df = df[df['table'].isin(table_values)].copy()
|
filtered_df = df[df['table'].isin(table_values)].copy()
|
||||||
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
||||||
return result_dict
|
return result_dict
|
||||||
|
|
||||||
def get_value(self, df: pd.DataFrame, params: dict):
|
|
||||||
|
|
||||||
modes = params.get("modes")
|
|
||||||
tables = params.get("tables")
|
|
||||||
if not isinstance(modes, list):
|
|
||||||
raise ValueError("Поле 'modes' должно быть списком")
|
|
||||||
if not isinstance(tables, list):
|
|
||||||
raise ValueError("Поле 'tables' должно быть списком")
|
|
||||||
# Собираем данные
|
|
||||||
data_dict = {}
|
|
||||||
for mode in modes:
|
|
||||||
data_dict[mode] = self.get_data(df, mode, tables)
|
|
||||||
return self.data_dict_to_json(data_dict)
|
|
||||||
|
|||||||
326
python_parser/adapters/parsers/svodka_pm copy.py
Normal file
326
python_parser/adapters/parsers/svodka_pm copy.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
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, так как он теперь в базовом классе
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
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 core.ports import ParserPort
|
||||||
from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
|
from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SvodkaPMParser(ParserPort):
|
class SvodkaPMParser(ParserPort):
|
||||||
@@ -9,38 +20,140 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
name = "Сводки ПМ"
|
name = "Сводки ПМ"
|
||||||
|
|
||||||
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
|
def __init__(self):
|
||||||
"""Определения индекса заголовка в excel по ключевому слову"""
|
super().__init__()
|
||||||
# Читаем первые max_rows строк без заголовков
|
self._register_default_getters()
|
||||||
df_temp = pd.read_excel(
|
|
||||||
file,
|
def _register_default_getters(self):
|
||||||
sheet_name=sheet,
|
"""Регистрация геттеров для Сводки ПМ"""
|
||||||
header=None,
|
self.register_getter(
|
||||||
nrows=max_rows
|
name="single_og",
|
||||||
|
method=self._get_single_og,
|
||||||
|
required_params=["id", "codes", "columns"],
|
||||||
|
optional_params=["search"],
|
||||||
|
description="Получение данных по одному ОГ из сводки ПМ"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.register_getter(
|
||||||
|
name="total_ogs",
|
||||||
|
method=self._get_total_ogs,
|
||||||
|
required_params=["codes", "columns"],
|
||||||
|
optional_params=["search"],
|
||||||
|
description="Получение данных по всем ОГ из сводки ПМ"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
def parse(self, file_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||||||
for idx, row in df_temp.iterrows():
|
"""Парсинг ZIP архива со сводками ПМ и возврат словаря с DataFrame"""
|
||||||
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
# Проверяем расширение файла
|
||||||
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
if not file_path.lower().endswith('.zip'):
|
||||||
return idx # 0-based index — то, что нужно для header=
|
raise ValueError(f"Ожидается ZIP архив: {file_path}")
|
||||||
|
|
||||||
|
# Создаем временную директорию для разархивирования
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Разархивируем файл
|
||||||
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
|
||||||
|
# Возвращаем словарь с данными (как в оригинале)
|
||||||
|
result = {
|
||||||
|
'df_pm_facts': df_pm_facts,
|
||||||
|
'df_pm_plans': df_pm_plans
|
||||||
|
}
|
||||||
|
|
||||||
|
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])} план")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Удаляем временную директорию
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"🗑️ Временная директория удалена: {temp_dir}")
|
||||||
|
|
||||||
def parse_svodka_pm(self, file, sheet, header_num=None):
|
def _parse_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame:
|
||||||
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
|
"""Парсинг отчетов одного ОГ для БП, ПП и факта"""
|
||||||
# Автоопределение header_num, если не передан
|
try:
|
||||||
if header_num is None:
|
# Автоопределение header_num, если не передан
|
||||||
header_num = self.find_header_row(file, sheet, search_value="Итого")
|
if header_num is None:
|
||||||
|
header_num = find_header_row(file_path, sheet_name, search_value="Итого")
|
||||||
|
|
||||||
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
||||||
df_probe = pd.read_excel(
|
df_probe = pd.read_excel(
|
||||||
file,
|
file_path,
|
||||||
sheet_name=sheet,
|
sheet_name=sheet_name,
|
||||||
header=header_num,
|
header=header_num,
|
||||||
usecols=None,
|
usecols=None,
|
||||||
nrows=2,
|
nrows=2,
|
||||||
)
|
engine='openpyxl' # Явно указываем движок
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Ошибка при чтении файла {file_path}: {str(e)}")
|
||||||
|
|
||||||
if df_probe.shape[0] == 0:
|
if df_probe.shape[0] == 0:
|
||||||
raise ValueError("Файл пуст или не содержит данных.")
|
raise ValueError("Файл пуст или не содержит данных.")
|
||||||
@@ -53,15 +166,15 @@ 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,
|
file_path,
|
||||||
sheet_name=sheet,
|
sheet_name=sheet_name,
|
||||||
header=header_num,
|
header=header_num,
|
||||||
usecols=None,
|
usecols=None,
|
||||||
index_col=None
|
index_col=None,
|
||||||
|
engine='openpyxl' # Явно указываем движок
|
||||||
)
|
)
|
||||||
|
|
||||||
if indicator_col_name not in df_full.columns:
|
if indicator_col_name not in df_full.columns:
|
||||||
@@ -78,19 +191,18 @@ 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:
|
||||||
@@ -102,106 +214,149 @@ class SvodkaPMParser(ParserPort):
|
|||||||
# Проверяем, начинается ли на "Итого"
|
# Проверяем, начинается ли на "Итого"
|
||||||
if col_str.startswith('Итого'):
|
if col_str.startswith('Итого'):
|
||||||
current_name = 'Итого'
|
current_name = 'Итого'
|
||||||
last_good_name = current_name # обновляем last_good_name
|
last_good_name = current_name
|
||||||
new_columns.append(current_name)
|
new_columns.append(current_name)
|
||||||
elif is_empty_or_unnamed:
|
elif is_empty_or_unnamed:
|
||||||
# Используем последнее хорошее имя
|
# Используем последнее хорошее имя
|
||||||
new_columns.append(last_good_name)
|
new_columns.append(last_good_name)
|
||||||
else:
|
else:
|
||||||
# Имя, полученное из exel
|
# Имя, полученное из 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
|
||||||
|
|
||||||
print(f"Окончательное количество столбцов: {len(df_final.columns)}")
|
|
||||||
return df_final
|
return df_final
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
def _get_svodka_value(self, df_svodka: pd.DataFrame, og_id: str, code: int, search_value: Optional[str] = None):
|
||||||
import zipfile
|
"""Служебная функция для простой выборке по сводке"""
|
||||||
pm_dict = {
|
logger.debug(f"🔍 Ищем код '{code}' для ОГ '{og_id}' в DataFrame с {len(df_svodka)} строками")
|
||||||
"facts": {},
|
logger.debug(f"🔍 Первая строка данных: {df_svodka.iloc[0].tolist()}")
|
||||||
"plans": {}
|
logger.debug(f"🔍 Доступные индексы: {list(df_svodka.index)}")
|
||||||
}
|
logger.debug(f"🔍 Доступные столбцы: {list(df_svodka.columns)}")
|
||||||
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
|
||||||
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
|
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
|
||||||
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 code not in df_svodka.index:
|
||||||
if len(fact_candidates) == 1:
|
logger.warning(f"⚠️ Код '{code}' не найден в индексе")
|
||||||
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, id, code, search_value=None):
|
|
||||||
''' Служебная функция для простой выборке по сводке '''
|
|
||||||
row_index = id
|
|
||||||
|
|
||||||
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
|
return 0
|
||||||
|
|
||||||
|
# Получаем позицию строки с кодом
|
||||||
|
code_row_loc = df_svodka.index.get_loc(code)
|
||||||
|
logger.debug(f"🔍 Код '{code}' в позиции {code_row_loc}")
|
||||||
|
|
||||||
|
# Определяем позиции для поиска
|
||||||
|
if search_value is None:
|
||||||
|
# Ищем все позиции кроме "Итого" и None (первый столбец с заголовком)
|
||||||
|
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:
|
||||||
if row_index in df_svodka.index:
|
# Ищем позиции в первой строке, где есть нужное название
|
||||||
# Получаем позицию строки
|
target_positions = []
|
||||||
row_loc = df_svodka.index.get_loc(row_index)
|
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}'")
|
||||||
values = df_svodka.iloc[row_loc, col_positions]
|
logger.debug(f"🔍 Целевые позиции: {target_positions[:10]}...")
|
||||||
|
|
||||||
# Преобразуем в числовой формат
|
if not target_positions:
|
||||||
numeric_values = pd.to_numeric(values, errors='coerce')
|
logger.warning(f"⚠️ Позиции '{search_value}' не найдены")
|
||||||
|
return 0
|
||||||
|
|
||||||
# Агрегация данных (NaN игнорируются)
|
# Извлекаем значения из найденных позиций
|
||||||
if search_value is None:
|
values = []
|
||||||
return numeric_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:
|
else:
|
||||||
return numeric_values.iloc[0]
|
values.append(0)
|
||||||
else:
|
else:
|
||||||
return None
|
values.append(value)
|
||||||
|
|
||||||
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
|
|
||||||
''' Служебная функция получения данных по одному ОГ '''
|
|
||||||
|
# Преобразуем в числовой формат
|
||||||
|
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:
|
||||||
|
# Возвращаем массив всех значений (игнорируя 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 []
|
||||||
|
|
||||||
|
def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None):
|
||||||
|
"""Служебная функция получения данных по одному ОГ"""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
fact_df = pm_dict['facts'][id]
|
# Получаем данные из сохраненных словарей (через self.df)
|
||||||
plan_df = pm_dict['plans'][id]
|
if not hasattr(self, 'df') or self.df is None:
|
||||||
|
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:
|
||||||
@@ -209,67 +364,91 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
if col in ['ПП', 'БП']:
|
if col in ['ПП', 'БП']:
|
||||||
if plan_df is None:
|
if plan_df is None:
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
|
logger.warning(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}")
|
||||||
else:
|
else:
|
||||||
|
logger.debug(f"🔍 ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====")
|
||||||
for code in codes:
|
for code in codes:
|
||||||
val = self.get_svodka_value(plan_df, code, col, search_value)
|
logger.debug(f"🔍 --- Код {code} для {col} ---")
|
||||||
col_result[code] = val
|
val = self._get_svodka_value(plan_df, og_id, code, col)
|
||||||
|
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:
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
|
logger.warning(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}")
|
||||||
else:
|
else:
|
||||||
for code in codes:
|
for code in codes:
|
||||||
val = self.get_svodka_value(fact_df, code, col, search_value)
|
val = self._get_svodka_value(fact_df, og_id, code, col)
|
||||||
col_result[code] = val
|
col_result[str(code)] = val
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
logger.warning(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
||||||
col_result = {code: None for code in codes}
|
col_result = {str(code): None for code in codes}
|
||||||
|
|
||||||
result[col] = col_result
|
result[col] = col_result
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
|
def _get_single_og(self, params: Dict[str, Any]) -> str:
|
||||||
''' Служебная функция агрегации данные по всем ОГ '''
|
"""API функция для получения данных по одному ОГ"""
|
||||||
total_result = {}
|
# Если на входе строка — парсим как JSON
|
||||||
|
if isinstance(params, str):
|
||||||
for name, og_id in OG_IDS.items():
|
|
||||||
if og_id == 'BASH':
|
|
||||||
continue
|
|
||||||
|
|
||||||
# print(f"📊 Обработка: {name} ({og_id})")
|
|
||||||
try:
|
try:
|
||||||
data = self.get_svodka_og(
|
params = json.loads(params)
|
||||||
pm_dict,
|
except json.JSONDecodeError as e:
|
||||||
og_id,
|
raise ValueError(f"Некорректный JSON: {e}")
|
||||||
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
|
# Проверяем структуру
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
||||||
|
|
||||||
def get_value(self, df, params):
|
|
||||||
og_id = params.get("id")
|
og_id = params.get("id")
|
||||||
codes = params.get("codes")
|
codes = params.get("codes")
|
||||||
columns = params.get("columns")
|
columns = params.get("columns")
|
||||||
search = params.get("search")
|
search = params.get("search")
|
||||||
mode = params.get("mode", "total")
|
|
||||||
if not isinstance(codes, list):
|
if not isinstance(codes, list):
|
||||||
raise ValueError("Поле 'codes' должно быть списком")
|
raise ValueError("Поле 'codes' должно быть списком")
|
||||||
if not isinstance(columns, list):
|
if not isinstance(columns, list):
|
||||||
raise ValueError("Поле 'columns' должно быть списком")
|
raise ValueError("Поле 'columns' должно быть списком")
|
||||||
data = None
|
|
||||||
if mode == "single":
|
data = self._get_svodka_og(og_id, codes, columns, search)
|
||||||
if not og_id:
|
|
||||||
raise ValueError("Отсутствует идентификатор ОГ")
|
|
||||||
data = self.get_svodka_og(df, og_id, codes, columns, search)
|
|
||||||
elif mode == "total":
|
|
||||||
data = self.get_svodka_total(df, codes, columns, search)
|
|
||||||
json_result = data_to_json(data)
|
json_result = data_to_json(data)
|
||||||
return json_result
|
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 = {}
|
||||||
|
|
||||||
|
for og_id in SINGLE_OGS:
|
||||||
|
if og_id == 'BASH':
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = self._get_svodka_og(og_id, codes, columns, search)
|
||||||
|
total_result[og_id] = data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при обработке {og_id}: {e}")
|
||||||
|
total_result[og_id] = None
|
||||||
|
|
||||||
|
json_result = data_to_json(total_result)
|
||||||
|
return json_result
|
||||||
381
python_parser/adapters/parsers/svodka_repair_ca.py
Normal file
381
python_parser/adapters/parsers/svodka_repair_ca.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
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
|
||||||
@@ -3,6 +3,11 @@ 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",
|
||||||
@@ -22,8 +27,37 @@ 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",
|
||||||
@@ -40,7 +74,18 @@ SNPZ_IDS = {
|
|||||||
|
|
||||||
|
|
||||||
def replace_id_in_path(file_path, new_id):
|
def replace_id_in_path(file_path, new_id):
|
||||||
return file_path.replace('ID', str(new_id))
|
# Заменяем '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):
|
||||||
@@ -109,6 +154,25 @@ 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.
|
||||||
@@ -153,11 +217,18 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
|||||||
|
|
||||||
# --- рекурсия по dict и list ---
|
# --- рекурсия по dict и list ---
|
||||||
elif isinstance(obj, dict):
|
elif isinstance(obj, dict):
|
||||||
return {
|
# Обрабатываем только значения, ключи оставляем как строки
|
||||||
key: convert_obj(value)
|
converted = {}
|
||||||
for key, value in obj.items()
|
for k, v in obj.items():
|
||||||
if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON)
|
if is_nan_like(k):
|
||||||
}
|
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]
|
||||||
@@ -175,7 +246,6 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cleaned_data = convert_obj(data)
|
cleaned_data = convert_obj(data)
|
||||||
cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
return 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}")
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
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 хранилища"""
|
||||||
@@ -37,8 +41,8 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
# Проверяем bucket только при первом использовании
|
# Проверяем bucket только при первом использовании
|
||||||
self._ensure_bucket_exists()
|
self._ensure_bucket_exists()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Не удалось подключиться к MinIO: {e}")
|
logger.warning(f"⚠️ Не удалось подключиться к MinIO: {e}")
|
||||||
print("MinIO будет недоступен, но приложение продолжит работать")
|
logger.warning("MinIO будет недоступен, но приложение продолжит работать")
|
||||||
return None
|
return None
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@@ -50,16 +54,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)
|
||||||
print(f"✅ Bucket '{self._bucket_name}' создан")
|
logger.info(f"✅ Bucket '{self._bucket_name}' создан")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при работе с bucket: {e}")
|
logger.error(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:
|
||||||
print("⚠️ MinIO недоступен, данные не сохранены")
|
logger.warning("⚠️ MinIO недоступен, данные не сохранены")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -78,16 +82,16 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
content_type='application/octet-stream'
|
content_type='application/octet-stream'
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"✅ DataFrame успешно сохранен в MinIO: {self._bucket_name}/{object_id}")
|
logger.info(f"✅ DataFrame успешно сохранен в MinIO: {self._bucket_name}/{object_id}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при сохранении в MinIO: {e}")
|
logger.error(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:
|
||||||
print("⚠️ MinIO недоступен, данные не загружены")
|
logger.warning("⚠️ MinIO недоступен, данные не загружены")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -102,7 +106,7 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
|
|
||||||
return df
|
return df
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при загрузке данных из MinIO: {e}")
|
logger.error(f"❌ Ошибка при загрузке данных из MinIO: {e}")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if 'response' in locals():
|
if 'response' in locals():
|
||||||
@@ -112,15 +116,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:
|
||||||
print("⚠️ MinIO недоступен, объект не удален")
|
logger.warning("⚠️ 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)
|
||||||
print(f"✅ Объект успешно удален из MinIO: {self._bucket_name}/{object_id}")
|
logger.info(f"✅ Объект успешно удален из MinIO: {self._bucket_name}/{object_id}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при удалении объекта из MinIO: {e}")
|
logger.error(f"❌ Ошибка при удалении объекта из MinIO: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def object_exists(self, object_id: str) -> bool:
|
def object_exists(self, object_id: str) -> bool:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
from .monitoring_fuel import MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
|
from .monitoring_fuel import MonitoringFuelMonthRequest, MonitoringFuelTotalRequest, MonitoringFuelSeriesRequest
|
||||||
from .svodka_ca import SvodkaCARequest
|
from .svodka_ca import SvodkaCARequest
|
||||||
from .svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
|
from .svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
|
||||||
from .server import ServerInfoResponse
|
from .server import ServerInfoResponse
|
||||||
@@ -6,7 +6,7 @@ from .upload import UploadResponse, UploadErrorResponse
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MonitoringFuelMonthRequest', 'MonitoringFuelTotalRequest',
|
'MonitoringFuelMonthRequest', 'MonitoringFuelTotalRequest', 'MonitoringFuelSeriesRequest',
|
||||||
'SvodkaCARequest',
|
'SvodkaCARequest',
|
||||||
'SvodkaPMSingleOGRequest', 'SvodkaPMTotalOGsRequest',
|
'SvodkaPMSingleOGRequest', 'SvodkaPMTotalOGsRequest',
|
||||||
'ServerInfoResponse',
|
'ServerInfoResponse',
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -32,3 +32,19 @@ class MonitoringFuelTotalRequest(BaseModel):
|
|||||||
"columns": ["total", "normativ"]
|
"columns": ["total", "normativ"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringFuelSeriesRequest(BaseModel):
|
||||||
|
columns: List[str] = Field(
|
||||||
|
...,
|
||||||
|
description="Массив названий выбираемых столбцов",
|
||||||
|
example=["total", "normativ"],
|
||||||
|
min_items=1
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"columns": ["total", "normativ"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
33
python_parser/app/schemas/monitoring_tar.py
Normal file
33
python_parser/app/schemas/monitoring_tar.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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": {}
|
||||||
|
}
|
||||||
38
python_parser/app/schemas/oper_spravka_tech_pos.py
Normal file
38
python_parser/app/schemas/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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": "Данные успешно получены"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
python_parser/app/schemas/statuses_repair_ca.py
Normal file
34
python_parser/app/schemas/statuses_repair_ca.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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": [
|
||||||
|
["Дата начала ремонта"],
|
||||||
|
["Готовность к КР", "Факт"],
|
||||||
|
["Заключение договоров на СМР", "Договор", "%"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ class OGID(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class SvodkaPMSingleOGRequest(BaseModel):
|
class SvodkaPMSingleOGRequest(BaseModel):
|
||||||
id: OGID = Field(
|
id: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Идентификатор МА для запрашиваемого ОГ",
|
description="Идентификатор МА для запрашиваемого ОГ",
|
||||||
example="SNPZ"
|
example="SNPZ"
|
||||||
|
|||||||
46
python_parser/app/schemas/svodka_repair_ca.py
Normal file
46
python_parser/app/schemas/svodka_repair_ca.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
72
python_parser/core/async_services.py
Normal file
72
python_parser/core/async_services.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Асинхронные сервисы для работы с отчетами
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .services import ReportService
|
||||||
|
from .models import UploadRequest, UploadResult, DataRequest, DataResult
|
||||||
|
from .ports import StoragePort
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncReportService:
|
||||||
|
"""Асинхронный сервис для работы с отчетами"""
|
||||||
|
|
||||||
|
def __init__(self, report_service: ReportService):
|
||||||
|
self.report_service = report_service
|
||||||
|
self.executor = ThreadPoolExecutor(max_workers=4)
|
||||||
|
|
||||||
|
async def upload_report_async(self, request: UploadRequest) -> UploadResult:
|
||||||
|
"""Асинхронная загрузка отчета"""
|
||||||
|
try:
|
||||||
|
# Запускаем синхронную обработку в отдельном потоке
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
self.executor,
|
||||||
|
self._process_upload_sync,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при асинхронной загрузке отчета: {str(e)}")
|
||||||
|
return UploadResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Ошибка при асинхронной загрузке отчета: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_upload_sync(self, request: UploadRequest) -> UploadResult:
|
||||||
|
"""Синхронная обработка загрузки (выполняется в отдельном потоке)"""
|
||||||
|
return self.report_service.upload_report(request)
|
||||||
|
|
||||||
|
async def get_data_async(self, request: DataRequest) -> DataResult:
|
||||||
|
"""Асинхронное получение данных"""
|
||||||
|
try:
|
||||||
|
# Запускаем синхронную обработку в отдельном потоке
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
self.executor,
|
||||||
|
self._process_get_data_sync,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при асинхронном получении данных: {str(e)}")
|
||||||
|
return DataResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Ошибка при асинхронном получении данных: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_get_data_sync(self, request: DataRequest) -> DataResult:
|
||||||
|
"""Синхронное получение данных (выполняется в отдельном потоке)"""
|
||||||
|
return self.report_service.get_data(request)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Очистка ресурсов"""
|
||||||
|
if hasattr(self, 'executor'):
|
||||||
|
self.executor.shutdown(wait=False)
|
||||||
@@ -2,28 +2,93 @@
|
|||||||
Порты (интерфейсы) для hexagonal architecture
|
Порты (интерфейсы) для hexagonal architecture
|
||||||
"""
|
"""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional
|
from typing import Optional, Dict, List, Any, Callable
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
class ParserPort(ABC):
|
class ParserPort(ABC):
|
||||||
"""Интерфейс для парсеров"""
|
"""Интерфейс для парсеров с поддержкой множественных геттеров"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Инициализация с пустым словарем геттеров"""
|
||||||
|
self.getters: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._register_default_getters()
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию - переопределяется в наследниках"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def register_getter(self, name: str, method: Callable, required_params: List[str],
|
||||||
|
optional_params: List[str] = None, description: str = ""):
|
||||||
|
"""
|
||||||
|
Регистрация нового геттера
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя геттера
|
||||||
|
method: Метод для выполнения
|
||||||
|
required_params: Список обязательных параметров
|
||||||
|
optional_params: Список необязательных параметров
|
||||||
|
description: Описание геттера
|
||||||
|
"""
|
||||||
|
if optional_params is None:
|
||||||
|
optional_params = []
|
||||||
|
|
||||||
|
self.getters[name] = {
|
||||||
|
"method": method,
|
||||||
|
"required_params": required_params,
|
||||||
|
"optional_params": optional_params,
|
||||||
|
"description": description
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_available_getters(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Получение списка доступных геттеров с их описанием"""
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
"required_params": info["required_params"],
|
||||||
|
"optional_params": info["optional_params"],
|
||||||
|
"description": info["description"]
|
||||||
|
}
|
||||||
|
for name, info in self.getters.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавить схему
|
||||||
|
def get_value(self, getter_name: str, params: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Получение значения через указанный геттер
|
||||||
|
|
||||||
|
Args:
|
||||||
|
getter_name: Имя геттера
|
||||||
|
params: Параметры для геттера
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результат выполнения геттера
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Если геттер не найден или параметры неверны
|
||||||
|
"""
|
||||||
|
if getter_name not in self.getters:
|
||||||
|
available = list(self.getters.keys())
|
||||||
|
raise ValueError(f"Геттер '{getter_name}' не найден. Доступные: {available}")
|
||||||
|
|
||||||
|
getter_info = self.getters[getter_name]
|
||||||
|
required = getter_info["required_params"]
|
||||||
|
|
||||||
|
# Проверка обязательных параметров
|
||||||
|
missing = [p for p in required if p not in params]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Отсутствуют обязательные параметры для геттера '{getter_name}': {missing}")
|
||||||
|
|
||||||
|
# Вызов метода геттера
|
||||||
|
try:
|
||||||
|
return getter_info["method"](params)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Ошибка выполнения геттера '{getter_name}': {str(e)}")
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
"""Парсинг файла и возврат DataFrame"""
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_value(self, df: pd.DataFrame, params: dict):
|
|
||||||
"""Получение значения из DataFrame по параметрам"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# @abstractmethod
|
|
||||||
# def get_schema(self) -> dict:
|
|
||||||
# """Возвращает схему входных параметров для парсера"""
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
class StoragePort(ABC):
|
class StoragePort(ABC):
|
||||||
"""Интерфейс для хранилища данных"""
|
"""Интерфейс для хранилища данных"""
|
||||||
|
|||||||
140
python_parser/core/schema_utils.py
Normal file
140
python_parser/core/schema_utils.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Упрощенные утилиты для работы со схемами 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)}")
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
"""
|
"""
|
||||||
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]] = {}
|
||||||
@@ -43,7 +47,7 @@ class ReportService:
|
|||||||
try:
|
try:
|
||||||
# Парсим файл
|
# Парсим файл
|
||||||
parse_params = request.parse_params or {}
|
parse_params = request.parse_params or {}
|
||||||
df = parser.parse(temp_file_path, parse_params)
|
parse_result = 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}"
|
||||||
@@ -51,10 +55,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)
|
||||||
print(f"Старый объект удален: {object_id}")
|
logger.debug(f"Старый объект удален: {object_id}")
|
||||||
|
|
||||||
# Сохраняем в хранилище
|
# Сохраняем в хранилище
|
||||||
if self.storage.save_dataframe(df, object_id):
|
if self.storage.save_dataframe(parse_result, object_id):
|
||||||
return UploadResult(
|
return UploadResult(
|
||||||
success=True,
|
success=True,
|
||||||
message="Отчет успешно загружен",
|
message="Отчет успешно загружен",
|
||||||
@@ -89,9 +93,9 @@ class ReportService:
|
|||||||
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Загружаем DataFrame из хранилища
|
# Загружаем данные из хранилища
|
||||||
df = self.storage.load_dataframe(object_id)
|
loaded_data = self.storage.load_dataframe(object_id)
|
||||||
if df is None:
|
if loaded_data is None:
|
||||||
return DataResult(
|
return DataResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
||||||
@@ -99,9 +103,111 @@ class ReportService:
|
|||||||
|
|
||||||
# Получаем парсер
|
# Получаем парсер
|
||||||
parser = get_parser(request.report_type)
|
parser = get_parser(request.report_type)
|
||||||
|
|
||||||
|
# Устанавливаем данные в парсер для использования в геттерах
|
||||||
|
parser.df = loaded_data
|
||||||
|
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)}")
|
||||||
|
|
||||||
# Получаем значение
|
# Получаем параметры запроса
|
||||||
value = parser.get_value(df, request.get_params)
|
get_params = request.get_params or {}
|
||||||
|
|
||||||
|
# Для svodka_ca определяем режим из данных или используем 'fact' по умолчанию
|
||||||
|
if request.report_type == 'svodka_ca':
|
||||||
|
# Извлекаем режим из DataFrame или используем 'fact' по умолчанию
|
||||||
|
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']
|
||||||
|
# Используем первый найденный режим или 'fact' по умолчанию
|
||||||
|
default_mode = modes_in_df[0] if len(modes_in_df) > 0 else 'fact'
|
||||||
|
else:
|
||||||
|
default_mode = 'fact'
|
||||||
|
|
||||||
|
# Устанавливаем режим в параметры, если он не указан
|
||||||
|
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:
|
||||||
|
value = parser.get_value(getter_name, get_params)
|
||||||
|
except ValueError as e:
|
||||||
|
return DataResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Ошибка параметров: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Формируем результат
|
# Формируем результат
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"version":"1","format":"xl-single","id":"29118f57-702e-4363-9a41-9f06655e449d","xl":{"version":"3","this":"195a90f4-fc26-46a8-b6d4-0b50b99b1342","sets":[["195a90f4-fc26-46a8-b6d4-0b50b99b1342"]],"distributionAlgo":"SIPMOD+PARITY"}}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,5 +11,4 @@ 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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Запуск Streamlit интерфейса для NIN Excel Parsers API
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import webbrowser
|
|
||||||
import time
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Основная функция"""
|
|
||||||
print("🚀 ЗАПУСК STREAMLIT ИНТЕРФЕЙСА")
|
|
||||||
print("=" * 50)
|
|
||||||
print("Убедитесь, что FastAPI сервер запущен на порту 8000")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
# Проверяем, установлен ли Streamlit
|
|
||||||
try:
|
|
||||||
import streamlit
|
|
||||||
print(f"✅ Streamlit {streamlit.__version__} установлен")
|
|
||||||
except ImportError:
|
|
||||||
print("❌ Streamlit не установлен")
|
|
||||||
print("Установите: pip install streamlit")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("\n🚀 Запускаю Streamlit...")
|
|
||||||
print("📍 URL: http://localhost:8501")
|
|
||||||
print("🛑 Для остановки нажмите Ctrl+C")
|
|
||||||
|
|
||||||
# Открываем браузер
|
|
||||||
try:
|
|
||||||
webbrowser.open("http://localhost:8501")
|
|
||||||
print("✅ Браузер открыт")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Не удалось открыть браузер: {e}")
|
|
||||||
|
|
||||||
# Запускаем Streamlit
|
|
||||||
try:
|
|
||||||
subprocess.run([
|
|
||||||
sys.executable, "-m", "streamlit", "run", "streamlit_app.py",
|
|
||||||
"--server.port", "8501",
|
|
||||||
"--server.address", "localhost",
|
|
||||||
"--server.headless", "false",
|
|
||||||
"--browser.gatherUsageStats", "false"
|
|
||||||
])
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n👋 Streamlit остановлен")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
python-3.11.*
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import pandas as pd
|
|
||||||
import io
|
|
||||||
import zipfile
|
|
||||||
from typing import Dict, Any
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Конфигурация страницы
|
|
||||||
st.set_page_config(
|
|
||||||
page_title="NIN Excel Parsers API Demo",
|
|
||||||
page_icon="📊",
|
|
||||||
layout="wide",
|
|
||||||
initial_sidebar_state="expanded"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Конфигурация API
|
|
||||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://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:
|
|
||||||
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("📊 Сводки ПМ - Полный функционал")
|
|
||||||
|
|
||||||
# Секция загрузки файлов
|
|
||||||
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(
|
|
||||||
"Выберите режимы",
|
|
||||||
["План", "Факт", "Норматив"],
|
|
||||||
default=["План", "Факт"],
|
|
||||||
key="ca_modes"
|
|
||||||
)
|
|
||||||
|
|
||||||
tables = st.multiselect(
|
|
||||||
"Выберите таблицы",
|
|
||||||
["ТиП", "Топливо", "Потери"],
|
|
||||||
default=["ТиП", "Топливо"],
|
|
||||||
key="ca_tables"
|
|
||||||
)
|
|
||||||
|
|
||||||
with col2:
|
|
||||||
st.subheader("Результат")
|
|
||||||
if st.button("🔍 Получить данные СА", key="ca_btn"):
|
|
||||||
if modes and tables:
|
|
||||||
with st.spinner("Получаю данные..."):
|
|
||||||
data = {
|
|
||||||
"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', 'Неизвестная ошибка')}")
|
|
||||||
|
|
||||||
# Футер
|
|
||||||
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()
|
|
||||||
20
python_parser/test_app.py
Normal file
20
python_parser/test_app.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/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)
|
||||||
33
run_tests.py
Normal file
33
run_tests.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/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)
|
||||||
49
start_dev.py
Normal file
49
start_dev.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/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()
|
||||||
49
start_prod.py
Normal file
49
start_prod.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/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()
|
||||||
15
streamlit_app/.streamlit/config.toml
Normal file
15
streamlit_app/.streamlit/config.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[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"
|
||||||
23
streamlit_app/Dockerfile
Normal file
23
streamlit_app/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Установка системных зависимостей
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Копирование requirements.txt
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Установка Python зависимостей
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копирование кода приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Открытие порта
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
# Запуск Streamlit
|
||||||
|
CMD ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
100
streamlit_app/_streamlit_app.py
Normal file
100
streamlit_app/_streamlit_app.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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")
|
||||||
76
streamlit_app/api_client.py
Normal file
76
streamlit_app/api_client.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Модуль для работы с API
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from typing import Dict, Any, List, Tuple
|
||||||
|
from config import API_BASE_URL, API_PUBLIC_URL
|
||||||
|
|
||||||
|
|
||||||
|
def check_api_health() -> bool:
|
||||||
|
"""Проверка доступности API"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/", timeout=5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_parsers() -> List[str]:
|
||||||
|
"""Получение списка доступных парсеров"""
|
||||||
|
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() -> Dict[str, Any]:
|
||||||
|
"""Получение информации о сервере"""
|
||||||
|
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) -> Tuple[Dict[str, Any], int]:
|
||||||
|
"""Загрузка файла на 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]) -> Tuple[Dict[str, Any], int]:
|
||||||
|
"""Выполнение 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 []
|
||||||
160
streamlit_app/async_upload_page.py
Normal file
160
streamlit_app/async_upload_page.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
Страница асинхронной загрузки файлов
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from api_client import upload_file_to_api
|
||||||
|
from config import PARSER_TABS
|
||||||
|
|
||||||
|
# Глобальное хранилище задач (в реальном приложении лучше использовать Redis или БД)
|
||||||
|
TASKS_STORAGE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file_async_background(endpoint, file_data, filename, task_id):
|
||||||
|
"""Асинхронная загрузка файла в фоновом режиме"""
|
||||||
|
global TASKS_STORAGE
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Обновляем статус на "running"
|
||||||
|
TASKS_STORAGE[task_id] = {
|
||||||
|
'status': 'running',
|
||||||
|
'filename': filename,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'started_at': time.time(),
|
||||||
|
'progress': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имитируем асинхронную работу
|
||||||
|
time.sleep(1) # Небольшая задержка для демонстрации
|
||||||
|
|
||||||
|
# Выполняем загрузку
|
||||||
|
result, status = upload_file_to_api(endpoint, file_data, filename)
|
||||||
|
|
||||||
|
# Сохраняем результат в глобальном хранилище
|
||||||
|
TASKS_STORAGE[task_id] = {
|
||||||
|
'status': 'completed' if status == 200 else 'failed',
|
||||||
|
'result': result,
|
||||||
|
'status_code': status,
|
||||||
|
'filename': filename,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'started_at': TASKS_STORAGE.get(task_id, {}).get('started_at', time.time()),
|
||||||
|
'completed_at': time.time(),
|
||||||
|
'progress': 100
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Сохраняем ошибку
|
||||||
|
TASKS_STORAGE[task_id] = {
|
||||||
|
'status': 'failed',
|
||||||
|
'error': str(e),
|
||||||
|
'filename': filename,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'started_at': TASKS_STORAGE.get(task_id, {}).get('started_at', time.time()),
|
||||||
|
'completed_at': time.time(),
|
||||||
|
'progress': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_async_upload_page():
|
||||||
|
"""Рендер страницы асинхронной загрузки"""
|
||||||
|
st.title("🚀 Асинхронная загрузка файлов")
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
st.info("""
|
||||||
|
**Асинхронная загрузка** позволяет загружать файлы без блокировки интерфейса.
|
||||||
|
После загрузки файл будет обработан в фоновом режиме, а вы сможете отслеживать прогресс на странице "Управление задачами".
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Выбор парсера
|
||||||
|
st.subheader("📋 Выбор парсера")
|
||||||
|
|
||||||
|
# Создаем словарь парсеров с их асинхронными эндпоинтами
|
||||||
|
parser_endpoints = {
|
||||||
|
"Сводки ПМ": "/async/svodka_pm/upload-zip",
|
||||||
|
"Сводки СА": "/async/svodka_ca/upload",
|
||||||
|
"Мониторинг топлива": "/async/monitoring_fuel/upload-zip",
|
||||||
|
"Ремонт СА": "/svodka_repair_ca/upload", # Пока синхронный
|
||||||
|
"Статусы ремонта СА": "/statuses_repair_ca/upload", # Пока синхронный
|
||||||
|
"Мониторинг ТЭР": "/monitoring_tar/upload", # Пока синхронный
|
||||||
|
"Операционные справки": "/oper_spravka_tech_pos/upload" # Пока синхронный
|
||||||
|
}
|
||||||
|
|
||||||
|
selected_parser = st.selectbox(
|
||||||
|
"Выберите тип парсера для загрузки:",
|
||||||
|
list(parser_endpoints.keys()),
|
||||||
|
key="async_parser_select"
|
||||||
|
)
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Загрузка файла
|
||||||
|
st.subheader("📤 Загрузка файла")
|
||||||
|
|
||||||
|
uploaded_file = st.file_uploader(
|
||||||
|
f"Выберите ZIP архив для парсера '{selected_parser}'",
|
||||||
|
type=['zip'],
|
||||||
|
key="async_file_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_file is not None:
|
||||||
|
st.success(f"✅ Файл выбран: {uploaded_file.name}")
|
||||||
|
st.info(f"📊 Размер файла: {uploaded_file.size / 1024 / 1024:.2f} MB")
|
||||||
|
|
||||||
|
if st.button("🚀 Загрузить асинхронно", key="async_upload_btn", use_container_width=True):
|
||||||
|
# Создаем уникальный ID задачи
|
||||||
|
task_id = f"task_{int(time.time())}_{uploaded_file.name}"
|
||||||
|
|
||||||
|
# Показываем сообщение о создании задачи
|
||||||
|
st.success("✅ Задача загрузки создана!")
|
||||||
|
st.info(f"ID задачи: `{task_id}`")
|
||||||
|
st.info("📋 Перейдите на страницу 'Управление задачами' для отслеживания прогресса")
|
||||||
|
|
||||||
|
# Запускаем загрузку в фоновом потоке
|
||||||
|
endpoint = parser_endpoints[selected_parser]
|
||||||
|
file_data = uploaded_file.read()
|
||||||
|
|
||||||
|
# Создаем поток для асинхронной загрузки
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=upload_file_async_background,
|
||||||
|
args=(endpoint, file_data, uploaded_file.name, task_id)
|
||||||
|
)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# Автоматически переключаемся на страницу задач
|
||||||
|
st.session_state.sidebar_tasks_clicked = True
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Информация о поддерживаемых форматах
|
||||||
|
with st.expander("ℹ️ Поддерживаемые форматы файлов"):
|
||||||
|
st.markdown("""
|
||||||
|
**Поддерживаемые форматы:**
|
||||||
|
- 📦 ZIP архивы с Excel файлами
|
||||||
|
- 📊 Excel файлы (.xlsx, .xls)
|
||||||
|
- 📋 CSV файлы (для некоторых парсеров)
|
||||||
|
|
||||||
|
**Ограничения:**
|
||||||
|
- Максимальный размер файла: 100 MB
|
||||||
|
- Количество файлов в архиве: до 50
|
||||||
|
- Поддерживаемые кодировки: UTF-8, Windows-1251
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Статистика загрузок
|
||||||
|
st.subheader("📈 Статистика загрузок")
|
||||||
|
|
||||||
|
col1, col2, col3 = st.columns(3)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.metric("Всего загружено", "0", "0")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.metric("В обработке", "0", "0")
|
||||||
|
|
||||||
|
with col3:
|
||||||
|
st.metric("Завершено", "0", "0")
|
||||||
58
streamlit_app/config.py
Normal file
58
streamlit_app/config.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация приложения
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Конфигурация 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 setup_page_config():
|
||||||
|
"""Настройка конфигурации страницы Streamlit"""
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="NIN Excel Parsers API Demo",
|
||||||
|
page_icon="📊",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="expanded"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Константы для парсеров
|
||||||
|
PARSER_TABS = [
|
||||||
|
"📊 Сводки ПМ",
|
||||||
|
"🏭 Сводки СА",
|
||||||
|
"⛽ Мониторинг топлива",
|
||||||
|
"🔧 Ремонт СА",
|
||||||
|
"📋 Статусы ремонта СА",
|
||||||
|
"⚡ Мониторинг ТЭР",
|
||||||
|
"🏭 Операционные справки"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Константы для ОГ
|
||||||
|
DEFAULT_OGS = [
|
||||||
|
"SNPZ", "KNPZ", "ANHK", "AchNPZ", "UNPZ", "UNH", "NOV",
|
||||||
|
"NovKuybNPZ", "KuybNPZ", "CyzNPZ", "TuapsNPZ", "RNPK",
|
||||||
|
"NVNPO", "KLNPZ", "PurNP", "YANOS"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Константы для кодов строк ПМ
|
||||||
|
PM_CODES = [78, 79, 394, 395, 396, 397, 81, 82, 83, 84]
|
||||||
|
|
||||||
|
# Константы для столбцов ПМ
|
||||||
|
PM_COLUMNS = ["БП", "ПП", "СЭБ", "Факт", "План"]
|
||||||
|
|
||||||
|
# Константы для режимов СА
|
||||||
|
CA_MODES = ["plan", "fact", "normativ"]
|
||||||
|
|
||||||
|
# Константы для таблиц СА
|
||||||
|
CA_TABLES = ["ТиП", "Топливо", "Потери"]
|
||||||
|
|
||||||
|
# Константы для столбцов мониторинга топлива
|
||||||
|
FUEL_COLUMNS = ["normativ", "total", "total_1"]
|
||||||
|
|
||||||
|
# Константы для типов ремонта
|
||||||
|
REPAIR_TYPES = ["КР", "КП", "ТР"]
|
||||||
|
|
||||||
|
# Константы для режимов мониторинга ТЭР
|
||||||
|
TAR_MODES = ["all", "total", "last_day"]
|
||||||
3
streamlit_app/parsers_ui/__init__.py
Normal file
3
streamlit_app/parsers_ui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
UI модули для парсеров
|
||||||
|
"""
|
||||||
162
streamlit_app/parsers_ui/monitoring_fuel_ui.py
Normal file
162
streamlit_app/parsers_ui/monitoring_fuel_ui.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для мониторинга топлива
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
from api_client import upload_file_to_api, make_api_request
|
||||||
|
from config import FUEL_COLUMNS
|
||||||
|
|
||||||
|
|
||||||
|
def render_monitoring_fuel_tab():
|
||||||
|
"""Рендер вкладки мониторинга топлива"""
|
||||||
|
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(
|
||||||
|
"Выберите столбцы",
|
||||||
|
FUEL_COLUMNS,
|
||||||
|
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', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Новая секция для временных рядов
|
||||||
|
st.subheader("📈 Временные ряды")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("Временные ряды по колонкам")
|
||||||
|
|
||||||
|
# Выбор колонок для временного ряда
|
||||||
|
series_columns = st.multiselect(
|
||||||
|
"Выберите столбцы для временного ряда",
|
||||||
|
FUEL_COLUMNS,
|
||||||
|
default=["total", "normativ"],
|
||||||
|
key="fuel_series_columns"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("📊 Получить временные ряды", key="fuel_series_btn"):
|
||||||
|
if series_columns:
|
||||||
|
with st.spinner("Получаю временные ряды..."):
|
||||||
|
data = {
|
||||||
|
"columns": series_columns
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status = make_api_request("/monitoring_fuel/get_series_by_id_and_columns", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Временные ряды получены")
|
||||||
|
|
||||||
|
# Отображаем данные
|
||||||
|
if result.get('data'):
|
||||||
|
series_data = result['data']
|
||||||
|
|
||||||
|
# Показываем количество найденных ID
|
||||||
|
st.info(f"📊 Найдено {len(series_data)} объектов")
|
||||||
|
|
||||||
|
# Показываем JSON данные
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.warning("⚠️ Данные не найдены")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
else:
|
||||||
|
st.warning("⚠️ Выберите столбцы")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("ℹ️ Справка")
|
||||||
|
st.info("""
|
||||||
|
**Временные ряды** показывают изменение значений по месяцам для всех объектов.
|
||||||
|
|
||||||
|
**Формат данных:**
|
||||||
|
- Каждый ID объекта содержит массивы значений по месяцам
|
||||||
|
- Массивы упорядочены по месяцам (01, 02, 03, ..., 12)
|
||||||
|
- Отсутствующие месяцы пропускаются
|
||||||
|
|
||||||
|
**Доступные колонки:**
|
||||||
|
- `total` - общее потребление
|
||||||
|
- `normativ` - нормативное потребление
|
||||||
|
- И другие колонки из загруженных данных
|
||||||
|
|
||||||
|
**Пример результата:**
|
||||||
|
```
|
||||||
|
SNPZ.VISB: {
|
||||||
|
"total": [23.86, 26.51, 19.66, ...],
|
||||||
|
"normativ": [19.46, 19.45, 18.57, ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
""")
|
||||||
108
streamlit_app/parsers_ui/monitoring_tar_ui.py
Normal file
108
streamlit_app/parsers_ui/monitoring_tar_ui.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для мониторинга ТЭР
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
import json
|
||||||
|
from api_client import upload_file_to_api, make_api_request
|
||||||
|
from config import TAR_MODES
|
||||||
|
|
||||||
|
|
||||||
|
def render_monitoring_tar_tab():
|
||||||
|
"""Рендер вкладки мониторинга ТЭР"""
|
||||||
|
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(
|
||||||
|
"Выберите режим данных:",
|
||||||
|
TAR_MODES,
|
||||||
|
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("📋 Результат:")
|
||||||
|
|
||||||
|
# Парсим данные, если они пришли как строка
|
||||||
|
if isinstance(data, str):
|
||||||
|
try:
|
||||||
|
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', 'Неизвестная ошибка')}")
|
||||||
84
streamlit_app/parsers_ui/oper_spravka_tech_pos_ui.py
Normal file
84
streamlit_app/parsers_ui/oper_spravka_tech_pos_ui.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для операционных справок технологических позиций
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
from api_client import upload_file_to_api, make_api_request, get_available_ogs
|
||||||
|
|
||||||
|
|
||||||
|
def render_oper_spravka_tech_pos_tab():
|
||||||
|
"""Рендер вкладки операционных справок технологических позиций"""
|
||||||
|
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', 'Неизвестная ошибка')}")
|
||||||
146
streamlit_app/parsers_ui/statuses_repair_ca_ui.py
Normal file
146
streamlit_app/parsers_ui/statuses_repair_ca_ui.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для статусов ремонта СА
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
from api_client import upload_file_to_api, make_api_request, get_available_ogs
|
||||||
|
|
||||||
|
|
||||||
|
def render_statuses_repair_ca_tab():
|
||||||
|
"""Рендер вкладки статусов ремонта СА"""
|
||||||
|
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', 'Неизвестная ошибка')}")
|
||||||
80
streamlit_app/parsers_ui/svodka_ca_ui.py
Normal file
80
streamlit_app/parsers_ui/svodka_ca_ui.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для парсера сводок СА
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import requests
|
||||||
|
from api_client import make_api_request, API_BASE_URL
|
||||||
|
from config import CA_MODES, CA_TABLES
|
||||||
|
|
||||||
|
|
||||||
|
def render_svodka_ca_tab():
|
||||||
|
"""Рендер вкладки сводок СА"""
|
||||||
|
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(
|
||||||
|
"Выберите режимы",
|
||||||
|
CA_MODES,
|
||||||
|
default=["plan", "fact"],
|
||||||
|
key="ca_modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
tables = st.multiselect(
|
||||||
|
"Выберите таблицы",
|
||||||
|
CA_TABLES,
|
||||||
|
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("⚠️ Выберите режимы и таблицы")
|
||||||
118
streamlit_app/parsers_ui/svodka_pm_ui.py
Normal file
118
streamlit_app/parsers_ui/svodka_pm_ui.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для парсера сводок ПМ
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
from api_client import upload_file_to_api, make_api_request
|
||||||
|
from config import PM_CODES, PM_COLUMNS, DEFAULT_OGS
|
||||||
|
|
||||||
|
|
||||||
|
def render_svodka_pm_tab():
|
||||||
|
"""Рендер вкладки сводок ПМ"""
|
||||||
|
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(
|
||||||
|
"Выберите ОГ",
|
||||||
|
DEFAULT_OGS,
|
||||||
|
key="pm_single_og"
|
||||||
|
)
|
||||||
|
|
||||||
|
codes = st.multiselect(
|
||||||
|
"Выберите коды строк",
|
||||||
|
PM_CODES,
|
||||||
|
default=[78, 79],
|
||||||
|
key="pm_single_codes"
|
||||||
|
)
|
||||||
|
|
||||||
|
columns = st.multiselect(
|
||||||
|
"Выберите столбцы",
|
||||||
|
PM_COLUMNS,
|
||||||
|
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(
|
||||||
|
"Выберите коды строк",
|
||||||
|
PM_CODES,
|
||||||
|
default=[78, 79, 394, 395],
|
||||||
|
key="pm_total_codes"
|
||||||
|
)
|
||||||
|
|
||||||
|
columns_total = st.multiselect(
|
||||||
|
"Выберите столбцы",
|
||||||
|
PM_COLUMNS,
|
||||||
|
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("⚠️ Выберите коды и столбцы")
|
||||||
110
streamlit_app/parsers_ui/svodka_repair_ca_ui.py
Normal file
110
streamlit_app/parsers_ui/svodka_repair_ca_ui.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
UI модуль для ремонта СА
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
from api_client import upload_file_to_api, make_api_request, get_available_ogs
|
||||||
|
from config import REPAIR_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def render_svodka_repair_ca_tab():
|
||||||
|
"""Рендер вкладки ремонта СА"""
|
||||||
|
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(
|
||||||
|
"Выберите типы ремонта (оставьте пустым для всех)",
|
||||||
|
REPAIR_TYPES,
|
||||||
|
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', 'Неизвестная ошибка')}")
|
||||||
7
streamlit_app/requirements.txt
Normal file
7
streamlit_app/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
streamlit>=1.28.0
|
||||||
|
pandas>=2.0.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
plotly>=5.15.0
|
||||||
|
minio>=7.1.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
xlrd>=2.0.1
|
||||||
76
streamlit_app/sidebar.py
Normal file
76
streamlit_app/sidebar.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Модуль для сайдбара
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
from api_client import get_server_info, get_available_parsers
|
||||||
|
from config import API_PUBLIC_URL
|
||||||
|
|
||||||
|
|
||||||
|
def render_sidebar():
|
||||||
|
"""Рендер боковой панели"""
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("ℹ️ Информация1")
|
||||||
|
|
||||||
|
# Информация о сервере
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Навигация по страницам
|
||||||
|
st.markdown("---")
|
||||||
|
st.subheader("🧭 Навигация")
|
||||||
|
|
||||||
|
# Определяем активную страницу
|
||||||
|
active_page = st.session_state.get("active_page", 0)
|
||||||
|
|
||||||
|
# Кнопка для страницы синхронных парсеров
|
||||||
|
if st.button("📊 Синхронные парсеры", key="sidebar_sync_btn", use_container_width=True, type="primary" if active_page == 0 else "secondary"):
|
||||||
|
st.session_state.sidebar_sync_clicked = True
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Кнопка для страницы асинхронной загрузки
|
||||||
|
if st.button("🚀 Асинхронная загрузка", key="sidebar_async_btn", use_container_width=True, type="primary" if active_page == 1 else "secondary"):
|
||||||
|
st.session_state.sidebar_async_clicked = True
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Кнопка для страницы управления задачами
|
||||||
|
if st.button("📋 Управление задачами", key="sidebar_tasks_btn", use_container_width=True, type="primary" if active_page == 2 else "secondary"):
|
||||||
|
st.session_state.sidebar_tasks_clicked = True
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
def render_footer():
|
||||||
|
"""Рендер футера"""
|
||||||
|
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 (веб-интерфейс)
|
||||||
|
""")
|
||||||
61
streamlit_app/streamlit_app.py
Normal file
61
streamlit_app/streamlit_app.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from config import setup_page_config, API_PUBLIC_URL
|
||||||
|
from api_client import check_api_health
|
||||||
|
from sidebar import render_sidebar, render_footer
|
||||||
|
from sync_parsers_page import render_sync_parsers_page
|
||||||
|
from async_upload_page import render_async_upload_page
|
||||||
|
from tasks_page import render_tasks_page
|
||||||
|
|
||||||
|
# Конфигурация страницы
|
||||||
|
setup_page_config()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Определяем активную страницу для заголовка
|
||||||
|
active_page = st.session_state.get("active_page", 0)
|
||||||
|
page_titles = {
|
||||||
|
0: "Синхронные парсеры",
|
||||||
|
1: "Асинхронная загрузка",
|
||||||
|
2: "Управление задачами"
|
||||||
|
}
|
||||||
|
|
||||||
|
st.title(f"🚀 NIN Excel Parsers API - {page_titles.get(active_page, 'Демонстрация')}")
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Проверка доступности API
|
||||||
|
if not check_api_health():
|
||||||
|
st.error(f"❌ API недоступен по адресу {API_PUBLIC_URL}")
|
||||||
|
st.info("Убедитесь, что FastAPI сервер запущен")
|
||||||
|
return
|
||||||
|
|
||||||
|
st.success(f"✅ API доступен по адресу {API_PUBLIC_URL}")
|
||||||
|
|
||||||
|
# Обрабатываем клики по кнопкам в сайдбаре ПЕРЕД рендером
|
||||||
|
if st.session_state.get("sidebar_sync_clicked", False):
|
||||||
|
st.session_state.sidebar_sync_clicked = False
|
||||||
|
st.session_state.active_page = 0
|
||||||
|
elif st.session_state.get("sidebar_async_clicked", False):
|
||||||
|
st.session_state.sidebar_async_clicked = False
|
||||||
|
st.session_state.active_page = 1
|
||||||
|
elif st.session_state.get("sidebar_tasks_clicked", False):
|
||||||
|
st.session_state.sidebar_tasks_clicked = False
|
||||||
|
st.session_state.active_page = 2
|
||||||
|
|
||||||
|
# Определяем активную страницу
|
||||||
|
active_page = st.session_state.get("active_page", 0)
|
||||||
|
|
||||||
|
# Боковая панель с информацией и навигацией
|
||||||
|
render_sidebar()
|
||||||
|
|
||||||
|
# Рендерим соответствующую страницу
|
||||||
|
if active_page == 0:
|
||||||
|
render_sync_parsers_page()
|
||||||
|
elif active_page == 1:
|
||||||
|
render_async_upload_page()
|
||||||
|
else:
|
||||||
|
render_tasks_page()
|
||||||
|
|
||||||
|
# Футер
|
||||||
|
render_footer()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
54
streamlit_app/sync_parsers_page.py
Normal file
54
streamlit_app/sync_parsers_page.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Страница синхронных парсеров
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
from parsers_ui.svodka_pm_ui import render_svodka_pm_tab
|
||||||
|
from parsers_ui.svodka_ca_ui import render_svodka_ca_tab
|
||||||
|
from parsers_ui.monitoring_fuel_ui import render_monitoring_fuel_tab
|
||||||
|
from parsers_ui.svodka_repair_ca_ui import render_svodka_repair_ca_tab
|
||||||
|
from parsers_ui.statuses_repair_ca_ui import render_statuses_repair_ca_tab
|
||||||
|
from parsers_ui.monitoring_tar_ui import render_monitoring_tar_tab
|
||||||
|
from parsers_ui.oper_spravka_tech_pos_ui import render_oper_spravka_tech_pos_tab
|
||||||
|
from config import PARSER_TABS
|
||||||
|
|
||||||
|
|
||||||
|
def render_sync_parsers_page():
|
||||||
|
"""Рендер страницы синхронных парсеров"""
|
||||||
|
st.title("📊 Синхронные парсеры")
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
st.info("""
|
||||||
|
**Синхронные парсеры** обрабатывают файлы сразу после загрузки.
|
||||||
|
Интерфейс будет заблокирован до завершения обработки.
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Основные вкладки - по одной на каждый парсер
|
||||||
|
tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs(PARSER_TABS)
|
||||||
|
|
||||||
|
# Вкладка 1: Сводки ПМ - полный функционал
|
||||||
|
with tab1:
|
||||||
|
render_svodka_pm_tab()
|
||||||
|
|
||||||
|
# Вкладка 2: Сводки СА - полный функционал
|
||||||
|
with tab2:
|
||||||
|
render_svodka_ca_tab()
|
||||||
|
|
||||||
|
# Вкладка 3: Мониторинг топлива - полный функционал
|
||||||
|
with tab3:
|
||||||
|
render_monitoring_fuel_tab()
|
||||||
|
|
||||||
|
# Вкладка 4: Ремонт СА
|
||||||
|
with tab4:
|
||||||
|
render_svodka_repair_ca_tab()
|
||||||
|
|
||||||
|
# Вкладка 5: Статусы ремонта СА
|
||||||
|
with tab5:
|
||||||
|
render_statuses_repair_ca_tab()
|
||||||
|
|
||||||
|
# Вкладка 6: Мониторинг ТЭР
|
||||||
|
with tab6:
|
||||||
|
render_monitoring_tar_tab()
|
||||||
|
|
||||||
|
# Вкладка 7: Операционные справки технологических позиций
|
||||||
|
with tab7:
|
||||||
|
render_oper_spravka_tech_pos_tab()
|
||||||
186
streamlit_app/tasks_page.py
Normal file
186
streamlit_app/tasks_page.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Страница управления задачами загрузки
|
||||||
|
"""
|
||||||
|
import streamlit as st
|
||||||
|
from datetime import datetime
|
||||||
|
import time
|
||||||
|
from async_upload_page import TASKS_STORAGE
|
||||||
|
|
||||||
|
|
||||||
|
def render_tasks_page():
|
||||||
|
"""Рендер страницы управления задачами"""
|
||||||
|
st.title("📋 Управление задачами загрузки")
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Кнопки управления
|
||||||
|
col1, col2, col3, col4 = st.columns([1, 1, 1, 2])
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
if st.button("🔄 Обновить", key="refresh_tasks_btn", use_container_width=True):
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
if st.button("🗑️ Очистить завершенные", key="clear_completed_btn", use_container_width=True):
|
||||||
|
# Удаляем завершенные и неудачные задачи
|
||||||
|
tasks_to_remove = []
|
||||||
|
for task_id, task in TASKS_STORAGE.items():
|
||||||
|
if task.get('status') in ['completed', 'failed']:
|
||||||
|
tasks_to_remove.append(task_id)
|
||||||
|
|
||||||
|
for task_id in tasks_to_remove:
|
||||||
|
del TASKS_STORAGE[task_id]
|
||||||
|
|
||||||
|
st.success(f"✅ Удалено {len(tasks_to_remove)} завершенных задач")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with col3:
|
||||||
|
auto_refresh = st.checkbox("🔄 Автообновление", key="auto_refresh_checkbox")
|
||||||
|
if auto_refresh:
|
||||||
|
time.sleep(2)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with col4:
|
||||||
|
st.caption("Последнее обновление: " + datetime.now().strftime("%H:%M:%S"))
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Статистика задач
|
||||||
|
st.subheader("📊 Статистика задач")
|
||||||
|
|
||||||
|
# Получаем задачи из глобального хранилища
|
||||||
|
tasks = TASKS_STORAGE
|
||||||
|
|
||||||
|
# Подсчитываем статистику
|
||||||
|
total_tasks = len(tasks)
|
||||||
|
pending_tasks = len([t for t in tasks.values() if t.get('status') == 'pending'])
|
||||||
|
running_tasks = len([t for t in tasks.values() if t.get('status') == 'running'])
|
||||||
|
completed_tasks = len([t for t in tasks.values() if t.get('status') == 'completed'])
|
||||||
|
failed_tasks = len([t for t in tasks.values() if t.get('status') == 'failed'])
|
||||||
|
|
||||||
|
col1, col2, col3, col4, col5 = st.columns(5)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.metric("Всего", total_tasks, f"+{total_tasks}")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.metric("Ожидают", pending_tasks, f"+{pending_tasks}")
|
||||||
|
|
||||||
|
with col3:
|
||||||
|
st.metric("Выполняются", running_tasks, f"+{running_tasks}")
|
||||||
|
|
||||||
|
with col4:
|
||||||
|
st.metric("Завершены", completed_tasks, f"+{completed_tasks}")
|
||||||
|
|
||||||
|
with col5:
|
||||||
|
st.metric("Ошибки", failed_tasks, f"+{failed_tasks}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Список задач
|
||||||
|
st.subheader("📋 Список задач")
|
||||||
|
|
||||||
|
# Получаем задачи из глобального хранилища
|
||||||
|
tasks = TASKS_STORAGE
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
# Показываем задачи
|
||||||
|
for task_id, task in tasks.items():
|
||||||
|
status_emoji = {
|
||||||
|
'pending': '🟡',
|
||||||
|
'running': '🔵',
|
||||||
|
'completed': '🟢',
|
||||||
|
'failed': '🔴'
|
||||||
|
}.get(task.get('status', 'pending'), '⚪')
|
||||||
|
|
||||||
|
with st.expander(f"{status_emoji} {task.get('filename', 'Unknown')} - {task.get('status', 'unknown').upper()}", expanded=True):
|
||||||
|
col1, col2 = st.columns([3, 1])
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.write(f"**ID:** `{task_id}`")
|
||||||
|
st.write(f"**Статус:** {status_emoji} {task.get('status', 'unknown').upper()}")
|
||||||
|
st.write(f"**Файл:** {task.get('filename', 'Unknown')}")
|
||||||
|
st.write(f"**Эндпоинт:** {task.get('endpoint', 'Unknown')}")
|
||||||
|
|
||||||
|
# Показываем прогресс для выполняющихся задач
|
||||||
|
if task.get('status') == 'running':
|
||||||
|
progress = task.get('progress', 0)
|
||||||
|
st.write(f"**Прогресс:** {progress}%")
|
||||||
|
st.progress(progress / 100)
|
||||||
|
|
||||||
|
# Показываем время выполнения
|
||||||
|
if task.get('started_at'):
|
||||||
|
started_time = datetime.fromtimestamp(task['started_at']).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
st.write(f"**Начата:** {started_time}")
|
||||||
|
|
||||||
|
if task.get('completed_at'):
|
||||||
|
completed_time = datetime.fromtimestamp(task['completed_at']).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
st.write(f"**Завершена:** {completed_time}")
|
||||||
|
|
||||||
|
# Показываем длительность
|
||||||
|
if task.get('started_at'):
|
||||||
|
duration = task['completed_at'] - task['started_at']
|
||||||
|
st.write(f"**Длительность:** {duration:.1f} сек")
|
||||||
|
|
||||||
|
if task.get('result'):
|
||||||
|
result = task['result']
|
||||||
|
if task.get('status') == 'completed':
|
||||||
|
st.success(f"✅ {result.get('message', 'Задача выполнена')}")
|
||||||
|
if result.get('object_id'):
|
||||||
|
st.info(f"ID объекта: {result['object_id']}")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ {result.get('message', 'Ошибка выполнения')}")
|
||||||
|
|
||||||
|
if task.get('error'):
|
||||||
|
st.error(f"❌ Ошибка: {task['error']}")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
if task.get('status') in ['pending', 'running']:
|
||||||
|
if st.button("❌ Отменить", key=f"cancel_{task_id}_btn", use_container_width=True):
|
||||||
|
st.info("Функция отмены будет реализована в следующих версиях")
|
||||||
|
else:
|
||||||
|
if st.button("🗑️ Удалить", key=f"delete_{task_id}_btn", use_container_width=True):
|
||||||
|
# Удаляем задачу из глобального хранилища
|
||||||
|
if task_id in TASKS_STORAGE:
|
||||||
|
del TASKS_STORAGE[task_id]
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
# Пустое состояние
|
||||||
|
st.info("""
|
||||||
|
**Нет активных задач**
|
||||||
|
|
||||||
|
Загрузите файл на странице "Асинхронная загрузка", чтобы создать новую задачу.
|
||||||
|
Здесь вы сможете отслеживать прогресс обработки и управлять задачами.
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Кнопка для создания тестовой задачи
|
||||||
|
if st.button("🧪 Создать тестовую задачу", key="create_test_task_btn"):
|
||||||
|
test_task_id = f"test_task_{int(time.time())}"
|
||||||
|
|
||||||
|
TASKS_STORAGE[test_task_id] = {
|
||||||
|
'status': 'completed',
|
||||||
|
'filename': 'test_file.zip',
|
||||||
|
'endpoint': '/test/upload',
|
||||||
|
'result': {'message': 'Тестовая задача выполнена', 'object_id': 'test-123'},
|
||||||
|
'started_at': time.time() - 5, # 5 секунд назад
|
||||||
|
'completed_at': time.time(),
|
||||||
|
'progress': 100
|
||||||
|
}
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Информация о статусах задач
|
||||||
|
with st.expander("ℹ️ Статусы задач"):
|
||||||
|
st.markdown("""
|
||||||
|
**Статусы задач:**
|
||||||
|
- 🟡 **Ожидает** - задача создана и ожидает выполнения
|
||||||
|
- 🔵 **Выполняется** - задача обрабатывается
|
||||||
|
- 🟢 **Завершена** - задача успешно выполнена
|
||||||
|
- 🔴 **Ошибка** - произошла ошибка при выполнении
|
||||||
|
- ⚫ **Отменена** - задача была отменена пользователем
|
||||||
|
|
||||||
|
**Действия:**
|
||||||
|
- ❌ **Отменить** - отменить выполнение задачи
|
||||||
|
- 🔄 **Обновить** - обновить статус задачи
|
||||||
|
- 📊 **Детали** - просмотреть подробную информацию
|
||||||
|
""")
|
||||||
BIN
test_repair_ca.zip
Normal file
BIN
test_repair_ca.zip
Normal file
Binary file not shown.
44
tests/README.md
Normal file
44
tests/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Тесты для парсеров
|
||||||
|
|
||||||
|
Этот каталог содержит 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. Создайте тесты для всех геттеров
|
||||||
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация 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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tests/requirements.txt
Normal file
4
tests/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pytest>=7.0.0
|
||||||
|
pandas>=1.5.0
|
||||||
|
numpy>=1.20.0
|
||||||
|
openpyxl>=3.0.0
|
||||||
BIN
tests/test_data/monitoring.zip
Normal file
BIN
tests/test_data/monitoring.zip
Normal file
Binary file not shown.
BIN
tests/test_data/oper_spavka_tech_pos_SNPZ.zip
Normal file
BIN
tests/test_data/oper_spavka_tech_pos_SNPZ.zip
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user