Compare commits
2 Commits
work-1
...
9459196804
| Author | SHA1 | Date | |
|---|---|---|---|
| 9459196804 | |||
| ce228d9756 |
179
.gitignore
vendored
179
.gitignore
vendored
@@ -1,26 +1,13 @@
|
|||||||
# Python
|
data/
|
||||||
__pycache__
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
python_parser/__pycache__/
|
|
||||||
python_parser/core/__pycache__/
|
|
||||||
python_parser/adapters/__pycache__/
|
|
||||||
python_parser/tests/__pycache__/
|
|
||||||
python_parser/tests/test_core/__pycache__/
|
|
||||||
python_parser/tests/test_adapters/__pycache__/
|
|
||||||
python_parser/tests/test_app/__pycache__/
|
|
||||||
python_parser/app/__pycache__/
|
|
||||||
python_parser/app/schemas/__pycache__/
|
|
||||||
python_parser/app/schemas/test_schemas/__pycache__/
|
|
||||||
python_parser/app/schemas/test_schemas/test_core/__pycache__/
|
|
||||||
python_parser/app/schemas/test_schemas/test_adapters/__pycache__/
|
|
||||||
python_parser/app/schemas/test_schemas/test_app/__pycache__/
|
|
||||||
|
|
||||||
nin_python_parser
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
@@ -33,82 +20,26 @@ lib64/
|
|||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# Virtual environments
|
# Installer logs
|
||||||
.env
|
pip-log.txt
|
||||||
.venv
|
pip-delete-this-directory.txt
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# IDE
|
# Unit test / coverage reports
|
||||||
.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/
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
@@ -116,29 +47,6 @@ htmlcov/
|
|||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.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
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.dmypy.json
|
.dmypy.json
|
||||||
@@ -147,29 +55,36 @@ dmypy.json
|
|||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
# pytype static type analyzer
|
# VS Code
|
||||||
.pytype/
|
.vscode/
|
||||||
|
|
||||||
# Cython debug symbols
|
# PyCharm
|
||||||
cython_debug/
|
.idea/
|
||||||
|
|
||||||
# Local development
|
# Local envs
|
||||||
local_settings.py
|
.env
|
||||||
db.sqlite3
|
.venv
|
||||||
db.sqlite3-journal
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
# FastAPI
|
# MacOS
|
||||||
.pytest_cache/
|
.DS_Store
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
|
|
||||||
# Streamlit
|
# Windows
|
||||||
.streamlit/secrets.toml
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
# Node.js (if any frontend components)
|
# MinIO test data
|
||||||
node_modules/
|
minio_data/
|
||||||
npm-debug.log*
|
minio_test/
|
||||||
yarn-debug.log*
|
minio/
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
__pycache__/
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Streamlit cache
|
||||||
|
.streamlit/
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# 🚀 Быстрый запуск проекта
|
|
||||||
|
|
||||||
## 1. Запуск всех сервисов
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Проверка статуса
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Доступ к сервисам
|
|
||||||
- **FastAPI**: http://localhost:8000
|
|
||||||
- **Streamlit**: http://localhost:8501
|
|
||||||
- **MinIO Console**: http://localhost:9001
|
|
||||||
- **MinIO API**: http://localhost:9000
|
|
||||||
|
|
||||||
## 4. Остановка
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Просмотр логов
|
|
||||||
```bash
|
|
||||||
# Все сервисы
|
|
||||||
docker compose logs
|
|
||||||
|
|
||||||
# Конкретный сервис
|
|
||||||
docker compose logs fastapi
|
|
||||||
docker compose logs streamlit
|
|
||||||
docker compose logs minio
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Пересборка и перезапуск
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
**Примечание**: При первом запуске Docker будет скачивать образы и собирать контейнеры, это может занять несколько минут.
|
|
||||||
117
README.md
117
README.md
@@ -1,117 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Лицензия
|
|
||||||
|
|
||||||
Проект разработан для внутреннего использования.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
services:
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
container_name: svodka_minio_dev
|
|
||||||
ports:
|
|
||||||
- "9000:9000" # API порт
|
|
||||||
- "9001:9001" # Консоль порт
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: minioadmin
|
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
volumes:
|
|
||||||
- ./minio_data:/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
fastapi:
|
|
||||||
image: python:3.11-slim
|
|
||||||
container_name: svodka_fastapi_dev
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
- MINIO_ENDPOINT=minio:9000
|
|
||||||
- MINIO_ACCESS_KEY=minioadmin
|
|
||||||
- MINIO_SECRET_KEY=minioadmin
|
|
||||||
- MINIO_SECURE=false
|
|
||||||
- MINIO_BUCKET=svodka-data
|
|
||||||
volumes:
|
|
||||||
# Монтируем исходный код для автоматической перезагрузки
|
|
||||||
- ./python_parser:/app
|
|
||||||
# Монтируем requirements.txt для установки зависимостей
|
|
||||||
- ./python_parser/requirements.txt:/app/requirements.txt
|
|
||||||
working_dir: /app
|
|
||||||
depends_on:
|
|
||||||
- minio
|
|
||||||
restart: unless-stopped
|
|
||||||
command: >
|
|
||||||
bash -c "
|
|
||||||
pip install --no-cache-dir -r requirements.txt &&
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
"
|
|
||||||
|
|
||||||
streamlit:
|
|
||||||
image: python:3.11-slim
|
|
||||||
container_name: svodka_streamlit_dev
|
|
||||||
ports:
|
|
||||||
- "8501:8501"
|
|
||||||
environment:
|
|
||||||
- API_BASE_URL=http://fastapi:8000
|
|
||||||
- API_PUBLIC_URL=http://localhost:8000
|
|
||||||
- MINIO_ENDPOINT=minio:9000
|
|
||||||
- MINIO_ACCESS_KEY=minioadmin
|
|
||||||
- MINIO_SECRET_KEY=minioadmin
|
|
||||||
- MINIO_SECURE=false
|
|
||||||
- MINIO_BUCKET=svodka-data
|
|
||||||
volumes:
|
|
||||||
# Монтируем исходный код для автоматической перезагрузки
|
|
||||||
- ./streamlit_app:/app
|
|
||||||
# Монтируем requirements.txt для установки зависимостей
|
|
||||||
- ./streamlit_app/requirements.txt:/app/requirements.txt
|
|
||||||
working_dir: /app
|
|
||||||
depends_on:
|
|
||||||
- minio
|
|
||||||
- fastapi
|
|
||||||
restart: unless-stopped
|
|
||||||
command: >
|
|
||||||
bash -c "
|
|
||||||
pip install --no-cache-dir -r requirements.txt &&
|
|
||||||
streamlit run streamlit_app.py --server.port=8501 --server.address=0.0.0.0 --server.runOnSave=true
|
|
||||||
"
|
|
||||||
28
python_parser/.streamlit/config.toml
Normal file
28
python_parser/.streamlit/config.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[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
|
||||||
20
python_parser/Dockerfile_
Normal file
20
python_parser/Dockerfile_
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM repo-dev.predix.rosneft.ru/python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# RUN pip install kafka-python==2.0.2
|
||||||
|
# RUN pip freeze > /app/requirements.txt
|
||||||
|
|
||||||
|
# ADD . /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN mkdir -p vendor
|
||||||
|
RUN pip download -r /app/requirements.txt --no-binary=:none: -d /app/vendor
|
||||||
|
|
||||||
|
# ADD . /app
|
||||||
|
|
||||||
|
# ENV KAFKA_BROKER=10.234.160.10:9093,10.234.160.10:9094,10.234.160.10:9095
|
||||||
|
# ENV KAFKA_UPDATE_ALGORITHM_RULES_TOPIC=algorithm-rule-update
|
||||||
|
# ENV KAFKA_CLIENT_USERNAME=cf-service
|
||||||
|
|
||||||
|
# CMD ["python", "/app/run_dev.py"]
|
||||||
1
python_parser/Procfile
Normal file
1
python_parser/Procfile
Normal file
@@ -0,0 +1 @@
|
|||||||
|
web: python /app/run_stand.py
|
||||||
66
python_parser/QUICK_START.md
Normal file
66
python_parser/QUICK_START.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 🚀 Быстрый старт 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
|
||||||
|
```
|
||||||
143
python_parser/README.md
Normal file
143
python_parser/README.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# NIN Excel Parsers API
|
||||||
|
|
||||||
|
API для парсинга Excel отчетов нефтеперерабатывающих заводов (НПЗ) с использованием FastAPI и MinIO для хранения данных.
|
||||||
|
|
||||||
|
## 🚀 Быстрый запуск
|
||||||
|
|
||||||
|
### **Вариант 1: Все сервисы в Docker (рекомендуется)**
|
||||||
|
```bash
|
||||||
|
# Запуск всех сервисов: MinIO + FastAPI + Streamlit
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Доступ:
|
||||||
|
# - MinIO Console: http://localhost:9001
|
||||||
|
# - FastAPI: http://localhost:8000
|
||||||
|
# - Streamlit: http://localhost:8501
|
||||||
|
# - API Docs: http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Вариант 2: Только MinIO в Docker + FastAPI локально**
|
||||||
|
```bash
|
||||||
|
# Запуск MinIO в Docker
|
||||||
|
docker-compose up -d minio
|
||||||
|
|
||||||
|
# Запуск FastAPI локально
|
||||||
|
python run_dev.py
|
||||||
|
|
||||||
|
# В отдельном терминале запуск Streamlit
|
||||||
|
cd streamlit_app
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Вариант 3: Только MinIO в Docker**
|
||||||
|
```bash
|
||||||
|
# Запуск только MinIO
|
||||||
|
docker-compose up -d minio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Описание сервисов
|
||||||
|
|
||||||
|
- **MinIO** (порт 9000-9001): S3-совместимое хранилище для данных
|
||||||
|
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
|
||||||
|
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
|
||||||
|
|
||||||
|
## 🛑 Остановка
|
||||||
|
|
||||||
|
### Остановка Docker сервисов:
|
||||||
|
```bash
|
||||||
|
# Все сервисы
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Только MinIO
|
||||||
|
docker-compose stop minio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Остановка локальных сервисов:
|
||||||
|
```bash
|
||||||
|
# Нажмите Ctrl+C в терминале с FastAPI/Streamlit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
python_parser/
|
||||||
|
├── app/ # FastAPI приложение
|
||||||
|
│ ├── main.py # Основной файл приложения
|
||||||
|
│ └── schemas/ # Pydantic схемы
|
||||||
|
├── core/ # Бизнес-логика
|
||||||
|
│ ├── models.py # Модели данных
|
||||||
|
│ ├── ports.py # Интерфейсы (порты)
|
||||||
|
│ └── services.py # Сервисы
|
||||||
|
├── adapters/ # Адаптеры для внешних систем
|
||||||
|
│ ├── storage.py # MinIO адаптер
|
||||||
|
│ └── parsers/ # Парсеры Excel файлов
|
||||||
|
├── streamlit_app/ # Изолированный Streamlit пакет
|
||||||
|
│ ├── app.py # Основное Streamlit приложение
|
||||||
|
│ ├── requirements.txt # Зависимости Streamlit
|
||||||
|
│ ├── Dockerfile # Docker образ для Streamlit
|
||||||
|
│ └── .streamlit/ # Конфигурация Streamlit
|
||||||
|
├── data/ # Тестовые данные
|
||||||
|
├── docker-compose.yml # Docker Compose конфигурация
|
||||||
|
├── Dockerfile # Docker образ для FastAPI
|
||||||
|
└── run_dev.py # Запуск FastAPI локально
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Доступные эндпоинты
|
||||||
|
|
||||||
|
- **GET /** - Информация об API
|
||||||
|
- **GET /docs** - Swagger документация
|
||||||
|
- **GET /parsers** - Список доступных парсеров
|
||||||
|
- **GET /parsers/{parser_name}/getters** - Информация о геттерах парсера
|
||||||
|
- **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
|
||||||
|
- **POST /svodka_ca/upload** - Загрузка сводок ЦА
|
||||||
|
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
|
||||||
|
- **POST /svodka_pm/get_data** - Получение данных сводок ПМ
|
||||||
|
- **POST /svodka_ca/get_data** - Получение данных сводок ЦА
|
||||||
|
- **POST /monitoring_fuel/get_data** - Получение данных мониторинга топлива
|
||||||
|
|
||||||
|
## 📊 Поддерживаемые типы отчетов
|
||||||
|
|
||||||
|
1. **svodka_pm** - Сводки по переработке нефти (ПМ)
|
||||||
|
- Геттеры: `single_og`, `total_ogs`
|
||||||
|
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
|
||||||
|
- Геттеры: `get_data`
|
||||||
|
3. **monitoring_fuel** - Мониторинг топлива
|
||||||
|
- Геттеры: `total_by_columns`, `month_by_code`
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
Проект использует **Hexagonal Architecture (Ports and Adapters)**:
|
||||||
|
|
||||||
|
- **Порты (Ports)**: Интерфейсы для бизнес-логики
|
||||||
|
- **Адаптеры (Adapters)**: Реализации для внешних систем
|
||||||
|
- **Сервисы (Services)**: Бизнес-логика приложения
|
||||||
|
|
||||||
|
### Система геттеров парсеров
|
||||||
|
|
||||||
|
Каждый парсер может иметь несколько методов получения данных (геттеров):
|
||||||
|
- Регистрация геттеров в словаре с метаданными
|
||||||
|
- Валидация параметров для каждого геттера
|
||||||
|
- Единый интерфейс `get_value(getter_name, params)`
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
### Сборка образов:
|
||||||
|
```bash
|
||||||
|
# FastAPI
|
||||||
|
docker build -t nin-fastapi .
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
docker build -t nin-streamlit ./streamlit_app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск отдельных сервисов:
|
||||||
|
```bash
|
||||||
|
# Только MinIO
|
||||||
|
docker-compose up -d minio
|
||||||
|
|
||||||
|
# MinIO + FastAPI
|
||||||
|
docker-compose up -d minio fastapi
|
||||||
|
|
||||||
|
# Все сервисы
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
186
python_parser/README_STREAMLIT.md
Normal file
186
python_parser/README_STREAMLIT.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# 🚀 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
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# Интеграция схем Pydantic с парсерами
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Этот документ описывает решение для устранения дублирования логики между схемами Pydantic и парсерами. Теперь схемы Pydantic являются единым источником правды для определения параметров парсеров.
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
Ранее в парсерах дублировалась информация о параметрах:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# В парсере
|
|
||||||
self.register_getter(
|
|
||||||
name="single_og",
|
|
||||||
method=self._get_single_og,
|
|
||||||
required_params=["id", "codes", "columns"], # Дублирование
|
|
||||||
optional_params=["search"], # Дублирование
|
|
||||||
description="Получение данных по одному ОГ"
|
|
||||||
)
|
|
||||||
|
|
||||||
# В схеме
|
|
||||||
class SvodkaPMSingleOGRequest(BaseModel):
|
|
||||||
id: OGID = Field(...) # Обязательное поле
|
|
||||||
codes: List[int] = Field(...) # Обязательное поле
|
|
||||||
columns: List[str] = Field(...) # Обязательное поле
|
|
||||||
search: Optional[str] = Field(None) # Необязательное поле
|
|
||||||
```
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
### 1. Утилиты для работы со схемами
|
|
||||||
|
|
||||||
Создан модуль `core/schema_utils.py` с функциями:
|
|
||||||
|
|
||||||
- `get_required_fields_from_schema()` - извлекает обязательные поля
|
|
||||||
- `get_optional_fields_from_schema()` - извлекает необязательные поля
|
|
||||||
- `register_getter_from_schema()` - регистрирует геттер с использованием схемы
|
|
||||||
- `validate_params_with_schema()` - валидирует параметры с помощью схемы
|
|
||||||
|
|
||||||
### 2. Обновленные парсеры
|
|
||||||
|
|
||||||
Теперь парсеры используют схемы как единый источник правды:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _register_default_getters(self):
|
|
||||||
"""Регистрация геттеров по умолчанию"""
|
|
||||||
# Используем схемы Pydantic как единый источник правды
|
|
||||||
register_getter_from_schema(
|
|
||||||
parser_instance=self,
|
|
||||||
getter_name="single_og",
|
|
||||||
method=self._get_single_og,
|
|
||||||
schema_class=SvodkaPMSingleOGRequest,
|
|
||||||
description="Получение данных по одному ОГ"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Валидация параметров
|
|
||||||
|
|
||||||
Методы геттеров теперь автоматически валидируют параметры:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _get_single_og(self, params: dict):
|
|
||||||
"""Получение данных по одному ОГ"""
|
|
||||||
# Валидируем параметры с помощью схемы Pydantic
|
|
||||||
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
|
|
||||||
|
|
||||||
og_id = validated_params["id"]
|
|
||||||
codes = validated_params["codes"]
|
|
||||||
columns = validated_params["columns"]
|
|
||||||
search = validated_params.get("search")
|
|
||||||
|
|
||||||
# ... остальная логика
|
|
||||||
```
|
|
||||||
|
|
||||||
## Преимущества
|
|
||||||
|
|
||||||
1. **Единый источник правды** - информация о параметрах хранится только в схемах Pydantic
|
|
||||||
2. **Автоматическая валидация** - параметры автоматически валидируются с помощью Pydantic
|
|
||||||
3. **Синхронизация** - изменения в схемах автоматически отражаются в парсерах
|
|
||||||
4. **Типобезопасность** - использование типов Pydantic обеспечивает типобезопасность
|
|
||||||
5. **Документация** - Swagger документация автоматически генерируется из схем
|
|
||||||
|
|
||||||
## Совместимость
|
|
||||||
|
|
||||||
Решение работает с:
|
|
||||||
- Pydantic v1 (через `__fields__`)
|
|
||||||
- Pydantic v2 (через `model_fields` и `is_required()`)
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Для новых парсеров
|
|
||||||
|
|
||||||
1. Создайте схему Pydantic с нужными полями
|
|
||||||
2. Используйте `register_getter_from_schema()` для регистрации геттера
|
|
||||||
3. Используйте `validate_params_with_schema()` в методах геттеров
|
|
||||||
|
|
||||||
### Для существующих парсеров
|
|
||||||
|
|
||||||
1. Убедитесь, что у вас есть соответствующая схема Pydantic
|
|
||||||
2. Замените ручную регистрацию геттеров на `register_getter_from_schema()`
|
|
||||||
3. Добавьте валидацию параметров в методы геттеров
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
### Схема с обязательными и необязательными полями
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ExampleRequest(BaseModel):
|
|
||||||
required_field: str = Field(..., description="Обязательное поле")
|
|
||||||
optional_field: Optional[str] = Field(None, description="Необязательное поле")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Регистрация геттера
|
|
||||||
|
|
||||||
```python
|
|
||||||
register_getter_from_schema(
|
|
||||||
parser_instance=self,
|
|
||||||
getter_name="example_getter",
|
|
||||||
method=self._example_method,
|
|
||||||
schema_class=ExampleRequest,
|
|
||||||
description="Пример геттера"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Валидация в методе
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _example_method(self, params: dict):
|
|
||||||
validated_params = validate_params_with_schema(params, ExampleRequest)
|
|
||||||
# validated_params содержит валидированные данные
|
|
||||||
```
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Это решение устраняет дублирование кода и обеспечивает единообразие между API схемами и парсерами. Теперь изменения в схемах автоматически отражаются в парсерах, что упрощает поддержку и развитие системы.
|
|
||||||
Binary file not shown.
@@ -1,88 +0,0 @@
|
|||||||
# Парсер Сводки ПМ
|
|
||||||
|
|
||||||
## Описание
|
|
||||||
|
|
||||||
Парсер для обработки сводок ПМ (план и факт) с поддержкой множественных геттеров. Наследуется от `ParserPort` и реализует архитектуру hexagonal architecture.
|
|
||||||
|
|
||||||
## Доступные геттеры
|
|
||||||
|
|
||||||
### 1. `get_single_og`
|
|
||||||
Получение данных по одному ОГ из сводки ПМ.
|
|
||||||
|
|
||||||
**Обязательные параметры:**
|
|
||||||
- `id` (str): ID ОГ (например, "SNPZ", "KNPZ")
|
|
||||||
- `codes` (list): Список кодов показателей (например, [78, 79, 81, 82])
|
|
||||||
- `columns` (list): Список столбцов для извлечения (например, ["ПП", "БП", "СЭБ"])
|
|
||||||
|
|
||||||
**Необязательные параметры:**
|
|
||||||
- `search` (str): Значение для поиска в столбцах
|
|
||||||
|
|
||||||
**Пример использования:**
|
|
||||||
```python
|
|
||||||
parser = SvodkaPMParser()
|
|
||||||
params = {
|
|
||||||
"id": "SNPZ",
|
|
||||||
"codes": [78, 79, 81, 82],
|
|
||||||
"columns": ["ПП", "БП", "СЭБ"]
|
|
||||||
}
|
|
||||||
result = parser.get_value("get_single_og", params)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `get_total_ogs`
|
|
||||||
Получение данных по всем ОГ из сводки ПМ.
|
|
||||||
|
|
||||||
**Обязательные параметры:**
|
|
||||||
- `codes` (list): Список кодов показателей
|
|
||||||
- `columns` (list): Список столбцов для извлечения
|
|
||||||
|
|
||||||
**Необязательные параметры:**
|
|
||||||
- `search` (str): Значение для поиска в столбцах
|
|
||||||
|
|
||||||
**Пример использования:**
|
|
||||||
```python
|
|
||||||
parser = SvodkaPMParser()
|
|
||||||
params = {
|
|
||||||
"codes": [78, 79, 81, 82],
|
|
||||||
"columns": ["ПП", "БП", "СЭБ"]
|
|
||||||
}
|
|
||||||
result = parser.get_value("get_total_ogs", params)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поддерживаемые столбцы
|
|
||||||
|
|
||||||
- **ПП, БП**: Данные из файлов плана
|
|
||||||
- **ТБ, СЭБ, НЭБ**: Данные из файлов факта
|
|
||||||
|
|
||||||
## Структура файлов
|
|
||||||
|
|
||||||
Парсер ожидает следующую структуру файлов:
|
|
||||||
- `data/pm_fact/svodka_fact_pm_{OG_ID}.xlsx` или `.xlsm`
|
|
||||||
- `data/pm_plan/svodka_plan_pm_{OG_ID}.xlsx` или `.xlsm`
|
|
||||||
|
|
||||||
Где `{OG_ID}` - это ID ОГ (например, SNPZ, KNPZ и т.д.)
|
|
||||||
|
|
||||||
## Формат результата
|
|
||||||
|
|
||||||
Результат возвращается в формате JSON со следующей структурой:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ПП": {
|
|
||||||
"78": 123.45,
|
|
||||||
"79": 234.56
|
|
||||||
},
|
|
||||||
"БП": {
|
|
||||||
"78": 111.11,
|
|
||||||
"79": 222.22
|
|
||||||
},
|
|
||||||
"СЭБ": {
|
|
||||||
"78": 333.33,
|
|
||||||
"79": 444.44
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Обработка ошибок
|
|
||||||
|
|
||||||
- Если файл плана/факта не найден, соответствующие столбцы будут пустыми
|
|
||||||
- Если код показателя не найден, возвращается 0
|
|
||||||
- Валидация параметров выполняется автоматически
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,8 +3,6 @@ import re
|
|||||||
import zipfile
|
import zipfile
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
from core.ports import ParserPort
|
from core.ports import ParserPort
|
||||||
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
|
||||||
from app.schemas.monitoring_fuel import MonitoringFuelTotalRequest, MonitoringFuelMonthRequest
|
|
||||||
from adapters.pconfig import data_to_json
|
from adapters.pconfig import data_to_json
|
||||||
|
|
||||||
|
|
||||||
@@ -15,130 +13,46 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
|
|
||||||
def _register_default_getters(self):
|
def _register_default_getters(self):
|
||||||
"""Регистрация геттеров по умолчанию"""
|
"""Регистрация геттеров по умолчанию"""
|
||||||
# Используем схемы Pydantic как единый источник правды
|
self.register_getter(
|
||||||
register_getter_from_schema(
|
name="total_by_columns",
|
||||||
parser_instance=self,
|
|
||||||
getter_name="total_by_columns",
|
|
||||||
method=self._get_total_by_columns,
|
method=self._get_total_by_columns,
|
||||||
schema_class=MonitoringFuelTotalRequest,
|
required_params=["columns"],
|
||||||
|
optional_params=[],
|
||||||
description="Агрегация данных по колонкам"
|
description="Агрегация данных по колонкам"
|
||||||
)
|
)
|
||||||
|
|
||||||
register_getter_from_schema(
|
self.register_getter(
|
||||||
parser_instance=self,
|
name="month_by_code",
|
||||||
getter_name="month_by_code",
|
|
||||||
method=self._get_month_by_code,
|
method=self._get_month_by_code,
|
||||||
schema_class=MonitoringFuelMonthRequest,
|
required_params=["month"],
|
||||||
|
optional_params=[],
|
||||||
description="Получение данных за конкретный месяц"
|
description="Получение данных за конкретный месяц"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_total_by_columns(self, params: dict):
|
def _get_total_by_columns(self, params: dict):
|
||||||
"""Агрегация данных по колонкам"""
|
"""Агрегация по колонкам (обертка для совместимости)"""
|
||||||
# Валидируем параметры с помощью схемы Pydantic
|
columns = params["columns"]
|
||||||
validated_params = validate_params_with_schema(params, MonitoringFuelTotalRequest)
|
if not columns:
|
||||||
|
raise ValueError("Отсутствуют идентификаторы столбцов")
|
||||||
|
|
||||||
columns = validated_params["columns"]
|
# TODO: Переделать под новую архитектуру
|
||||||
|
df_means, _ = self.aggregate_by_columns(self.df, columns)
|
||||||
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
return df_means.to_dict(orient='index')
|
||||||
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
|
||||||
# Данные из парсинга
|
|
||||||
data_source = self.data_dict
|
|
||||||
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
|
||||||
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
|
||||||
data_source = self._df_to_data_dict()
|
|
||||||
else:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Агрегируем данные по колонкам
|
|
||||||
df_means, _ = self.aggregate_by_columns(data_source, columns)
|
|
||||||
|
|
||||||
# Преобразуем в JSON-совместимый формат
|
|
||||||
result = {}
|
|
||||||
for idx, row in df_means.iterrows():
|
|
||||||
result[str(idx)] = {}
|
|
||||||
for col in columns:
|
|
||||||
value = row.get(col)
|
|
||||||
if pd.isna(value) or value == float('inf') or value == float('-inf'):
|
|
||||||
result[str(idx)][col] = None
|
|
||||||
else:
|
|
||||||
result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _get_month_by_code(self, params: dict):
|
def _get_month_by_code(self, params: dict):
|
||||||
"""Получение данных за конкретный месяц"""
|
"""Получение данных за месяц (обертка для совместимости)"""
|
||||||
# Валидируем параметры с помощью схемы Pydantic
|
month = params["month"]
|
||||||
validated_params = validate_params_with_schema(params, MonitoringFuelMonthRequest)
|
if not month:
|
||||||
|
raise ValueError("Отсутствует идентификатор месяца")
|
||||||
|
|
||||||
month = validated_params["month"]
|
# TODO: Переделать под новую архитектуру
|
||||||
|
df_month = self.get_month(self.df, month)
|
||||||
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
return df_month.to_dict(orient='index')
|
||||||
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
|
||||||
# Данные из парсинга
|
|
||||||
data_source = self.data_dict
|
|
||||||
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
|
||||||
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
|
||||||
data_source = self._df_to_data_dict()
|
|
||||||
else:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Получаем данные за конкретный месяц
|
|
||||||
df_month = self.get_month(data_source, month)
|
|
||||||
|
|
||||||
# Преобразуем в JSON-совместимый формат
|
|
||||||
result = {}
|
|
||||||
for idx, row in df_month.iterrows():
|
|
||||||
result[str(idx)] = {}
|
|
||||||
for col in df_month.columns:
|
|
||||||
value = row[col]
|
|
||||||
if pd.isna(value) or value == float('inf') or value == float('-inf'):
|
|
||||||
result[str(idx)][col] = None
|
|
||||||
else:
|
|
||||||
result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _df_to_data_dict(self):
|
|
||||||
"""Преобразование DataFrame обратно в словарь данных"""
|
|
||||||
if not hasattr(self, 'df') or self.df is None or self.df.empty:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
data_dict = {}
|
|
||||||
|
|
||||||
# Группируем данные по месяцам
|
|
||||||
for _, row in self.df.iterrows():
|
|
||||||
month = row.get('month')
|
|
||||||
data = row.get('data')
|
|
||||||
|
|
||||||
if month and data is not None:
|
|
||||||
data_dict[month] = data
|
|
||||||
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
"""Парсинг файла и возврат DataFrame"""
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
# Парсим данные и сохраняем словарь для использования в геттерах
|
# Сохраняем DataFrame для использования в геттерах
|
||||||
self.data_dict = self.parse_monitoring_fuel_files(file_path, params)
|
self.df = self.parse_monitoring_fuel_files(file_path, params)
|
||||||
|
|
||||||
# Преобразуем словарь в DataFrame для совместимости с services.py
|
|
||||||
if self.data_dict:
|
|
||||||
# Создаем DataFrame с информацией о месяцах и данных
|
|
||||||
data_rows = []
|
|
||||||
for month, df_data in self.data_dict.items():
|
|
||||||
if df_data is not None and not df_data.empty:
|
|
||||||
data_rows.append({
|
|
||||||
'month': month,
|
|
||||||
'rows_count': len(df_data),
|
|
||||||
'data': df_data
|
|
||||||
})
|
|
||||||
|
|
||||||
if data_rows:
|
|
||||||
df = pd.DataFrame(data_rows)
|
|
||||||
self.df = df
|
|
||||||
return df
|
|
||||||
|
|
||||||
# Если данных нет, возвращаем пустой DataFrame
|
|
||||||
self.df = pd.DataFrame()
|
|
||||||
return self.df
|
return self.df
|
||||||
|
|
||||||
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||||||
@@ -180,8 +94,7 @@ 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'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||||
@@ -203,8 +116,7 @@ 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'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Удаление полностью пустых столбцов ===
|
# === Удаление полностью пустых столбцов ===
|
||||||
@@ -229,11 +141,7 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
if 'name' in df_full.columns:
|
if 'name' in df_full.columns:
|
||||||
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
||||||
# df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
|
# df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
|
||||||
# Временно используем name как id
|
pass # Placeholder for new_code
|
||||||
df_full['id'] = df_full['name']
|
|
||||||
else:
|
|
||||||
# Если нет колонки name, создаем id из индекса
|
|
||||||
df_full['id'] = df_full.index
|
|
||||||
|
|
||||||
# Устанавливаем id как индекс
|
# Устанавливаем id как индекс
|
||||||
df_full.set_index('id', inplace=True)
|
df_full.set_index('id', inplace=True)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import pandas as pd
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -14,212 +12,146 @@ class SvodkaCAParser(ParserPort):
|
|||||||
|
|
||||||
def _register_default_getters(self):
|
def _register_default_getters(self):
|
||||||
"""Регистрация геттеров по умолчанию"""
|
"""Регистрация геттеров по умолчанию"""
|
||||||
# Используем схемы Pydantic как единый источник правды
|
self.register_getter(
|
||||||
register_getter_from_schema(
|
name="get_data",
|
||||||
parser_instance=self,
|
|
||||||
getter_name="get_ca_data",
|
|
||||||
method=self._get_data_wrapper,
|
method=self._get_data_wrapper,
|
||||||
schema_class=SvodkaCARequest,
|
required_params=["modes", "tables"],
|
||||||
|
optional_params=[],
|
||||||
description="Получение данных по режимам и таблицам"
|
description="Получение данных по режимам и таблицам"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_data_wrapper(self, params: dict):
|
def _get_data_wrapper(self, params: dict):
|
||||||
"""Получение данных по режимам и таблицам"""
|
"""Обертка для получения данных (для совместимости)"""
|
||||||
print(f"🔍 DEBUG: _get_data_wrapper вызван с параметрами: {params}")
|
modes = params["modes"]
|
||||||
|
tables = params["tables"]
|
||||||
|
|
||||||
# Валидируем параметры с помощью схемы Pydantic
|
if not isinstance(modes, list):
|
||||||
validated_params = validate_params_with_schema(params, SvodkaCARequest)
|
raise ValueError("Поле 'modes' должно быть списком")
|
||||||
|
if not isinstance(tables, list):
|
||||||
modes = validated_params["modes"]
|
raise ValueError("Поле 'tables' должно быть списком")
|
||||||
tables = validated_params["tables"]
|
|
||||||
|
|
||||||
print(f"🔍 DEBUG: Запрошенные режимы: {modes}")
|
|
||||||
print(f"🔍 DEBUG: Запрошенные таблицы: {tables}")
|
|
||||||
|
|
||||||
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
|
||||||
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
|
||||||
# Данные из парсинга
|
|
||||||
data_source = self.data_dict
|
|
||||||
print(f"🔍 DEBUG: Используем 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()
|
|
||||||
print(f"🔍 DEBUG: Используем df, преобразованный в data_dict с режимами: {list(data_source.keys())}")
|
|
||||||
else:
|
|
||||||
print(f"🔍 DEBUG: Нет данных! 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())
|
|
||||||
print(f"🔍 DEBUG: Режим '{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
|
|
||||||
print(f"🔍 DEBUG: Добавлена таблица '{table_name}' (совпадение с '{requested_table}') с {len(table_data)} записями")
|
|
||||||
break # Найдено совпадение, переходим к следующей таблице
|
|
||||||
else:
|
|
||||||
print(f"🔍 DEBUG: Режим '{mode}' не найден в data_source")
|
|
||||||
|
|
||||||
print(f"🔍 DEBUG: Итоговый результат содержит режимы: {list(result_data.keys())}")
|
|
||||||
return result_data
|
|
||||||
|
|
||||||
def _df_to_data_dict(self):
|
|
||||||
"""Преобразование DataFrame обратно в словарь данных"""
|
|
||||||
if not hasattr(self, 'df') or self.df is None or self.df.empty:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
# TODO: Переделать под новую архитектуру
|
||||||
data_dict = {}
|
data_dict = {}
|
||||||
|
for mode in modes:
|
||||||
# Группируем данные по режимам и таблицам
|
data_dict[mode] = self.get_data(self.df, mode, tables)
|
||||||
for _, row in self.df.iterrows():
|
return self.data_dict_to_json(data_dict)
|
||||||
mode = row.get('mode')
|
|
||||||
table = row.get('table')
|
|
||||||
data = row.get('data')
|
|
||||||
|
|
||||||
if mode and table and data is not None:
|
|
||||||
if mode not in data_dict:
|
|
||||||
data_dict[mode] = {}
|
|
||||||
data_dict[mode][table] = data
|
|
||||||
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
"""Парсинг файла и возврат DataFrame"""
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
print(f"🔍 DEBUG: SvodkaCAParser.parse вызван с файлом: {file_path}")
|
# Сохраняем DataFrame для использования в геттерах
|
||||||
|
self.df = self.parse_svodka_ca(file_path, params)
|
||||||
# Парсим данные и сохраняем словарь для использования в геттерах
|
|
||||||
self.data_dict = self.parse_svodka_ca(file_path, params)
|
|
||||||
|
|
||||||
# Преобразуем словарь в DataFrame для совместимости с services.py
|
|
||||||
# Создаем простой DataFrame с информацией о загруженных данных
|
|
||||||
if self.data_dict:
|
|
||||||
# Создаем DataFrame с информацией о режимах и таблицах
|
|
||||||
data_rows = []
|
|
||||||
for mode, tables in self.data_dict.items():
|
|
||||||
for table_name, table_data in tables.items():
|
|
||||||
if table_data:
|
|
||||||
data_rows.append({
|
|
||||||
'mode': mode,
|
|
||||||
'table': table_name,
|
|
||||||
'rows_count': len(table_data),
|
|
||||||
'data': table_data
|
|
||||||
})
|
|
||||||
|
|
||||||
if data_rows:
|
|
||||||
df = pd.DataFrame(data_rows)
|
|
||||||
self.df = df
|
|
||||||
print(f"🔍 DEBUG: Создан DataFrame с {len(data_rows)} записями")
|
|
||||||
return df
|
|
||||||
|
|
||||||
# Если данных нет, возвращаем пустой DataFrame
|
|
||||||
self.df = pd.DataFrame()
|
|
||||||
print(f"🔍 DEBUG: Возвращаем пустой DataFrame")
|
|
||||||
return self.df
|
return self.df
|
||||||
|
|
||||||
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
|
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
|
||||||
"""Парсинг сводки СА - работает с тремя листами: План, Факт, Норматив"""
|
"""Парсинг сводки СА"""
|
||||||
print(f"🔍 DEBUG: Начинаем парсинг сводки СА из файла: {file_path}")
|
# === Извлечение и фильтрация ===
|
||||||
|
tables = self.extract_all_tables(file_path, sheet_name)
|
||||||
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
|
|
||||||
|
|
||||||
# Выгружаем План
|
|
||||||
inclusion_list_plan = {
|
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan)
|
# Фильтруем таблицы: оставляем только те, где первая строка содержит нужные заголовки
|
||||||
print(f"🔍 DEBUG: Объединённый и отсортированный План: {df_ca_plan.shape if df_ca_plan is not None else 'None'}")
|
filtered_tables = []
|
||||||
|
for table in tables:
|
||||||
|
if table.empty:
|
||||||
|
continue
|
||||||
|
first_row_values = table.iloc[0].astype(str).str.strip().tolist()
|
||||||
|
if any(val in inclusion_list for val in first_row_values):
|
||||||
|
filtered_tables.append(table)
|
||||||
|
|
||||||
# Выгружаем Факт
|
tables = filtered_tables
|
||||||
inclusion_list_fact = {
|
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн",
|
|
||||||
"в т.ч. Неидентифицированные потери, %"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact)
|
# === Итоговый список таблиц датафреймов ===
|
||||||
print(f"🔍 DEBUG: Объединённый и отсортированный Факт: {df_ca_fact.shape if df_ca_fact is not None else 'None'}")
|
result_list = []
|
||||||
|
|
||||||
# Выгружаем Норматив
|
for table in tables:
|
||||||
inclusion_list_normativ = {
|
if table.empty:
|
||||||
"Топливо итого, тонн",
|
continue
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
|
# Получаем первую строку (до удаления)
|
||||||
print(f"🔍 DEBUG: Объединённый и отсортированный Норматив: {df_ca_normativ.shape if df_ca_normativ is not None else 'None'}")
|
first_row_values = table.iloc[0].astype(str).str.strip().tolist()
|
||||||
|
|
||||||
# Преобразуем DataFrame в словарь по режимам и таблицам
|
# Находим, какой элемент из inclusion_list присутствует
|
||||||
data_dict = {}
|
matched_key = None
|
||||||
|
for val in first_row_values:
|
||||||
# Обрабатываем План
|
if val in inclusion_list:
|
||||||
if df_ca_plan is not None and not df_ca_plan.empty:
|
matched_key = val
|
||||||
data_dict['plan'] = {}
|
break # берём первый совпадающий заголовок
|
||||||
for table_name, group_df in df_ca_plan.groupby('table'):
|
|
||||||
table_data = group_df.drop('table', axis=1)
|
if matched_key is None:
|
||||||
data_dict['plan'][table_name] = table_data.to_dict('records')
|
continue # на всякий случай (хотя уже отфильтровано)
|
||||||
|
|
||||||
# Обрабатываем Факт
|
# Удаляем первую строку (заголовок) и сбрасываем индекс
|
||||||
if df_ca_fact is not None and not df_ca_fact.empty:
|
df_cleaned = table.iloc[1:].copy().reset_index(drop=True)
|
||||||
data_dict['fact'] = {}
|
|
||||||
for table_name, group_df in df_ca_fact.groupby('table'):
|
# Пропускаем, если таблица пустая
|
||||||
table_data = group_df.drop('table', axis=1)
|
if df_cleaned.empty:
|
||||||
data_dict['fact'][table_name] = table_data.to_dict('records')
|
continue
|
||||||
|
|
||||||
# Обрабатываем Норматив
|
# Первая строка становится заголовком
|
||||||
if df_ca_normativ is not None and not df_ca_normativ.empty:
|
new_header = df_cleaned.iloc[0] # извлекаем первую строку как потенциальные названия столбцов
|
||||||
data_dict['normativ'] = {}
|
|
||||||
for table_name, group_df in df_ca_normativ.groupby('table'):
|
# Преобразуем заголовок: только первый столбец может быть заменён на "name"
|
||||||
table_data = group_df.drop('table', axis=1)
|
cleaned_header = []
|
||||||
data_dict['normativ'][table_name] = table_data.to_dict('records')
|
|
||||||
|
# Обрабатываем первый столбец отдельно
|
||||||
print(f"🔍 DEBUG: Итоговый data_dict содержит режимы: {list(data_dict.keys())}")
|
first_item = new_header.iloc[0] if isinstance(new_header, pd.Series) else new_header[0]
|
||||||
for mode, tables in data_dict.items():
|
first_item_str = str(first_item).strip() if pd.notna(first_item) else ""
|
||||||
print(f"🔍 DEBUG: Режим '{mode}' содержит таблицы: {list(tables.keys())}")
|
if first_item_str == "" or first_item_str == "nan":
|
||||||
|
cleaned_header.append("name")
|
||||||
return data_dict
|
else:
|
||||||
|
cleaned_header.append(first_item_str)
|
||||||
|
|
||||||
|
# Остальные столбцы добавляем без изменений (или с минимальной очисткой)
|
||||||
|
for item in new_header[1:]:
|
||||||
|
# Опционально: приводим к строке и убираем лишние пробелы, но не заменяем на "name"
|
||||||
|
item_str = str(item).strip() if pd.notna(item) else ""
|
||||||
|
cleaned_header.append(item_str)
|
||||||
|
|
||||||
|
# Применяем очищенные названия столбцов
|
||||||
|
df_cleaned = df_cleaned[1:] # удаляем строку с заголовком
|
||||||
|
df_cleaned.columns = cleaned_header
|
||||||
|
df_cleaned = df_cleaned.reset_index(drop=True)
|
||||||
|
|
||||||
|
if matched_key.endswith('**'):
|
||||||
|
cleaned_key = matched_key[:-2] # удаляем последние **
|
||||||
|
else:
|
||||||
|
cleaned_key = matched_key
|
||||||
|
|
||||||
|
# Добавляем новую колонку с именем параметра
|
||||||
|
df_cleaned["table"] = cleaned_key
|
||||||
|
|
||||||
|
# Проверяем, что колонка 'name' существует
|
||||||
|
if 'name' not in df_cleaned.columns:
|
||||||
|
print(
|
||||||
|
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
|
||||||
|
continue # или обработать по-другому
|
||||||
|
else:
|
||||||
|
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
||||||
|
df_cleaned['id'] = df_cleaned['name'].apply(get_og_by_name)
|
||||||
|
|
||||||
|
# Удаляем строки, где id — None, NaN или пустой
|
||||||
|
df_cleaned = df_cleaned.dropna(subset=['id']) # dropna удаляет NaN
|
||||||
|
# Дополнительно: удаляем None (если не поймал dropna)
|
||||||
|
df_cleaned = df_cleaned[df_cleaned['id'].notna() & (df_cleaned['id'].astype(str) != 'None')]
|
||||||
|
|
||||||
|
# Добавляем в словарь
|
||||||
|
result_list.append(df_cleaned)
|
||||||
|
|
||||||
|
# === Объединение и сортировка по id (индекс) и table ===
|
||||||
|
if result_list:
|
||||||
|
combined_df = pd.concat(result_list, axis=0)
|
||||||
|
|
||||||
|
# Сортируем по индексу (id) и по столбцу 'table'
|
||||||
|
combined_df = combined_df.sort_values(by=['id', 'table'], axis=0)
|
||||||
|
|
||||||
|
# Устанавливаем id как индекс
|
||||||
|
# combined_df.set_index('id', inplace=True)
|
||||||
|
|
||||||
|
return combined_df
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
def extract_all_tables(self, file_path, sheet_name=0):
|
def extract_all_tables(self, file_path, sheet_name=0):
|
||||||
"""Извлечение всех таблиц из Excel файла"""
|
"""Извлекает все таблицы из Excel файла"""
|
||||||
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None, engine='openpyxl')
|
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
|
|
||||||
from core.ports import ParserPort
|
|
||||||
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
|
||||||
from app.schemas.svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
|
|
||||||
from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
|
|
||||||
|
|
||||||
|
|
||||||
class SvodkaPMParser(ParserPort):
|
|
||||||
"""Парсер для сводок ПМ (план и факт)"""
|
|
||||||
|
|
||||||
name = "Сводки ПМ"
|
|
||||||
|
|
||||||
def _register_default_getters(self):
|
|
||||||
"""Регистрация геттеров по умолчанию"""
|
|
||||||
# Используем схемы Pydantic как единый источник правды
|
|
||||||
register_getter_from_schema(
|
|
||||||
parser_instance=self,
|
|
||||||
getter_name="single_og",
|
|
||||||
method=self._get_single_og,
|
|
||||||
schema_class=SvodkaPMSingleOGRequest,
|
|
||||||
description="Получение данных по одному ОГ"
|
|
||||||
)
|
|
||||||
|
|
||||||
register_getter_from_schema(
|
|
||||||
parser_instance=self,
|
|
||||||
getter_name="total_ogs",
|
|
||||||
method=self._get_total_ogs,
|
|
||||||
schema_class=SvodkaPMTotalOGsRequest,
|
|
||||||
description="Получение данных по всем ОГ"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_single_og(self, params: dict):
|
|
||||||
"""Получение данных по одному ОГ"""
|
|
||||||
# Валидируем параметры с помощью схемы Pydantic
|
|
||||||
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
|
|
||||||
|
|
||||||
og_id = validated_params["id"]
|
|
||||||
codes = validated_params["codes"]
|
|
||||||
columns = validated_params["columns"]
|
|
||||||
search = validated_params.get("search")
|
|
||||||
|
|
||||||
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику
|
|
||||||
# TODO: Переделать под новую архитектуру
|
|
||||||
return self.get_svodka_og(self.df, og_id, codes, columns, search)
|
|
||||||
|
|
||||||
def _get_total_ogs(self, params: dict):
|
|
||||||
"""Получение данных по всем ОГ"""
|
|
||||||
# Валидируем параметры с помощью схемы Pydantic
|
|
||||||
validated_params = validate_params_with_schema(params, SvodkaPMTotalOGsRequest)
|
|
||||||
|
|
||||||
codes = validated_params["codes"]
|
|
||||||
columns = validated_params["columns"]
|
|
||||||
search = validated_params.get("search")
|
|
||||||
|
|
||||||
# TODO: Переделать под новую архитектуру
|
|
||||||
return self.get_svodka_total(self.df, codes, columns, search)
|
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
|
||||||
"""Парсинг файла и возврат DataFrame"""
|
|
||||||
# Сохраняем DataFrame для использования в геттерах
|
|
||||||
self.df = self.parse_svodka_pm_files(file_path, params)
|
|
||||||
return self.df
|
|
||||||
|
|
||||||
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
|
|
||||||
"""Определения индекса заголовка в excel по ключевому слову"""
|
|
||||||
# Читаем первые max_rows строк без заголовков
|
|
||||||
df_temp = pd.read_excel(
|
|
||||||
file,
|
|
||||||
sheet_name=sheet,
|
|
||||||
header=None,
|
|
||||||
nrows=max_rows,
|
|
||||||
engine='openpyxl'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
|
||||||
for idx, row in df_temp.iterrows():
|
|
||||||
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
|
||||||
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
|
||||||
return idx # 0-based index — то, что нужно для header=
|
|
||||||
|
|
||||||
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
|
||||||
|
|
||||||
def parse_svodka_pm(self, file, sheet, header_num=None):
|
|
||||||
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
|
|
||||||
# Автоопределение header_num, если не передан
|
|
||||||
if header_num is None:
|
|
||||||
header_num = self.find_header_row(file, sheet, search_value="Итого")
|
|
||||||
|
|
||||||
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
|
||||||
df_probe = pd.read_excel(
|
|
||||||
file,
|
|
||||||
sheet_name=sheet,
|
|
||||||
header=header_num,
|
|
||||||
usecols=None,
|
|
||||||
nrows=2,
|
|
||||||
engine='openpyxl'
|
|
||||||
)
|
|
||||||
|
|
||||||
if df_probe.shape[0] == 0:
|
|
||||||
raise ValueError("Файл пуст или не содержит данных.")
|
|
||||||
|
|
||||||
first_data_row = df_probe.iloc[0]
|
|
||||||
|
|
||||||
# Находим столбец с 'INDICATOR_ID'
|
|
||||||
indicator_cols = first_data_row[first_data_row == 'INDICATOR_ID']
|
|
||||||
if len(indicator_cols) == 0:
|
|
||||||
raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.')
|
|
||||||
|
|
||||||
indicator_col_name = indicator_cols.index[0]
|
|
||||||
print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}")
|
|
||||||
|
|
||||||
# Читаем весь лист
|
|
||||||
df_full = pd.read_excel(
|
|
||||||
file,
|
|
||||||
sheet_name=sheet,
|
|
||||||
header=header_num,
|
|
||||||
usecols=None,
|
|
||||||
index_col=None,
|
|
||||||
engine='openpyxl'
|
|
||||||
)
|
|
||||||
|
|
||||||
if indicator_col_name not in df_full.columns:
|
|
||||||
raise ValueError(f"Столбец {indicator_col_name} отсутствует при полной загрузке.")
|
|
||||||
|
|
||||||
# Перемещаем INDICATOR_ID в начало и делаем индексом
|
|
||||||
cols = [indicator_col_name] + [col for col in df_full.columns if col != indicator_col_name]
|
|
||||||
df_full = df_full[cols]
|
|
||||||
df_full.set_index(indicator_col_name, inplace=True)
|
|
||||||
|
|
||||||
# Обрезаем до "Итого" + 1
|
|
||||||
header_list = [str(h).strip() for h in df_full.columns]
|
|
||||||
try:
|
|
||||||
itogo_idx = header_list.index("Итого")
|
|
||||||
num_cols_needed = itogo_idx + 2
|
|
||||||
except ValueError:
|
|
||||||
print('Столбец "Итого" не найден. Оставляем все столбцы.')
|
|
||||||
num_cols_needed = len(header_list)
|
|
||||||
|
|
||||||
num_cols_needed = min(num_cols_needed, len(header_list))
|
|
||||||
df_final = df_full.iloc[:, :num_cols_needed]
|
|
||||||
|
|
||||||
# === Удаление полностью пустых столбцов ===
|
|
||||||
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
|
|
||||||
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
|
|
||||||
non_empty_mask = df_clean.notna().any()
|
|
||||||
df_final = df_final.loc[:, non_empty_mask]
|
|
||||||
|
|
||||||
# === Обработка заголовков: Unnamed и "Итого" → "Итого" ===
|
|
||||||
new_columns = []
|
|
||||||
last_good_name = None
|
|
||||||
for col in df_final.columns:
|
|
||||||
col_str = str(col).strip()
|
|
||||||
|
|
||||||
# Проверяем, является ли колонка пустой/некорректной
|
|
||||||
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
|
|
||||||
|
|
||||||
if is_empty_or_unnamed:
|
|
||||||
# Если это пустая колонка, используем последнее хорошее имя
|
|
||||||
if last_good_name:
|
|
||||||
new_columns.append(last_good_name)
|
|
||||||
else:
|
|
||||||
# Если нет хорошего имени, используем имя по умолчанию
|
|
||||||
new_columns.append(f"col_{len(new_columns)}")
|
|
||||||
else:
|
|
||||||
# Это хорошая колонка
|
|
||||||
last_good_name = col_str
|
|
||||||
new_columns.append(col_str)
|
|
||||||
|
|
||||||
# Убеждаемся, что количество столбцов совпадает
|
|
||||||
if len(new_columns) != len(df_final.columns):
|
|
||||||
# Если количество не совпадает, обрезаем или дополняем
|
|
||||||
if len(new_columns) > len(df_final.columns):
|
|
||||||
new_columns = new_columns[:len(df_final.columns)]
|
|
||||||
else:
|
|
||||||
# Дополняем недостающие столбцы
|
|
||||||
while len(new_columns) < len(df_final.columns):
|
|
||||||
new_columns.append(f"col_{len(new_columns)}")
|
|
||||||
|
|
||||||
# Применяем новые заголовки
|
|
||||||
df_final.columns = new_columns
|
|
||||||
|
|
||||||
return df_final
|
|
||||||
|
|
||||||
def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict:
|
|
||||||
"""Парсинг ZIP архива со сводками ПМ"""
|
|
||||||
import zipfile
|
|
||||||
pm_dict = {
|
|
||||||
"facts": {},
|
|
||||||
"plans": {}
|
|
||||||
}
|
|
||||||
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
|
||||||
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
|
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
||||||
file_list = zip_ref.namelist()
|
|
||||||
for name, id in OG_IDS.items():
|
|
||||||
if id == 'BASH':
|
|
||||||
continue # пропускаем BASH
|
|
||||||
|
|
||||||
current_fact = replace_id_in_path(excel_fact_template, id)
|
|
||||||
fact_candidates = [f for f in file_list if current_fact in f]
|
|
||||||
if len(fact_candidates) == 1:
|
|
||||||
print(f'Загрузка {current_fact}')
|
|
||||||
with zip_ref.open(fact_candidates[0]) as excel_file:
|
|
||||||
pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
|
||||||
print(f"✅ Факт загружен: {current_fact}")
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Файл не найден (Факт): {current_fact}")
|
|
||||||
pm_dict['facts'][id] = None
|
|
||||||
|
|
||||||
current_plan = replace_id_in_path(excel_plan_template, id)
|
|
||||||
plan_candidates = [f for f in file_list if current_plan in f]
|
|
||||||
if len(plan_candidates) == 1:
|
|
||||||
print(f'Загрузка {current_plan}')
|
|
||||||
with zip_ref.open(plan_candidates[0]) as excel_file:
|
|
||||||
pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
|
||||||
print(f"✅ План загружен: {current_plan}")
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Файл не найден (План): {current_plan}")
|
|
||||||
pm_dict['plans'][id] = None
|
|
||||||
|
|
||||||
return pm_dict
|
|
||||||
|
|
||||||
def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None):
|
|
||||||
''' Служебная функция получения значения по коду и столбцу '''
|
|
||||||
row_index = code
|
|
||||||
|
|
||||||
mask_value = df_svodka.iloc[0] == code
|
|
||||||
if search_value is None:
|
|
||||||
mask_name = df_svodka.columns != 'Итого'
|
|
||||||
else:
|
|
||||||
mask_name = df_svodka.columns == search_value
|
|
||||||
|
|
||||||
# Убедимся, что маски совпадают по длине
|
|
||||||
if len(mask_value) != len(mask_name):
|
|
||||||
raise ValueError(
|
|
||||||
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
final_mask = mask_value & mask_name # булевая маска по позициям столбцов
|
|
||||||
col_positions = final_mask.values # numpy array или Series булевых значений
|
|
||||||
|
|
||||||
if not final_mask.any():
|
|
||||||
print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
if row_index in df_svodka.index:
|
|
||||||
# Получаем позицию строки
|
|
||||||
row_loc = df_svodka.index.get_loc(row_index)
|
|
||||||
|
|
||||||
# Извлекаем значения по позициям столбцов
|
|
||||||
values = df_svodka.iloc[row_loc, col_positions]
|
|
||||||
|
|
||||||
# Преобразуем в числовой формат
|
|
||||||
numeric_values = pd.to_numeric(values, errors='coerce')
|
|
||||||
|
|
||||||
# Агрегация данных (NaN игнорируются)
|
|
||||||
if search_value is None:
|
|
||||||
return numeric_values
|
|
||||||
else:
|
|
||||||
return numeric_values.iloc[0]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
|
|
||||||
''' Служебная функция получения данных по одному ОГ '''
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# Безопасно получаем данные, проверяя их наличие
|
|
||||||
fact_df = pm_dict.get('facts', {}).get(id) if 'facts' in pm_dict else None
|
|
||||||
plan_df = pm_dict.get('plans', {}).get(id) if 'plans' in pm_dict else None
|
|
||||||
|
|
||||||
# Определяем, какие столбцы из какого датафрейма брать
|
|
||||||
for col in columns:
|
|
||||||
col_result = {}
|
|
||||||
|
|
||||||
if col in ['ПП', 'БП']:
|
|
||||||
if plan_df is None:
|
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
|
|
||||||
col_result = {code: None for code in codes}
|
|
||||||
else:
|
|
||||||
for code in codes:
|
|
||||||
val = self.get_svodka_value(plan_df, code, col, search_value)
|
|
||||||
col_result[code] = val
|
|
||||||
|
|
||||||
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
|
||||||
if fact_df is None:
|
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
|
|
||||||
col_result = {code: None for code in codes}
|
|
||||||
else:
|
|
||||||
for code in codes:
|
|
||||||
val = self.get_svodka_value(fact_df, code, col, search_value)
|
|
||||||
col_result[code] = val
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
|
||||||
col_result = {code: None for code in codes}
|
|
||||||
|
|
||||||
result[col] = col_result
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
|
|
||||||
''' Служебная функция агрегации данные по всем ОГ '''
|
|
||||||
total_result = {}
|
|
||||||
|
|
||||||
for name, og_id in OG_IDS.items():
|
|
||||||
if og_id == 'BASH':
|
|
||||||
continue
|
|
||||||
|
|
||||||
# print(f"📊 Обработка: {name} ({og_id})")
|
|
||||||
try:
|
|
||||||
data = self.get_svodka_og(
|
|
||||||
pm_dict,
|
|
||||||
og_id,
|
|
||||||
codes,
|
|
||||||
columns,
|
|
||||||
search_value
|
|
||||||
)
|
|
||||||
total_result[og_id] = data
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
|
|
||||||
total_result[og_id] = None
|
|
||||||
|
|
||||||
return total_result
|
|
||||||
|
|
||||||
# Убираем старый метод get_value, так как он теперь в базовом классе
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import zipfile
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
from core.ports import ParserPort
|
from core.ports import ParserPort
|
||||||
from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json
|
from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
|
||||||
|
|
||||||
|
|
||||||
class SvodkaPMParser(ParserPort):
|
class SvodkaPMParser(ParserPort):
|
||||||
@@ -16,140 +9,92 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
name = "Сводки ПМ"
|
name = "Сводки ПМ"
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._register_default_getters()
|
|
||||||
|
|
||||||
def _register_default_getters(self):
|
def _register_default_getters(self):
|
||||||
"""Регистрация геттеров для Сводки ПМ"""
|
"""Регистрация геттеров по умолчанию"""
|
||||||
self.register_getter(
|
self.register_getter(
|
||||||
name="single_og",
|
name="single_og",
|
||||||
method=self._get_single_og,
|
method=self._get_single_og,
|
||||||
required_params=["id", "codes", "columns"],
|
required_params=["id", "codes", "columns"],
|
||||||
optional_params=["search"],
|
optional_params=["search"],
|
||||||
description="Получение данных по одному ОГ из сводки ПМ"
|
description="Получение данных по одному ОГ"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.register_getter(
|
self.register_getter(
|
||||||
name="total_ogs",
|
name="total_ogs",
|
||||||
method=self._get_total_ogs,
|
method=self._get_total_ogs,
|
||||||
required_params=["codes", "columns"],
|
required_params=["codes", "columns"],
|
||||||
optional_params=["search"],
|
optional_params=["search"],
|
||||||
description="Получение данных по всем ОГ из сводки ПМ"
|
description="Получение данных по всем ОГ"
|
||||||
)
|
)
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
def _get_single_og(self, params: dict):
|
||||||
"""Парсинг ZIP архива со сводками ПМ и возврат словаря с DataFrame"""
|
"""Получение данных по одному ОГ (обертка для совместимости)"""
|
||||||
# Проверяем расширение файла
|
og_id = params["id"]
|
||||||
if not file_path.lower().endswith('.zip'):
|
codes = params["codes"]
|
||||||
raise ValueError(f"Ожидается ZIP архив: {file_path}")
|
columns = params["columns"]
|
||||||
|
search = params.get("search")
|
||||||
|
|
||||||
# Создаем временную директорию для разархивирования
|
if not isinstance(codes, list):
|
||||||
temp_dir = tempfile.mkdtemp()
|
raise ValueError("Поле 'codes' должно быть списком")
|
||||||
|
if not isinstance(columns, list):
|
||||||
|
raise ValueError("Поле 'columns' должно быть списком")
|
||||||
|
|
||||||
try:
|
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику
|
||||||
# Разархивируем файл
|
# TODO: Переделать под новую архитектуру
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
return self.get_svodka_og(self.df, og_id, codes, columns, search)
|
||||||
zip_ref.extractall(temp_dir)
|
|
||||||
print(f"📦 Архив разархивирован в: {temp_dir}")
|
|
||||||
|
|
||||||
# Посмотрим, что находится в архиве
|
|
||||||
print(f"🔍 Содержимое архива:")
|
|
||||||
for root, dirs, files in os.walk(temp_dir):
|
|
||||||
level = root.replace(temp_dir, '').count(os.sep)
|
|
||||||
indent = ' ' * 2 * level
|
|
||||||
print(f"{indent}{os.path.basename(root)}/")
|
|
||||||
subindent = ' ' * 2 * (level + 1)
|
|
||||||
for file in files:
|
|
||||||
print(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)
|
|
||||||
|
|
||||||
print(f"📊 Найдено файлов факта: {len(fact_files)}")
|
|
||||||
print(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:
|
|
||||||
print(f'📊 Загрузка факта: {fact_file} (ОГ: {og_id})')
|
|
||||||
df_pm_facts[og_id] = self._parse_svodka_pm(fact_file, 'Сводка Нефтепереработка')
|
|
||||||
print(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:
|
|
||||||
print(f'📊 Загрузка плана: {plan_file} (ОГ: {og_id})')
|
|
||||||
df_pm_plans[og_id] = self._parse_svodka_pm(plan_file, 'Сводка Нефтепереработка')
|
|
||||||
print(f"✅ План загружен для {og_id}")
|
|
||||||
|
|
||||||
# Инициализируем None для ОГ, для которых файлы не найдены
|
|
||||||
for og_id in SINGLE_OGS:
|
|
||||||
if og_id == 'BASH':
|
|
||||||
continue
|
|
||||||
if og_id not in df_pm_facts:
|
|
||||||
df_pm_facts[og_id] = None
|
|
||||||
if og_id not in df_pm_plans:
|
|
||||||
df_pm_plans[og_id] = None
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_total_ogs(self, params: dict):
|
||||||
# Возвращаем словарь с данными (как в оригинале)
|
"""Получение данных по всем ОГ (обертка для совместимости)"""
|
||||||
result = {
|
codes = params["codes"]
|
||||||
'df_pm_facts': df_pm_facts,
|
columns = params["columns"]
|
||||||
'df_pm_plans': df_pm_plans
|
search = params.get("search")
|
||||||
}
|
|
||||||
|
if not isinstance(codes, list):
|
||||||
print(f"🎯 Обработано ОГ: {len([k for k, v in df_pm_facts.items() if v is not None])} факт, {len([k for k, v in df_pm_plans.items() if v is not None])} план")
|
raise ValueError("Поле 'codes' должно быть списком")
|
||||||
|
if not isinstance(columns, list):
|
||||||
return result
|
raise ValueError("Поле 'columns' должно быть списком")
|
||||||
|
|
||||||
finally:
|
# TODO: Переделать под новую архитектуру
|
||||||
# Удаляем временную директорию
|
return self.get_svodka_total(self.df, codes, columns, search)
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
print(f"🗑️ Временная директория удалена: {temp_dir}")
|
|
||||||
|
|
||||||
def _parse_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame:
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
"""Парсинг отчетов одного ОГ для БП, ПП и факта"""
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
try:
|
# Сохраняем DataFrame для использования в геттерах
|
||||||
# Автоопределение header_num, если не передан
|
self.df = self.parse_svodka_pm_files(file_path, params)
|
||||||
if header_num is None:
|
return self.df
|
||||||
header_num = find_header_row(file_path, sheet_name, search_value="Итого")
|
|
||||||
|
|
||||||
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
|
||||||
df_probe = pd.read_excel(
|
"""Определения индекса заголовка в excel по ключевому слову"""
|
||||||
file_path,
|
# Читаем первые max_rows строк без заголовков
|
||||||
sheet_name=sheet_name,
|
df_temp = pd.read_excel(
|
||||||
header=header_num,
|
file,
|
||||||
usecols=None,
|
sheet_name=sheet,
|
||||||
nrows=2,
|
header=None,
|
||||||
engine='openpyxl' # Явно указываем движок
|
nrows=max_rows
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Ошибка при чтении файла {file_path}: {str(e)}")
|
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||||
|
for idx, row in df_temp.iterrows():
|
||||||
|
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
||||||
|
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
||||||
|
return idx # 0-based index — то, что нужно для header=
|
||||||
|
|
||||||
|
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
||||||
|
|
||||||
|
def parse_svodka_pm(self, file, sheet, header_num=None):
|
||||||
|
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
|
||||||
|
# Автоопределение header_num, если не передан
|
||||||
|
if header_num is None:
|
||||||
|
header_num = self.find_header_row(file, sheet, search_value="Итого")
|
||||||
|
|
||||||
|
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
||||||
|
df_probe = pd.read_excel(
|
||||||
|
file,
|
||||||
|
sheet_name=sheet,
|
||||||
|
header=header_num,
|
||||||
|
usecols=None,
|
||||||
|
nrows=2,
|
||||||
|
)
|
||||||
|
|
||||||
if df_probe.shape[0] == 0:
|
if df_probe.shape[0] == 0:
|
||||||
raise ValueError("Файл пуст или не содержит данных.")
|
raise ValueError("Файл пуст или не содержит данных.")
|
||||||
@@ -162,15 +107,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_path,
|
file,
|
||||||
sheet_name=sheet_name,
|
sheet_name=sheet,
|
||||||
header=header_num,
|
header=header_num,
|
||||||
usecols=None,
|
usecols=None,
|
||||||
index_col=None,
|
index_col=None
|
||||||
engine='openpyxl' # Явно указываем движок
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if indicator_col_name not in df_full.columns:
|
if indicator_col_name not in df_full.columns:
|
||||||
@@ -187,18 +132,19 @@ class SvodkaPMParser(ParserPort):
|
|||||||
itogo_idx = header_list.index("Итого")
|
itogo_idx = header_list.index("Итого")
|
||||||
num_cols_needed = itogo_idx + 2
|
num_cols_needed = itogo_idx + 2
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
print('Столбец "Итого" не найден. Оставляем все столбцы.')
|
||||||
num_cols_needed = len(header_list)
|
num_cols_needed = len(header_list)
|
||||||
|
|
||||||
num_cols_needed = min(num_cols_needed, len(header_list))
|
num_cols_needed = min(num_cols_needed, len(header_list))
|
||||||
df_final = df_full.iloc[:, :num_cols_needed]
|
df_final = df_full.iloc[:, :num_cols_needed]
|
||||||
|
|
||||||
# Удаление полностью пустых столбцов
|
# === Удаление полностью пустых столбцов ===
|
||||||
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
|
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
|
||||||
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
|
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
|
||||||
non_empty_mask = df_clean.notna().any()
|
non_empty_mask = df_clean.notna().any()
|
||||||
df_final = df_final.loc[:, non_empty_mask]
|
df_final = df_final.loc[:, non_empty_mask]
|
||||||
|
|
||||||
# Обработка заголовков: Unnamed и "Итого" → "Итого"
|
# === Обработка заголовков: Unnamed и "Итого" → "Итого" ===
|
||||||
new_columns = []
|
new_columns = []
|
||||||
last_good_name = None
|
last_good_name = None
|
||||||
for col in df_final.columns:
|
for col in df_final.columns:
|
||||||
@@ -207,152 +153,109 @@ class SvodkaPMParser(ParserPort):
|
|||||||
# Проверяем, является ли колонка пустой/некорректной
|
# Проверяем, является ли колонка пустой/некорректной
|
||||||
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
|
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
|
||||||
|
|
||||||
# Проверяем, начинается ли на "Итого"
|
if is_empty_or_unnamed:
|
||||||
if col_str.startswith('Итого'):
|
# Если это пустая колонка, используем последнее хорошее имя
|
||||||
current_name = 'Итого'
|
if last_good_name:
|
||||||
last_good_name = current_name
|
new_columns.append(last_good_name)
|
||||||
new_columns.append(current_name)
|
else:
|
||||||
elif is_empty_or_unnamed:
|
# Если нет хорошего имени, пропускаем
|
||||||
# Используем последнее хорошее имя
|
continue
|
||||||
new_columns.append(last_good_name)
|
|
||||||
else:
|
else:
|
||||||
# Имя, полученное из excel
|
# Это хорошая колонка
|
||||||
last_good_name = col_str
|
last_good_name = col_str
|
||||||
new_columns.append(col_str)
|
new_columns.append(col_str)
|
||||||
|
|
||||||
|
# Применяем новые заголовки
|
||||||
df_final.columns = new_columns
|
df_final.columns = new_columns
|
||||||
|
|
||||||
return df_final
|
return df_final
|
||||||
|
|
||||||
def _get_svodka_value(self, df_svodka: pd.DataFrame, og_id: str, code: int, search_value: Optional[str] = None):
|
def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict:
|
||||||
"""Служебная функция для простой выборке по сводке"""
|
"""Парсинг ZIP архива со сводками ПМ"""
|
||||||
print(f"🔍 DEBUG: Ищем код '{code}' для ОГ '{og_id}' в DataFrame с {len(df_svodka)} строками")
|
import zipfile
|
||||||
print(f"🔍 DEBUG: Первая строка данных: {df_svodka.iloc[0].tolist()}")
|
pm_dict = {
|
||||||
print(f"🔍 DEBUG: Доступные индексы: {list(df_svodka.index)}")
|
"facts": {},
|
||||||
print(f"🔍 DEBUG: Доступные столбцы: {list(df_svodka.columns)}")
|
"plans": {}
|
||||||
|
}
|
||||||
|
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
||||||
|
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
file_list = zip_ref.namelist()
|
||||||
|
for name, id in OG_IDS.items():
|
||||||
|
if id == 'BASH':
|
||||||
|
continue # пропускаем BASH
|
||||||
|
|
||||||
# Проверяем, есть ли код в индексе
|
current_fact = replace_id_in_path(excel_fact_template, id)
|
||||||
if code not in df_svodka.index:
|
fact_candidates = [f for f in file_list if current_fact in f]
|
||||||
print(f"⚠️ Код '{code}' не найден в индексе")
|
if len(fact_candidates) == 1:
|
||||||
return 0
|
print(f'Загрузка {current_fact}')
|
||||||
|
with zip_ref.open(fact_candidates[0]) as excel_file:
|
||||||
|
pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
||||||
|
print(f"✅ Факт загружен: {current_fact}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Файл не найден (Факт): {current_fact}")
|
||||||
|
pm_dict['facts'][id] = None
|
||||||
|
|
||||||
# Получаем позицию строки с кодом
|
current_plan = replace_id_in_path(excel_plan_template, id)
|
||||||
code_row_loc = df_svodka.index.get_loc(code)
|
plan_candidates = [f for f in file_list if current_plan in f]
|
||||||
print(f"🔍 DEBUG: Код '{code}' в позиции {code_row_loc}")
|
if len(plan_candidates) == 1:
|
||||||
|
print(f'Загрузка {current_plan}')
|
||||||
|
with zip_ref.open(plan_candidates[0]) as excel_file:
|
||||||
|
pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
||||||
|
print(f"✅ План загружен: {current_plan}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Файл не найден (План): {current_plan}")
|
||||||
|
pm_dict['plans'][id] = None
|
||||||
|
|
||||||
# Определяем позиции для поиска
|
return pm_dict
|
||||||
|
|
||||||
|
def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None):
|
||||||
|
''' Служебная функция получения значения по коду и столбцу '''
|
||||||
|
row_index = code
|
||||||
|
|
||||||
|
mask_value = df_svodka.iloc[0] == code
|
||||||
if search_value is None:
|
if search_value is None:
|
||||||
# Ищем все позиции кроме "Итого" и None (первый столбец с заголовком)
|
mask_name = df_svodka.columns != 'Итого'
|
||||||
target_positions = []
|
|
||||||
for i, col_name in enumerate(df_svodka.iloc[0]):
|
|
||||||
if col_name != 'Итого' and col_name is not None:
|
|
||||||
target_positions.append(i)
|
|
||||||
else:
|
else:
|
||||||
# Ищем позиции в первой строке, где есть нужное название
|
mask_name = df_svodka.columns == search_value
|
||||||
target_positions = []
|
|
||||||
for i, col_name in enumerate(df_svodka.iloc[0]):
|
|
||||||
if col_name == search_value:
|
|
||||||
target_positions.append(i)
|
|
||||||
|
|
||||||
print(f"🔍 DEBUG: Найдены позиции для '{search_value}': {target_positions[:5]}...")
|
|
||||||
print(f"🔍 DEBUG: Позиции в первой строке: {target_positions[:5]}...")
|
|
||||||
|
|
||||||
print(f"🔍 DEBUG: Ищем столбцы с названием '{search_value}'")
|
# Убедимся, что маски совпадают по длине
|
||||||
print(f"🔍 DEBUG: Целевые позиции: {target_positions[:10]}...")
|
if len(mask_value) != len(mask_name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
|
||||||
|
)
|
||||||
|
|
||||||
if not target_positions:
|
final_mask = mask_value & mask_name # булевая маска по позициям столбцов
|
||||||
print(f"⚠️ Позиции '{search_value}' не найдены")
|
col_positions = final_mask.values # numpy array или Series булевых значений
|
||||||
|
|
||||||
|
if not final_mask.any():
|
||||||
|
print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Извлекаем значения из найденных позиций
|
|
||||||
values = []
|
|
||||||
for pos in target_positions:
|
|
||||||
# Берем значение из пересечения строки с кодом и позиции столбца
|
|
||||||
value = df_svodka.iloc[code_row_loc, pos]
|
|
||||||
|
|
||||||
# Если это Series, берем первое значение
|
|
||||||
if isinstance(value, pd.Series):
|
|
||||||
if len(value) > 0:
|
|
||||||
# Берем первое не-NaN значение
|
|
||||||
first_valid = value.dropna().iloc[0] if not value.dropna().empty else 0
|
|
||||||
values.append(first_valid)
|
|
||||||
else:
|
|
||||||
values.append(0)
|
|
||||||
else:
|
|
||||||
values.append(value)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Преобразуем в числовой формат
|
|
||||||
numeric_values = pd.to_numeric(values, errors='coerce')
|
|
||||||
print(f"🔍 DEBUG: Числовые значения (первые 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)
|
|
||||||
|
|
||||||
print(f"🔍 DEBUG: Ручное преобразование (первые 5): {manual_values[:5]}")
|
|
||||||
numeric_values = pd.Series(manual_values)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Ошибка при ручном преобразовании: {e}")
|
|
||||||
# Используем исходные значения
|
|
||||||
numeric_values = pd.Series([0 if pd.isna(v) or v is None else v for v in values])
|
|
||||||
|
|
||||||
# Агрегация данных (NaN игнорируются)
|
|
||||||
if search_value is None:
|
|
||||||
# Возвращаем массив всех значений (игнорируя NaN)
|
|
||||||
if len(numeric_values) > 0:
|
|
||||||
# Фильтруем NaN значения и возвращаем как список
|
|
||||||
valid_values = numeric_values.dropna()
|
|
||||||
if len(valid_values) > 0:
|
|
||||||
return valid_values.tolist()
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
else:
|
else:
|
||||||
# Возвращаем массив всех значений (игнорируя NaN)
|
if row_index in df_svodka.index:
|
||||||
if len(numeric_values) > 0:
|
# Получаем позицию строки
|
||||||
# Фильтруем NaN значения и возвращаем как список
|
row_loc = df_svodka.index.get_loc(row_index)
|
||||||
valid_values = numeric_values.dropna()
|
|
||||||
if len(valid_values) > 0:
|
|
||||||
return valid_values.tolist()
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None):
|
# Извлекаем значения по позициям столбцов
|
||||||
"""Служебная функция получения данных по одному ОГ"""
|
values = df_svodka.iloc[row_loc, col_positions]
|
||||||
|
|
||||||
|
# Преобразуем в числовой формат
|
||||||
|
numeric_values = pd.to_numeric(values, errors='coerce')
|
||||||
|
|
||||||
|
# Агрегация данных (NaN игнорируются)
|
||||||
|
if search_value is None:
|
||||||
|
return numeric_values
|
||||||
|
else:
|
||||||
|
return numeric_values.iloc[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
|
||||||
|
''' Служебная функция получения данных по одному ОГ '''
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
# Получаем данные из сохраненных словарей (через self.df)
|
fact_df = pm_dict['facts'][id]
|
||||||
if not hasattr(self, 'df') or self.df is None:
|
plan_df = pm_dict['plans'][id]
|
||||||
print("❌ Данные не загружены. Сначала загрузите 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)
|
|
||||||
|
|
||||||
print(f"🔍 ===== НАЧАЛО ОБРАБОТКИ ОГ {og_id} =====")
|
|
||||||
print(f"🔍 Коды: {codes}")
|
|
||||||
print(f"🔍 Столбцы: {columns}")
|
|
||||||
print(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:
|
||||||
@@ -360,91 +263,49 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
if col in ['ПП', 'БП']:
|
if col in ['ПП', 'БП']:
|
||||||
if plan_df is None:
|
if plan_df is None:
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}")
|
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
|
||||||
else:
|
else:
|
||||||
print(f"🔍 DEBUG: ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====")
|
|
||||||
for code in codes:
|
for code in codes:
|
||||||
print(f"🔍 DEBUG: --- Код {code} для {col} ---")
|
val = self.get_svodka_value(plan_df, code, col, search_value)
|
||||||
val = self._get_svodka_value(plan_df, og_id, code, col)
|
col_result[code] = val
|
||||||
col_result[str(code)] = val
|
|
||||||
print(f"🔍 DEBUG: ===== ЗАВЕРШИЛИ ОБРАБОТКУ '{col}' =====")
|
|
||||||
|
|
||||||
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
||||||
if fact_df is None:
|
if fact_df is None:
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}")
|
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
|
||||||
else:
|
else:
|
||||||
for code in codes:
|
for code in codes:
|
||||||
val = self._get_svodka_value(fact_df, og_id, code, col)
|
val = self.get_svodka_value(fact_df, code, col, search_value)
|
||||||
col_result[str(code)] = val
|
col_result[code] = val
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
||||||
col_result = {str(code): None for code in codes}
|
col_result = {code: None for code in codes}
|
||||||
|
|
||||||
result[col] = col_result
|
result[col] = col_result
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_single_og(self, params: Dict[str, Any]) -> str:
|
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
|
||||||
"""API функция для получения данных по одному ОГ"""
|
''' Служебная функция агрегации данные по всем ОГ '''
|
||||||
# Если на входе строка — парсим как JSON
|
|
||||||
if isinstance(params, str):
|
|
||||||
try:
|
|
||||||
params = json.loads(params)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ValueError(f"Некорректный JSON: {e}")
|
|
||||||
|
|
||||||
# Проверяем структуру
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
|
||||||
|
|
||||||
og_id = params.get("id")
|
|
||||||
codes = params.get("codes")
|
|
||||||
columns = params.get("columns")
|
|
||||||
search = params.get("search")
|
|
||||||
|
|
||||||
if not isinstance(codes, list):
|
|
||||||
raise ValueError("Поле 'codes' должно быть списком")
|
|
||||||
if not isinstance(columns, list):
|
|
||||||
raise ValueError("Поле 'columns' должно быть списком")
|
|
||||||
|
|
||||||
data = self._get_svodka_og(og_id, codes, columns, search)
|
|
||||||
json_result = data_to_json(data)
|
|
||||||
return json_result
|
|
||||||
|
|
||||||
def _get_total_ogs(self, params: Dict[str, Any]) -> str:
|
|
||||||
"""API функция для получения данных по всем ОГ"""
|
|
||||||
# Если на входе строка — парсим как JSON
|
|
||||||
if isinstance(params, str):
|
|
||||||
try:
|
|
||||||
params = json.loads(params)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ValueError(f"❌Некорректный JSON: {e}")
|
|
||||||
|
|
||||||
# Проверяем структуру
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
|
||||||
|
|
||||||
codes = params.get("codes")
|
|
||||||
columns = params.get("columns")
|
|
||||||
search = params.get("search")
|
|
||||||
|
|
||||||
if not isinstance(codes, list):
|
|
||||||
raise ValueError("Поле 'codes' должно быть списком")
|
|
||||||
if not isinstance(columns, list):
|
|
||||||
raise ValueError("Поле 'columns' должно быть списком")
|
|
||||||
|
|
||||||
total_result = {}
|
total_result = {}
|
||||||
|
|
||||||
for og_id in SINGLE_OGS:
|
for name, og_id in OG_IDS.items():
|
||||||
if og_id == 'BASH':
|
if og_id == 'BASH':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# print(f"📊 Обработка: {name} ({og_id})")
|
||||||
try:
|
try:
|
||||||
data = self._get_svodka_og(og_id, codes, columns, search)
|
data = self.get_svodka_og(
|
||||||
|
pm_dict,
|
||||||
|
og_id,
|
||||||
|
codes,
|
||||||
|
columns,
|
||||||
|
search_value
|
||||||
|
)
|
||||||
total_result[og_id] = data
|
total_result[og_id] = data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при обработке {og_id}: {e}")
|
print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
|
||||||
total_result[og_id] = None
|
total_result[og_id] = None
|
||||||
|
|
||||||
json_result = data_to_json(total_result)
|
return total_result
|
||||||
return json_result
|
|
||||||
|
# Убираем старый метод get_value, так как он теперь в базовом классе
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from functools import lru_cache
|
|||||||
import json
|
import json
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import os
|
|
||||||
|
|
||||||
OG_IDS = {
|
OG_IDS = {
|
||||||
"Комсомольский НПЗ": "KNPZ",
|
"Комсомольский НПЗ": "KNPZ",
|
||||||
@@ -23,37 +22,8 @@ OG_IDS = {
|
|||||||
"Красноленинский НПЗ": "KLNPZ",
|
"Красноленинский НПЗ": "KLNPZ",
|
||||||
"Пурнефтепереработка": "PurNP",
|
"Пурнефтепереработка": "PurNP",
|
||||||
"ЯНОС": "YANOS",
|
"ЯНОС": "YANOS",
|
||||||
"Уфанефтехим": "UNH",
|
|
||||||
"РНПК": "RNPK",
|
|
||||||
"КмсНПЗ": "KNPZ",
|
|
||||||
"АНХК": "ANHK",
|
|
||||||
"НК НПЗ": "NovKuybNPZ",
|
|
||||||
"КНПЗ": "KuybNPZ",
|
|
||||||
"СНПЗ": "CyzNPZ",
|
|
||||||
"Нижневаторское НПО": "NVNPO",
|
|
||||||
"ПурНП": "PurNP",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SINGLE_OGS = [
|
|
||||||
"KNPZ",
|
|
||||||
"ANHK",
|
|
||||||
"AchNPZ",
|
|
||||||
"BASH",
|
|
||||||
"UNPZ",
|
|
||||||
"UNH",
|
|
||||||
"NOV",
|
|
||||||
"NovKuybNPZ",
|
|
||||||
"KuybNPZ",
|
|
||||||
"CyzNPZ",
|
|
||||||
"TuapsNPZ",
|
|
||||||
"SNPZ",
|
|
||||||
"RNPK",
|
|
||||||
"NVNPO",
|
|
||||||
"KLNPZ",
|
|
||||||
"PurNP",
|
|
||||||
"YANOS",
|
|
||||||
]
|
|
||||||
|
|
||||||
SNPZ_IDS = {
|
SNPZ_IDS = {
|
||||||
"Висбрекинг": "SNPZ.VISB",
|
"Висбрекинг": "SNPZ.VISB",
|
||||||
"Изомеризация": "SNPZ.IZOM",
|
"Изомеризация": "SNPZ.IZOM",
|
||||||
@@ -70,18 +40,7 @@ SNPZ_IDS = {
|
|||||||
|
|
||||||
|
|
||||||
def replace_id_in_path(file_path, new_id):
|
def replace_id_in_path(file_path, new_id):
|
||||||
# Заменяем 'ID' на новое значение
|
return file_path.replace('ID', str(new_id))
|
||||||
modified_path = file_path.replace('ID', str(new_id)) + '.xlsx'
|
|
||||||
|
|
||||||
# Проверяем, существует ли файл
|
|
||||||
if not os.path.exists(modified_path):
|
|
||||||
# Меняем расширение на .xlsm
|
|
||||||
directory, filename = os.path.split(modified_path)
|
|
||||||
name, ext = os.path.splitext(filename)
|
|
||||||
new_filename = name + '.xlsm'
|
|
||||||
modified_path = os.path.join(directory, new_filename)
|
|
||||||
|
|
||||||
return modified_path
|
|
||||||
|
|
||||||
|
|
||||||
def get_table_name(exel):
|
def get_table_name(exel):
|
||||||
@@ -150,25 +109,6 @@ def get_id_by_name(name, dictionary):
|
|||||||
return best_match
|
return best_match
|
||||||
|
|
||||||
|
|
||||||
def find_header_row(file, sheet, search_value="Итого", max_rows=50):
|
|
||||||
''' Определения индекса заголовка в exel по ключевому слову '''
|
|
||||||
# Читаем первые max_rows строк без заголовков
|
|
||||||
df_temp = pd.read_excel(
|
|
||||||
file,
|
|
||||||
sheet_name=sheet,
|
|
||||||
header=None,
|
|
||||||
nrows=max_rows
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
|
||||||
for idx, row in df_temp.iterrows():
|
|
||||||
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
|
||||||
print(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.
|
||||||
@@ -213,18 +153,11 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
|||||||
|
|
||||||
# --- рекурсия по dict и list ---
|
# --- рекурсия по dict и list ---
|
||||||
elif isinstance(obj, dict):
|
elif isinstance(obj, dict):
|
||||||
# Обрабатываем только значения, ключи оставляем как строки
|
return {
|
||||||
converted = {}
|
key: convert_obj(value)
|
||||||
for k, v in obj.items():
|
for key, value in obj.items()
|
||||||
if is_nan_like(k):
|
if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON)
|
||||||
continue # ключи не могут быть null в JSON
|
}
|
||||||
# Превращаем ключ в строку, но не пытаемся интерпретировать как число
|
|
||||||
key_str = str(k)
|
|
||||||
converted[key_str] = convert_obj(v) # только значение проходит через convert_obj
|
|
||||||
# Если все значения 0.0 — считаем, что данных нет, т.к. возможно ожидается массив.
|
|
||||||
if converted and all(v == 0.0 for v in converted.values()):
|
|
||||||
return None
|
|
||||||
return converted
|
|
||||||
|
|
||||||
elif isinstance(obj, list):
|
elif isinstance(obj, list):
|
||||||
return [convert_obj(item) for item in obj]
|
return [convert_obj(item) for item in obj]
|
||||||
@@ -242,6 +175,7 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cleaned_data = convert_obj(data)
|
cleaned_data = convert_obj(data)
|
||||||
return json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
||||||
|
return cleaned_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")
|
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ async def get_svodka_pm_single_og(
|
|||||||
try:
|
try:
|
||||||
# Создаем запрос
|
# Создаем запрос
|
||||||
request_dict = request_data.model_dump()
|
request_dict = request_data.model_dump()
|
||||||
request_dict['mode'] = 'single_og'
|
request_dict['mode'] = 'single'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='svodka_pm',
|
report_type='svodka_pm',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -377,7 +377,7 @@ async def get_svodka_pm_total_ogs(
|
|||||||
try:
|
try:
|
||||||
# Создаем запрос
|
# Создаем запрос
|
||||||
request_dict = request_data.model_dump()
|
request_dict = request_data.model_dump()
|
||||||
request_dict['mode'] = 'total_ogs'
|
request_dict['mode'] = 'total'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='svodka_pm',
|
report_type='svodka_pm',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -400,40 +400,40 @@ async def get_svodka_pm_total_ogs(
|
|||||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/svodka_pm/get_data", tags=[SvodkaPMParser.name])
|
# @app.post("/svodka_pm/get_data", tags=[SvodkaPMParser.name])
|
||||||
async def get_svodka_pm_data(
|
# async def get_svodka_pm_data(
|
||||||
request_data: dict
|
# request_data: dict
|
||||||
):
|
# ):
|
||||||
report_service = get_report_service()
|
# report_service = get_report_service()
|
||||||
"""
|
# """
|
||||||
Получение данных из отчета сводки факта СарНПЗ
|
# Получение данных из отчета сводки факта СарНПЗ
|
||||||
|
|
||||||
- indicator_id: ID индикатора
|
# - indicator_id: ID индикатора
|
||||||
- code: Код для поиска
|
# - code: Код для поиска
|
||||||
- search_value: Опциональное значение для поиска
|
# - search_value: Опциональное значение для поиска
|
||||||
"""
|
# """
|
||||||
try:
|
# try:
|
||||||
# Создаем запрос
|
# # Создаем запрос
|
||||||
request = DataRequest(
|
# request = DataRequest(
|
||||||
report_type='svodka_pm',
|
# report_type='svodka_pm',
|
||||||
get_params=request_data
|
# get_params=request_data
|
||||||
)
|
# )
|
||||||
|
|
||||||
# Получаем данные
|
# # Получаем данные
|
||||||
result = report_service.get_data(request)
|
# result = report_service.get_data(request)
|
||||||
|
|
||||||
if result.success:
|
# if result.success:
|
||||||
return {
|
# return {
|
||||||
"success": True,
|
# "success": True,
|
||||||
"data": result.data
|
# "data": result.data
|
||||||
}
|
# }
|
||||||
else:
|
# else:
|
||||||
raise HTTPException(status_code=404, detail=result.message)
|
# raise HTTPException(status_code=404, detail=result.message)
|
||||||
|
|
||||||
except HTTPException:
|
# except HTTPException:
|
||||||
raise
|
# raise
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/svodka_ca/upload", tags=[SvodkaCAParser.name],
|
@app.post("/svodka_ca/upload", tags=[SvodkaCAParser.name],
|
||||||
@@ -610,38 +610,38 @@ async def get_svodka_ca_data(
|
|||||||
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/monitoring_fuel/get_data", tags=[MonitoringFuelParser.name])
|
# @app.post("/monitoring_fuel/get_data", tags=[MonitoringFuelParser.name])
|
||||||
async def get_monitoring_fuel_data(
|
# async def get_monitoring_fuel_data(
|
||||||
request_data: dict
|
# request_data: dict
|
||||||
):
|
# ):
|
||||||
report_service = get_report_service()
|
# report_service = get_report_service()
|
||||||
"""
|
# """
|
||||||
Получение данных из отчета мониторинга топлива
|
# Получение данных из отчета мониторинга топлива
|
||||||
|
|
||||||
- column: Название колонки для агрегации (normativ, total, total_svod)
|
# - column: Название колонки для агрегации (normativ, total, total_svod)
|
||||||
"""
|
# """
|
||||||
try:
|
# try:
|
||||||
# Создаем запрос
|
# # Создаем запрос
|
||||||
request = DataRequest(
|
# request = DataRequest(
|
||||||
report_type='monitoring_fuel',
|
# report_type='monitoring_fuel',
|
||||||
get_params=request_data
|
# get_params=request_data
|
||||||
)
|
# )
|
||||||
|
|
||||||
# Получаем данные
|
# # Получаем данные
|
||||||
result = report_service.get_data(request)
|
# result = report_service.get_data(request)
|
||||||
|
|
||||||
if result.success:
|
# if result.success:
|
||||||
return {
|
# return {
|
||||||
"success": True,
|
# "success": True,
|
||||||
"data": result.data
|
# "data": result.data
|
||||||
}
|
# }
|
||||||
else:
|
# else:
|
||||||
raise HTTPException(status_code=404, detail=result.message)
|
# raise HTTPException(status_code=404, detail=result.message)
|
||||||
|
|
||||||
except HTTPException:
|
# except HTTPException:
|
||||||
raise
|
# raise
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# @app.post("/monitoring_fuel/upload_directory", tags=[MonitoringFuelParser.name])
|
# @app.post("/monitoring_fuel/upload_directory", tags=[MonitoringFuelParser.name])
|
||||||
@@ -804,7 +804,7 @@ async def get_monitoring_fuel_total_by_columns(
|
|||||||
try:
|
try:
|
||||||
# Создаем запрос
|
# Создаем запрос
|
||||||
request_dict = request_data.model_dump()
|
request_dict = request_data.model_dump()
|
||||||
request_dict['mode'] = 'total_by_columns'
|
request_dict['mode'] = 'total'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='monitoring_fuel',
|
report_type='monitoring_fuel',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -849,7 +849,7 @@ async def get_monitoring_fuel_month_by_code(
|
|||||||
try:
|
try:
|
||||||
# Создаем запрос
|
# Создаем запрос
|
||||||
request_dict = request_data.model_dump()
|
request_dict = request_data.model_dump()
|
||||||
request_dict['mode'] = 'month_by_code'
|
request_dict['mode'] = 'month'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='monitoring_fuel',
|
report_type='monitoring_fuel',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,7 @@ class OGID(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class SvodkaPMSingleOGRequest(BaseModel):
|
class SvodkaPMSingleOGRequest(BaseModel):
|
||||||
id: str = Field(
|
id: OGID = Field(
|
||||||
...,
|
...,
|
||||||
description="Идентификатор МА для запрашиваемого ОГ",
|
description="Идентификатор МА для запрашиваемого ОГ",
|
||||||
example="SNPZ"
|
example="SNPZ"
|
||||||
|
|||||||
Binary file not shown.
@@ -1,140 +0,0 @@
|
|||||||
"""
|
|
||||||
Упрощенные утилиты для работы со схемами Pydantic
|
|
||||||
"""
|
|
||||||
from typing import List, Dict, Any, Type
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
|
|
||||||
def get_required_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
|
|
||||||
"""
|
|
||||||
Извлекает список обязательных полей из схемы Pydantic
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema_class: Класс схемы Pydantic
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Список имен обязательных полей
|
|
||||||
"""
|
|
||||||
required_fields = []
|
|
||||||
|
|
||||||
# Используем model_fields для Pydantic v2 или __fields__ для v1
|
|
||||||
if hasattr(schema_class, 'model_fields'):
|
|
||||||
fields = schema_class.model_fields
|
|
||||||
else:
|
|
||||||
fields = schema_class.__fields__
|
|
||||||
|
|
||||||
for field_name, field_info in fields.items():
|
|
||||||
# В Pydantic v2 есть метод is_required()
|
|
||||||
if hasattr(field_info, 'is_required'):
|
|
||||||
if field_info.is_required():
|
|
||||||
required_fields.append(field_name)
|
|
||||||
elif hasattr(field_info, 'required'):
|
|
||||||
if field_info.required:
|
|
||||||
required_fields.append(field_name)
|
|
||||||
else:
|
|
||||||
# Fallback для старых версий - проверяем наличие default
|
|
||||||
has_default = False
|
|
||||||
|
|
||||||
if hasattr(field_info, 'default'):
|
|
||||||
has_default = field_info.default is not ...
|
|
||||||
elif hasattr(field_info, 'default_factory'):
|
|
||||||
has_default = field_info.default_factory is not None
|
|
||||||
|
|
||||||
if not has_default:
|
|
||||||
required_fields.append(field_name)
|
|
||||||
|
|
||||||
return required_fields
|
|
||||||
|
|
||||||
|
|
||||||
def get_optional_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
|
|
||||||
"""
|
|
||||||
Извлекает список необязательных полей из схемы Pydantic
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema_class: Класс схемы Pydantic
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Список имен необязательных полей
|
|
||||||
"""
|
|
||||||
optional_fields = []
|
|
||||||
|
|
||||||
# Используем model_fields для Pydantic v2 или __fields__ для v1
|
|
||||||
if hasattr(schema_class, 'model_fields'):
|
|
||||||
fields = schema_class.model_fields
|
|
||||||
else:
|
|
||||||
fields = schema_class.__fields__
|
|
||||||
|
|
||||||
for field_name, field_info in fields.items():
|
|
||||||
# В Pydantic v2 есть метод is_required()
|
|
||||||
if hasattr(field_info, 'is_required'):
|
|
||||||
if not field_info.is_required():
|
|
||||||
optional_fields.append(field_name)
|
|
||||||
elif hasattr(field_info, 'required'):
|
|
||||||
if not field_info.required:
|
|
||||||
optional_fields.append(field_name)
|
|
||||||
else:
|
|
||||||
# Fallback для старых версий - проверяем наличие default
|
|
||||||
has_default = False
|
|
||||||
|
|
||||||
if hasattr(field_info, 'default'):
|
|
||||||
has_default = field_info.default is not ...
|
|
||||||
elif hasattr(field_info, 'default_factory'):
|
|
||||||
has_default = field_info.default_factory is not None
|
|
||||||
|
|
||||||
if has_default:
|
|
||||||
optional_fields.append(field_name)
|
|
||||||
|
|
||||||
return optional_fields
|
|
||||||
|
|
||||||
|
|
||||||
def register_getter_from_schema(parser_instance, getter_name: str, method: callable,
|
|
||||||
schema_class: Type[BaseModel], description: str = ""):
|
|
||||||
"""
|
|
||||||
Регистрирует геттер в парсере, используя схему Pydantic для определения параметров
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parser_instance: Экземпляр парсера
|
|
||||||
getter_name: Имя геттера
|
|
||||||
method: Метод для выполнения
|
|
||||||
schema_class: Класс схемы Pydantic
|
|
||||||
description: Описание геттера (если не указано, берется из docstring метода)
|
|
||||||
"""
|
|
||||||
# Извлекаем параметры из схемы
|
|
||||||
required_params = get_required_fields_from_schema(schema_class)
|
|
||||||
optional_params = get_optional_fields_from_schema(schema_class)
|
|
||||||
|
|
||||||
# Если описание не указано, берем из docstring метода
|
|
||||||
if not description:
|
|
||||||
description = inspect.getdoc(method) or ""
|
|
||||||
|
|
||||||
# Регистрируем геттер
|
|
||||||
parser_instance.register_getter(
|
|
||||||
name=getter_name,
|
|
||||||
method=method,
|
|
||||||
required_params=required_params,
|
|
||||||
optional_params=optional_params,
|
|
||||||
description=description
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_params_with_schema(params: Dict[str, Any], schema_class: Type[BaseModel]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Валидирует параметры с помощью схемы Pydantic
|
|
||||||
|
|
||||||
Args:
|
|
||||||
params: Словарь параметров
|
|
||||||
schema_class: Класс схемы Pydantic
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Валидированные параметры
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: Если параметры не прошли валидацию
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Создаем экземпляр схемы для валидации
|
|
||||||
validated_data = schema_class(**params)
|
|
||||||
return validated_data.dict()
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Ошибка валидации параметров: {str(e)}")
|
|
||||||
@@ -43,7 +43,7 @@ class ReportService:
|
|||||||
try:
|
try:
|
||||||
# Парсим файл
|
# Парсим файл
|
||||||
parse_params = request.parse_params or {}
|
parse_params = request.parse_params or {}
|
||||||
parse_result = parser.parse(temp_file_path, parse_params)
|
df = parser.parse(temp_file_path, parse_params)
|
||||||
|
|
||||||
# Генерируем object_id
|
# Генерируем object_id
|
||||||
object_id = f"nin_excel_data_{request.report_type}"
|
object_id = f"nin_excel_data_{request.report_type}"
|
||||||
@@ -54,7 +54,7 @@ class ReportService:
|
|||||||
print(f"Старый объект удален: {object_id}")
|
print(f"Старый объект удален: {object_id}")
|
||||||
|
|
||||||
# Сохраняем в хранилище
|
# Сохраняем в хранилище
|
||||||
if self.storage.save_dataframe(parse_result, object_id):
|
if self.storage.save_dataframe(df, object_id):
|
||||||
return UploadResult(
|
return UploadResult(
|
||||||
success=True,
|
success=True,
|
||||||
message="Отчет успешно загружен",
|
message="Отчет успешно загружен",
|
||||||
@@ -89,9 +89,9 @@ class ReportService:
|
|||||||
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Загружаем данные из хранилища
|
# Загружаем DataFrame из хранилища
|
||||||
loaded_data = self.storage.load_dataframe(object_id)
|
df = self.storage.load_dataframe(object_id)
|
||||||
if loaded_data is None:
|
if df is None:
|
||||||
return DataResult(
|
return DataResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
||||||
@@ -100,84 +100,25 @@ class ReportService:
|
|||||||
# Получаем парсер
|
# Получаем парсер
|
||||||
parser = get_parser(request.report_type)
|
parser = get_parser(request.report_type)
|
||||||
|
|
||||||
# Устанавливаем данные в парсер для использования в геттерах
|
# Устанавливаем DataFrame в парсер для использования в геттерах
|
||||||
parser.df = loaded_data
|
parser.df = df
|
||||||
print(f"🔍 DEBUG: ReportService.get_data - установлены данные в парсер {request.report_type}")
|
|
||||||
|
|
||||||
# Проверяем тип загруженных данных
|
|
||||||
if hasattr(loaded_data, 'shape'):
|
|
||||||
# Это DataFrame
|
|
||||||
print(f"🔍 DEBUG: DataFrame shape: {loaded_data.shape}")
|
|
||||||
print(f"🔍 DEBUG: DataFrame columns: {list(loaded_data.columns) if not loaded_data.empty else 'Empty'}")
|
|
||||||
elif isinstance(loaded_data, dict):
|
|
||||||
# Это словарь (для парсера ПМ)
|
|
||||||
print(f"🔍 DEBUG: Словарь с ключами: {list(loaded_data.keys())}")
|
|
||||||
else:
|
|
||||||
print(f"🔍 DEBUG: Неизвестный тип данных: {type(loaded_data)}")
|
|
||||||
|
|
||||||
# Получаем параметры запроса
|
# Получаем параметры запроса
|
||||||
get_params = request.get_params or {}
|
get_params = request.get_params or {}
|
||||||
|
|
||||||
# Для svodka_ca определяем режим из данных или используем 'fact' по умолчанию
|
# Определяем имя геттера (по умолчанию используем первый доступный)
|
||||||
if request.report_type == 'svodka_ca':
|
getter_name = get_params.pop("getter", None)
|
||||||
# Извлекаем режим из DataFrame или используем 'fact' по умолчанию
|
if not getter_name:
|
||||||
if hasattr(parser, 'df') and parser.df is not None and not parser.df.empty:
|
# Если геттер не указан, берем первый доступный
|
||||||
modes_in_df = parser.df['mode'].unique() if 'mode' in parser.df.columns else ['fact']
|
available_getters = list(parser.getters.keys())
|
||||||
# Используем первый найденный режим или 'fact' по умолчанию
|
if available_getters:
|
||||||
default_mode = modes_in_df[0] if len(modes_in_df) > 0 else 'fact'
|
getter_name = available_getters[0]
|
||||||
|
print(f"⚠️ Геттер не указан, используем первый доступный: {getter_name}")
|
||||||
else:
|
else:
|
||||||
default_mode = 'fact'
|
return DataResult(
|
||||||
|
success=False,
|
||||||
# Устанавливаем режим в параметры, если он не указан
|
message="Парсер не имеет доступных геттеров"
|
||||||
if 'mode' not in get_params:
|
)
|
||||||
get_params['mode'] = default_mode
|
|
||||||
|
|
||||||
# Определяем имя геттера
|
|
||||||
if request.report_type == 'svodka_ca':
|
|
||||||
# Для svodka_ca используем геттер get_ca_data
|
|
||||||
getter_name = 'get_ca_data'
|
|
||||||
elif request.report_type == '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]
|
|
||||||
print(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]
|
|
||||||
print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
|
||||||
else:
|
|
||||||
return DataResult(
|
|
||||||
success=False,
|
|
||||||
message="Парсер не имеет доступных геттеров"
|
|
||||||
)
|
|
||||||
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]
|
|
||||||
print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
|
||||||
else:
|
|
||||||
return DataResult(
|
|
||||||
success=False,
|
|
||||||
message="Парсер не имеет доступных геттеров"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Получаем значение через указанный геттер
|
# Получаем значение через указанный геттер
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# Продакшн конфигурация
|
|
||||||
# Для разработки используйте: docker compose -f docker-compose.dev.yml up -d
|
|
||||||
services:
|
services:
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
@@ -12,11 +10,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: ./python_parser
|
build: .
|
||||||
container_name: svodka_fastapi
|
container_name: svodka_fastapi
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -37,13 +35,9 @@ services:
|
|||||||
- "8501:8501"
|
- "8501:8501"
|
||||||
environment:
|
environment:
|
||||||
- API_BASE_URL=http://fastapi:8000
|
- API_BASE_URL=http://fastapi:8000
|
||||||
- API_PUBLIC_URL=http://localhost:8000
|
|
||||||
- MINIO_ENDPOINT=minio:9000
|
|
||||||
- MINIO_ACCESS_KEY=minioadmin
|
|
||||||
- MINIO_SECRET_KEY=minioadmin
|
|
||||||
- MINIO_SECURE=false
|
|
||||||
- MINIO_BUCKET=svodka-data
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
|
||||||
- fastapi
|
- fastapi
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data:
|
||||||
17
python_parser/manifest.yml
Normal file
17
python_parser/manifest.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
applications:
|
||||||
|
- name: nin-python-parser-dev-test
|
||||||
|
buildpack: python_buildpack
|
||||||
|
health-check-type: web
|
||||||
|
services:
|
||||||
|
- logging-shared-dev
|
||||||
|
command: python /app/run_stand.py
|
||||||
|
path: .
|
||||||
|
disk_quota: 2G
|
||||||
|
memory: 4G
|
||||||
|
instances: 1
|
||||||
|
env:
|
||||||
|
MINIO_ENDPOINT: s3-region1.ppc-jv-dev.sibintek.ru
|
||||||
|
MINIO_ACCESS_KEY: 00a70fac02c1208446de
|
||||||
|
MINIO_SECRET_KEY: 1gk9tVYEEoH9ADRxb4kiAuCo6CCISdV6ie0p6oDO
|
||||||
|
MINIO_BUCKET: bucket-476684e7-1223-45ac-a101-8b5aeda487d6
|
||||||
|
MINIO_SECURE: false
|
||||||
BIN
python_parser/minio/.minio.sys/buckets/.bloomcycle.bin/xl.meta
Normal file
BIN
python_parser/minio/.minio.sys/buckets/.bloomcycle.bin/xl.meta
Normal file
Binary file not shown.
BIN
python_parser/minio/.minio.sys/buckets/.heal/mrf/list.bin
Normal file
BIN
python_parser/minio/.minio.sys/buckets/.heal/mrf/list.bin
Normal file
Binary file not shown.
Binary file not shown.
BIN
python_parser/minio/.minio.sys/buckets/.usage-cache.bin/xl.meta
Normal file
BIN
python_parser/minio/.minio.sys/buckets/.usage-cache.bin/xl.meta
Normal file
Binary file not shown.
BIN
python_parser/minio/.minio.sys/buckets/.usage.json/xl.meta
Normal file
BIN
python_parser/minio/.minio.sys/buckets/.usage.json/xl.meta
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
python_parser/minio/.minio.sys/config/config.json/xl.meta
Normal file
BIN
python_parser/minio/.minio.sys/config/config.json/xl.meta
Normal file
Binary file not shown.
BIN
python_parser/minio/.minio.sys/config/iam/format.json/xl.meta
Normal file
BIN
python_parser/minio/.minio.sys/config/iam/format.json/xl.meta
Normal file
Binary file not shown.
Binary file not shown.
1
python_parser/minio/.minio.sys/format.json
Normal file
1
python_parser/minio/.minio.sys/format.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"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"}}
|
||||||
BIN
python_parser/minio/.minio.sys/pool.bin/xl.meta
Normal file
BIN
python_parser/minio/.minio.sys/pool.bin/xl.meta
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
python_parser/minio/svodka-data/nin_excel_data_svodka_ca/xl.meta
Normal file
BIN
python_parser/minio/svodka-data/nin_excel_data_svodka_ca/xl.meta
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
python_parser/minio/svodka-data/nin_excel_data_svodka_pm/xl.meta
Normal file
BIN
python_parser/minio/svodka-data/nin_excel_data_svodka_pm/xl.meta
Normal file
Binary file not shown.
@@ -11,4 +11,5 @@ requests>=2.31.0
|
|||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
# pytest-mock>=3.10.0
|
# pytest-mock>=3.10.0
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
numpy
|
numpy
|
||||||
|
streamlit>=1.28.0
|
||||||
60
python_parser/run_streamlit_local.py
Normal file
60
python_parser/run_streamlit_local.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Запуск Streamlit интерфейса локально из изолированного пакета
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
import os
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Основная функция"""
|
||||||
|
print("🚀 ЗАПУСК STREAMLIT ИЗ ИЗОЛИРОВАННОГО ПАКЕТА")
|
||||||
|
print("=" * 60)
|
||||||
|
print("Убедитесь, что FastAPI сервер запущен на порту 8000")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Проверяем, существует ли папка streamlit_app
|
||||||
|
if not os.path.exists("streamlit_app"):
|
||||||
|
print("❌ Папка streamlit_app не найдена")
|
||||||
|
print("Создайте изолированный пакет или используйте docker-compose up -d")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Переходим в папку streamlit_app
|
||||||
|
os.chdir("streamlit_app")
|
||||||
|
|
||||||
|
# Проверяем, установлен ли Streamlit
|
||||||
|
try:
|
||||||
|
import streamlit
|
||||||
|
print(f"✅ Streamlit {streamlit.__version__} установлен")
|
||||||
|
except ImportError:
|
||||||
|
print("❌ Streamlit не установлен")
|
||||||
|
print("Установите: pip install -r requirements.txt")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n🚀 Запускаю Streamlit...")
|
||||||
|
print("📍 URL: http://localhost:8501")
|
||||||
|
print("🛑 Для остановки нажмите 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", "app.py",
|
||||||
|
"--server.port", "8501",
|
||||||
|
"--server.address", "localhost",
|
||||||
|
"--server.headless", "false",
|
||||||
|
"--browser.gatherUsageStats", "false"
|
||||||
|
])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n👋 Streamlit остановлен")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
python_parser/runtime.txt
Normal file
1
python_parser/runtime.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python-3.11.*
|
||||||
@@ -16,8 +16,7 @@ st.set_page_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Конфигурация API
|
# Конфигурация API
|
||||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://fastapi:8000") # Внутренний адрес для Docker
|
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
|
||||||
API_PUBLIC_URL = os.getenv("API_PUBLIC_URL", "http://localhost:8000") # Внешний адрес для пользователя
|
|
||||||
|
|
||||||
def check_api_health():
|
def check_api_health():
|
||||||
"""Проверка доступности API"""
|
"""Проверка доступности API"""
|
||||||
@@ -74,7 +73,7 @@ def main():
|
|||||||
st.info("Убедитесь, что FastAPI сервер запущен")
|
st.info("Убедитесь, что FastAPI сервер запущен")
|
||||||
return
|
return
|
||||||
|
|
||||||
st.success(f"✅ API доступен по адресу {API_PUBLIC_URL}")
|
st.success(f"✅ API доступен по адресу {API_BASE_URL}")
|
||||||
|
|
||||||
# Боковая панель с информацией
|
# Боковая панель с информацией
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
@@ -374,7 +373,7 @@ def main():
|
|||||||
# Футер
|
# Футер
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
st.markdown("### 📚 Документация API")
|
st.markdown("### 📚 Документация API")
|
||||||
st.markdown(f"Полная документация доступна по адресу: {API_PUBLIC_URL}/docs")
|
st.markdown(f"Полная документация доступна по адресу: {API_BASE_URL}/docs")
|
||||||
|
|
||||||
# Информация о проекте
|
# Информация о проекте
|
||||||
with st.expander("ℹ️ О проекте"):
|
with st.expander("ℹ️ О проекте"):
|
||||||
31
python_parser/streamlit_app/.dockerignore
Normal file
31
python_parser/streamlit_app/.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.hypothesis
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
23
python_parser/streamlit_app/Dockerfile
Normal file
23
python_parser/streamlit_app/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Устанавливаем системные зависимости
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Копируем файлы зависимостей
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Устанавливаем Python зависимости
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копируем код приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Открываем порт
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
# Команда запуска
|
||||||
|
CMD ["streamlit", "run", "app.py", "--server.port", "8501", "--server.address", "0.0.0.0"]
|
||||||
44
python_parser/streamlit_app/README.md
Normal file
44
python_parser/streamlit_app/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 📊 Streamlit App - NIN Excel Parsers API
|
||||||
|
|
||||||
|
Изолированное Streamlit приложение для демонстрации работы NIN Excel Parsers API.
|
||||||
|
|
||||||
|
## 🚀 Запуск
|
||||||
|
|
||||||
|
### Локально:
|
||||||
|
```bash
|
||||||
|
cd streamlit_app
|
||||||
|
pip install -r requirements.txt
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### В Docker:
|
||||||
|
```bash
|
||||||
|
docker build -t streamlit-app .
|
||||||
|
docker run -p 8501:8501 streamlit-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения:
|
||||||
|
- `API_BASE_URL` - адрес FastAPI сервера (по умолчанию: `http://fastapi:8000`)
|
||||||
|
|
||||||
|
### Параметры Streamlit:
|
||||||
|
- Порт: 8501
|
||||||
|
- Адрес: 0.0.0.0 (для Docker)
|
||||||
|
- Режим: headless (для Docker)
|
||||||
|
|
||||||
|
## 📁 Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
streamlit_app/
|
||||||
|
├── app.py # Основное приложение
|
||||||
|
├── requirements.txt # Зависимости Python
|
||||||
|
├── Dockerfile # Docker образ
|
||||||
|
├── .streamlit/ # Конфигурация Streamlit
|
||||||
|
│ └── config.toml # Настройки
|
||||||
|
└── README.md # Документация
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Доступ
|
||||||
|
|
||||||
|
После запуска приложение доступно по адресу: **http://localhost:8501**
|
||||||
447
python_parser/streamlit_app/app.py
Normal file
447
python_parser/streamlit_app/app.py
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import pandas as pd
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from typing import Dict, Any
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Конфигурация страницы
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="NIN Excel Parsers API Demo",
|
||||||
|
page_icon="📊",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="expanded"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Конфигурация API - используем переменную окружения или значение по умолчанию
|
||||||
|
API_BASE_URL = os.getenv("API_BASE_URL", "http://fastapi:8000")
|
||||||
|
|
||||||
|
def check_api_health():
|
||||||
|
"""Проверка доступности API"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/", timeout=5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_available_parsers():
|
||||||
|
"""Получение списка доступных парсеров"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/parsers")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()["parsers"]
|
||||||
|
return []
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_parser_getters(parser_name: str):
|
||||||
|
"""Получение информации о геттерах парсера"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/parsers/{parser_name}/getters")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
return {}
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_server_info():
|
||||||
|
"""Получение информации о сервере"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/server-info")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
return {}
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def upload_file_to_api(endpoint: str, file_data: bytes, filename: str):
|
||||||
|
"""Загрузка файла на API"""
|
||||||
|
try:
|
||||||
|
files = {"zip_file": (filename, file_data, "application/zip")}
|
||||||
|
response = requests.post(f"{API_BASE_URL}{endpoint}", files=files)
|
||||||
|
return response.json(), response.status_code
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
def make_api_request(endpoint: str, data: Dict[str, Any]):
|
||||||
|
"""Выполнение API запроса"""
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{API_BASE_URL}{endpoint}", json=data)
|
||||||
|
return response.json(), response.status_code
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st.title("🚀 NIN Excel Parsers API - Демонстрация")
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Проверка доступности API
|
||||||
|
if not check_api_health():
|
||||||
|
st.error(f"❌ API недоступен по адресу {API_BASE_URL}")
|
||||||
|
st.info("Убедитесь, что FastAPI сервер запущен")
|
||||||
|
return
|
||||||
|
|
||||||
|
st.success(f"✅ API доступен по адресу {API_BASE_URL}")
|
||||||
|
|
||||||
|
# Боковая панель с информацией
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("ℹ️ Информация")
|
||||||
|
|
||||||
|
# Информация о сервере
|
||||||
|
server_info = get_server_info()
|
||||||
|
if server_info:
|
||||||
|
st.subheader("Сервер")
|
||||||
|
st.write(f"PID: {server_info.get('process_id', 'N/A')}")
|
||||||
|
st.write(f"CPU ядер: {server_info.get('cpu_cores', 'N/A')}")
|
||||||
|
st.write(f"Память: {server_info.get('memory_mb', 'N/A'):.1f} MB")
|
||||||
|
|
||||||
|
# Доступные парсеры
|
||||||
|
parsers = get_available_parsers()
|
||||||
|
if parsers:
|
||||||
|
st.subheader("Доступные парсеры")
|
||||||
|
for parser in parsers:
|
||||||
|
st.write(f"• {parser}")
|
||||||
|
|
||||||
|
# Основные вкладки - по одной на каждый парсер
|
||||||
|
tab1, tab2, tab3 = st.tabs([
|
||||||
|
"📊 Сводки ПМ",
|
||||||
|
"🏭 Сводки СА",
|
||||||
|
"⛽ Мониторинг топлива"
|
||||||
|
])
|
||||||
|
|
||||||
|
# Вкладка 1: Сводки ПМ - полный функционал
|
||||||
|
with tab1:
|
||||||
|
st.header("📊 Сводки ПМ - Полный функционал")
|
||||||
|
|
||||||
|
# Получаем информацию о геттерах
|
||||||
|
getters_info = get_parser_getters("svodka_pm")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
uploaded_pm = st.file_uploader(
|
||||||
|
"Выберите ZIP архив со сводками ПМ",
|
||||||
|
type=['zip'],
|
||||||
|
key="pm_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_pm is not None:
|
||||||
|
if st.button("📤 Загрузить сводки ПМ", key="upload_pm_btn"):
|
||||||
|
with st.spinner("Загружаю файл..."):
|
||||||
|
result, status = upload_file_to_api(
|
||||||
|
"/svodka_pm/upload-zip",
|
||||||
|
uploaded_pm.read(),
|
||||||
|
uploaded_pm.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||||
|
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("🔍 Получение данных")
|
||||||
|
|
||||||
|
# Показываем доступные геттеры
|
||||||
|
if getters_info and "getters" in getters_info:
|
||||||
|
st.info("📋 Доступные геттеры:")
|
||||||
|
for getter_name, getter_info in getters_info["getters"].items():
|
||||||
|
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
|
||||||
|
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
|
||||||
|
if getter_info.get('optional_params'):
|
||||||
|
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("Данные по одному ОГ")
|
||||||
|
|
||||||
|
og_id = st.selectbox(
|
||||||
|
"Выберите ОГ",
|
||||||
|
["SNPZ", "KNPZ", "ANHK", "AchNPZ", "UNPZ", "UNH", "NOV",
|
||||||
|
"NovKuybNPZ", "KuybNPZ", "CyzNPZ", "TuapsNPZ", "RNPK",
|
||||||
|
"NVNPO", "KLNPZ", "PurNP", "YANOS"],
|
||||||
|
key="pm_single_og"
|
||||||
|
)
|
||||||
|
|
||||||
|
codes = st.multiselect(
|
||||||
|
"Выберите коды строк",
|
||||||
|
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
|
||||||
|
default=[78, 79],
|
||||||
|
key="pm_single_codes"
|
||||||
|
)
|
||||||
|
|
||||||
|
columns = st.multiselect(
|
||||||
|
"Выберите столбцы",
|
||||||
|
["БП", "ПП", "СЭБ", "Факт", "План"],
|
||||||
|
default=["БП", "ПП"],
|
||||||
|
key="pm_single_columns"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("🔍 Получить данные по ОГ", key="pm_single_btn"):
|
||||||
|
if codes and columns:
|
||||||
|
with st.spinner("Получаю данные..."):
|
||||||
|
data = {
|
||||||
|
"getter": "single_og",
|
||||||
|
"id": og_id,
|
||||||
|
"codes": codes,
|
||||||
|
"columns": columns
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status = make_api_request("/svodka_pm/get_data", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Данные получены")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
else:
|
||||||
|
st.warning("⚠️ Выберите коды и столбцы")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("Данные по всем ОГ")
|
||||||
|
|
||||||
|
codes_total = st.multiselect(
|
||||||
|
"Выберите коды строк",
|
||||||
|
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
|
||||||
|
default=[78, 79, 394, 395],
|
||||||
|
key="pm_total_codes"
|
||||||
|
)
|
||||||
|
|
||||||
|
columns_total = st.multiselect(
|
||||||
|
"Выберите столбцы",
|
||||||
|
["БП", "ПП", "СЭБ", "Факт", "План"],
|
||||||
|
default=["БП", "ПП", "СЭБ"],
|
||||||
|
key="pm_total_columns"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("🔍 Получить данные по всем ОГ", key="pm_total_btn"):
|
||||||
|
if codes_total and columns_total:
|
||||||
|
with st.spinner("Получаю данные..."):
|
||||||
|
data = {
|
||||||
|
"getter": "total_ogs",
|
||||||
|
"codes": codes_total,
|
||||||
|
"columns": columns_total
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status = make_api_request("/svodka_pm/get_data", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Данные получены")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
else:
|
||||||
|
st.warning("⚠️ Выберите коды и столбцы")
|
||||||
|
|
||||||
|
# Вкладка 2: Сводки СА - полный функционал
|
||||||
|
with tab2:
|
||||||
|
st.header("🏭 Сводки СА - Полный функционал")
|
||||||
|
|
||||||
|
# Получаем информацию о геттерах
|
||||||
|
getters_info = get_parser_getters("svodka_ca")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
uploaded_ca = st.file_uploader(
|
||||||
|
"Выберите Excel файл сводки СА",
|
||||||
|
type=['xlsx', 'xlsm', 'xls'],
|
||||||
|
key="ca_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_ca is not None:
|
||||||
|
if st.button("📤 Загрузить сводку СА", key="upload_ca_btn"):
|
||||||
|
with st.spinner("Загружаю файл..."):
|
||||||
|
try:
|
||||||
|
files = {"file": (uploaded_ca.name, uploaded_ca.read(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||||
|
response = requests.post(f"{API_BASE_URL}/svodka_ca/upload", files=files)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||||
|
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("🔍 Получение данных")
|
||||||
|
|
||||||
|
# Показываем доступные геттеры
|
||||||
|
if getters_info and "getters" in getters_info:
|
||||||
|
st.info("📋 Доступные геттеры:")
|
||||||
|
for getter_name, getter_info in getters_info["getters"].items():
|
||||||
|
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
|
||||||
|
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
|
||||||
|
if getter_info.get('optional_params'):
|
||||||
|
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("Параметры запроса")
|
||||||
|
|
||||||
|
modes = st.multiselect(
|
||||||
|
"Выберите режимы",
|
||||||
|
["План", "Факт", "Норматив"],
|
||||||
|
default=["План", "Факт"],
|
||||||
|
key="ca_modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
tables = st.multiselect(
|
||||||
|
"Выберите таблицы",
|
||||||
|
["ТиП", "Топливо", "Потери"],
|
||||||
|
default=["ТиП", "Топливо"],
|
||||||
|
key="ca_tables"
|
||||||
|
)
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("Результат")
|
||||||
|
if st.button("🔍 Получить данные СА", key="ca_btn"):
|
||||||
|
if modes and tables:
|
||||||
|
with st.spinner("Получаю данные..."):
|
||||||
|
data = {
|
||||||
|
"getter": "get_data",
|
||||||
|
"modes": modes,
|
||||||
|
"tables": tables
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status = make_api_request("/svodka_ca/get_data", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Данные получены")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
else:
|
||||||
|
st.warning("⚠️ Выберите режимы и таблицы")
|
||||||
|
|
||||||
|
# Вкладка 3: Мониторинг топлива - полный функционал
|
||||||
|
with tab3:
|
||||||
|
st.header("⛽ Мониторинг топлива - Полный функционал")
|
||||||
|
|
||||||
|
# Получаем информацию о геттерах
|
||||||
|
getters_info = get_parser_getters("monitoring_fuel")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
uploaded_fuel = st.file_uploader(
|
||||||
|
"Выберите ZIP архив с мониторингом топлива",
|
||||||
|
type=['zip'],
|
||||||
|
key="fuel_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_fuel is not None:
|
||||||
|
if st.button("📤 Загрузить мониторинг топлива", key="upload_fuel_btn"):
|
||||||
|
with st.spinner("Загружаю файл..."):
|
||||||
|
result, status = upload_file_to_api(
|
||||||
|
"/monitoring_fuel/upload-zip",
|
||||||
|
uploaded_fuel.read(),
|
||||||
|
uploaded_fuel.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||||
|
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("🔍 Получение данных")
|
||||||
|
|
||||||
|
# Показываем доступные геттеры
|
||||||
|
if getters_info and "getters" in getters_info:
|
||||||
|
st.info("📋 Доступные геттеры:")
|
||||||
|
for getter_name, getter_info in getters_info["getters"].items():
|
||||||
|
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
|
||||||
|
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
|
||||||
|
if getter_info.get('optional_params'):
|
||||||
|
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("Агрегация по колонкам")
|
||||||
|
|
||||||
|
columns_fuel = st.multiselect(
|
||||||
|
"Выберите столбцы",
|
||||||
|
["normativ", "total", "total_1"],
|
||||||
|
default=["normativ", "total"],
|
||||||
|
key="fuel_columns"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("🔍 Получить агрегированные данные", key="fuel_total_btn"):
|
||||||
|
if columns_fuel:
|
||||||
|
with st.spinner("Получаю данные..."):
|
||||||
|
data = {
|
||||||
|
"getter": "total_by_columns",
|
||||||
|
"columns": columns_fuel
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status = make_api_request("/monitoring_fuel/get_data", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Данные получены")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
else:
|
||||||
|
st.warning("⚠️ Выберите столбцы")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("Данные за месяц")
|
||||||
|
|
||||||
|
month = st.selectbox(
|
||||||
|
"Выберите месяц",
|
||||||
|
[f"{i:02d}" for i in range(1, 13)],
|
||||||
|
key="fuel_month"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"):
|
||||||
|
with st.spinner("Получаю данные..."):
|
||||||
|
data = {
|
||||||
|
"getter": "month_by_code",
|
||||||
|
"month": month
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status = make_api_request("/monitoring_fuel/get_data", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Данные получены")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
# Футер
|
||||||
|
st.markdown("---")
|
||||||
|
st.markdown("### 📚 Документация API")
|
||||||
|
st.markdown(f"Полная документация доступна по адресу: {API_BASE_URL}/docs")
|
||||||
|
|
||||||
|
# Информация о проекте
|
||||||
|
with st.expander("ℹ️ О проекте"):
|
||||||
|
st.markdown("""
|
||||||
|
**NIN Excel Parsers API** - это веб-сервис для парсинга и обработки Excel-файлов нефтеперерабатывающих заводов.
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- 📊 Парсинг сводок ПМ (план и факт)
|
||||||
|
- 🏭 Парсинг сводок СА
|
||||||
|
- ⛽ Мониторинг топлива
|
||||||
|
|
||||||
|
**Технологии:**
|
||||||
|
- FastAPI
|
||||||
|
- Pandas
|
||||||
|
- MinIO (S3-совместимое хранилище)
|
||||||
|
- Streamlit (веб-интерфейс)
|
||||||
|
""")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
python_parser/streamlit_app/requirements.txt
Normal file
4
python_parser/streamlit_app/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
streamlit>=1.28.0
|
||||||
|
requests>=2.31.0
|
||||||
|
pandas>=1.5.0
|
||||||
|
numpy>=1.24.0
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Простой тест для проверки работы FastAPI
|
|
||||||
"""
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
app = FastAPI(title="Test API")
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
return {"message": "Test API is working"}
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
print("Starting test server...")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
||||||
49
start_dev.py
49
start_dev.py
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Скрипт для запуска проекта в режиме разработки
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
def run_command(command, description):
|
|
||||||
"""Выполнение команды с выводом"""
|
|
||||||
print(f"🔄 {description}...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
|
|
||||||
print(f"✅ {description} выполнено успешно")
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"❌ Ошибка при {description.lower()}:")
|
|
||||||
print(f" Команда: {command}")
|
|
||||||
print(f" Ошибка: {e.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("🚀 Запуск проекта в режиме разработки")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
# Останавливаем продакшн контейнеры если они запущены
|
|
||||||
if run_command("docker compose ps", "Проверка статуса контейнеров"):
|
|
||||||
if "Up" in subprocess.run("docker compose ps", shell=True, capture_output=True, text=True).stdout:
|
|
||||||
print("🛑 Останавливаю продакшн контейнеры...")
|
|
||||||
run_command("docker compose down", "Остановка продакшн контейнеров")
|
|
||||||
|
|
||||||
# Запускаем режим разработки
|
|
||||||
print("\n🔧 Запуск режима разработки...")
|
|
||||||
if run_command("docker compose -f docker-compose.dev.yml up -d", "Запуск контейнеров разработки"):
|
|
||||||
print("\n🎉 Проект запущен в режиме разработки!")
|
|
||||||
print("\n📍 Доступные сервисы:")
|
|
||||||
print(" • Streamlit: http://localhost:8501")
|
|
||||||
print(" • FastAPI: http://localhost:8000")
|
|
||||||
print(" • MinIO Console: http://localhost:9001")
|
|
||||||
print("\n💡 Теперь изменения в streamlit_app/ будут автоматически перезагружаться!")
|
|
||||||
print("\n🛑 Для остановки используйте:")
|
|
||||||
print(" docker compose -f docker-compose.dev.yml down")
|
|
||||||
else:
|
|
||||||
print("\n❌ Не удалось запустить проект в режиме разработки")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Скрипт для запуска проекта в продакшн режиме
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
def run_command(command, description):
|
|
||||||
"""Выполнение команды с выводом"""
|
|
||||||
print(f"🔄 {description}...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
|
|
||||||
print(f"✅ {description} выполнено успешно")
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"❌ Ошибка при {description.lower()}:")
|
|
||||||
print(f" Команда: {command}")
|
|
||||||
print(f" Ошибка: {e.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("🚀 Запуск проекта в продакшн режиме")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
# Останавливаем контейнеры разработки если они запущены
|
|
||||||
if run_command("docker compose -f docker-compose.dev.yml ps", "Проверка статуса контейнеров разработки"):
|
|
||||||
if "Up" in subprocess.run("docker compose -f docker-compose.dev.yml ps", shell=True, capture_output=True, text=True).stdout:
|
|
||||||
print("🛑 Останавливаю контейнеры разработки...")
|
|
||||||
run_command("docker compose -f docker-compose.dev.yml down", "Остановка контейнеров разработки")
|
|
||||||
|
|
||||||
# Запускаем продакшн режим
|
|
||||||
print("\n🏭 Запуск продакшн режима...")
|
|
||||||
if run_command("docker compose up -d --build", "Запуск продакшн контейнеров"):
|
|
||||||
print("\n🎉 Проект запущен в продакшн режиме!")
|
|
||||||
print("\n📍 Доступные сервисы:")
|
|
||||||
print(" • Streamlit: http://localhost:8501")
|
|
||||||
print(" • FastAPI: http://localhost:8000")
|
|
||||||
print(" • MinIO Console: http://localhost:9001")
|
|
||||||
print("\n💡 Для разработки используйте:")
|
|
||||||
print(" python start_dev.py")
|
|
||||||
print("\n🛑 Для остановки используйте:")
|
|
||||||
print(" docker compose down")
|
|
||||||
else:
|
|
||||||
print("\n❌ Не удалось запустить проект в продакшн режиме")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[server]
|
|
||||||
port = 8501
|
|
||||||
address = "0.0.0.0"
|
|
||||||
enableCORS = false
|
|
||||||
enableXsrfProtection = false
|
|
||||||
|
|
||||||
[browser]
|
|
||||||
gatherUsageStats = false
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
primaryColor = "#FF4B4B"
|
|
||||||
backgroundColor = "#FFFFFF"
|
|
||||||
secondaryBackgroundColor = "#F0F2F6"
|
|
||||||
textColor = "#262730"
|
|
||||||
font = "sans serif"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
import plotly.express as px
|
|
||||||
import plotly.graph_objects as go
|
|
||||||
from minio import Minio
|
|
||||||
import os
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
# Конфигурация страницы
|
|
||||||
st.set_page_config(
|
|
||||||
page_title="Сводка данных",
|
|
||||||
page_icon="📊",
|
|
||||||
layout="wide",
|
|
||||||
initial_sidebar_state="expanded"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Заголовок приложения
|
|
||||||
st.title("📊 Анализ данных сводки")
|
|
||||||
st.markdown("---")
|
|
||||||
|
|
||||||
# Инициализация MinIO клиента
|
|
||||||
@st.cache_resource
|
|
||||||
def init_minio_client():
|
|
||||||
try:
|
|
||||||
client = Minio(
|
|
||||||
os.getenv("MINIO_ENDPOINT", "localhost:9000"),
|
|
||||||
access_key=os.getenv("MINIO_ACCESS_KEY", "minioadmin"),
|
|
||||||
secret_key=os.getenv("MINIO_SECRET_KEY", "minioadmin"),
|
|
||||||
secure=os.getenv("MINIO_SECURE", "false").lower() == "true"
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
except Exception as e:
|
|
||||||
st.error(f"Ошибка подключения к MinIO: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Боковая панель
|
|
||||||
with st.sidebar:
|
|
||||||
st.header("⚙️ Настройки")
|
|
||||||
|
|
||||||
# Выбор типа данных
|
|
||||||
data_type = st.selectbox(
|
|
||||||
"Тип данных",
|
|
||||||
["Мониторинг топлива", "Сводка ПМ", "Сводка ЦА"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Выбор периода
|
|
||||||
period = st.date_input(
|
|
||||||
"Период",
|
|
||||||
value=pd.Timestamp.now().date()
|
|
||||||
)
|
|
||||||
|
|
||||||
st.markdown("---")
|
|
||||||
st.markdown("### 📈 Статистика")
|
|
||||||
st.info("Выберите тип данных для анализа")
|
|
||||||
|
|
||||||
# Основной контент
|
|
||||||
col1, col2 = st.columns([2, 1])
|
|
||||||
|
|
||||||
with col1:
|
|
||||||
st.subheader(f"📋 {data_type}")
|
|
||||||
|
|
||||||
if data_type == "Мониторинг топлива":
|
|
||||||
st.info("Анализ данных мониторинга топлива")
|
|
||||||
# Здесь будет логика для работы с данными мониторинга топлива
|
|
||||||
|
|
||||||
elif data_type == "Сводка ПМ":
|
|
||||||
st.info("Анализ данных сводки ПМ")
|
|
||||||
# Здесь будет логика для работы с данными сводки ПМ
|
|
||||||
|
|
||||||
elif data_type == "Сводка ЦА":
|
|
||||||
st.info("Анализ данных сводки ЦА")
|
|
||||||
# Здесь будет логика для работы с данными сводки ЦА
|
|
||||||
|
|
||||||
with col2:
|
|
||||||
st.subheader("📊 Быстрая статистика")
|
|
||||||
st.metric("Всего записей", "0")
|
|
||||||
st.metric("Активных", "0")
|
|
||||||
st.metric("Ошибок", "0")
|
|
||||||
|
|
||||||
# Нижняя панель
|
|
||||||
st.markdown("---")
|
|
||||||
st.subheader("🔍 Детальный анализ")
|
|
||||||
|
|
||||||
# Заглушка для графиков
|
|
||||||
placeholder = st.empty()
|
|
||||||
with placeholder.container():
|
|
||||||
col1, col2 = st.columns(2)
|
|
||||||
|
|
||||||
with col1:
|
|
||||||
st.write("📈 График 1")
|
|
||||||
# Здесь будет график
|
|
||||||
|
|
||||||
with col2:
|
|
||||||
st.write("📊 График 2")
|
|
||||||
# Здесь будет график
|
|
||||||
|
|
||||||
# Футер
|
|
||||||
st.markdown("---")
|
|
||||||
st.markdown("**Разработано для анализа данных сводки** | v1.0.0")
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
Reference in New Issue
Block a user