Compare commits
26 Commits
work-2
...
logging-up
| Author | SHA1 | Date | |
|---|---|---|---|
| 00a01e99d7 | |||
| bbbfbbd508 | |||
| 0f3340c899 | |||
| 3c0fce128d | |||
| b5c460bb6f | |||
| 4aca4ed6c6 | |||
| 8ede706a1e | |||
| 1d43ba8c5a | |||
| e22ef647eb | |||
| 51ee5bf73b | |||
| 0a328f9781 | |||
| 1fcb44193d | |||
| 631e58dad7 | |||
| 15d13870f3 | |||
| eb6d23bba8 | |||
| e3077252a8 | |||
| 8ed61a3c0b | |||
| 9c152ebe94 | |||
| b8074765e3 | |||
| 79ab91c700 | |||
| b98be22359 | |||
| fc0b4356da | |||
| 72fe115a99 | |||
| 46a30c32ed | |||
| 5e217c7cce | |||
| 7d2747c8fe |
20
.gitignore
vendored
20
.gitignore
vendored
@@ -1,5 +1,23 @@
|
|||||||
# Python
|
# Python
|
||||||
|
__pycache__
|
||||||
__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
|
||||||
*.so
|
*.so
|
||||||
@@ -153,3 +171,5 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# 🧹 Сводка по очистке проекта
|
|
||||||
|
|
||||||
## ✅ Что было удалено из `python_parser/`:
|
|
||||||
|
|
||||||
### Файлы Streamlit:
|
|
||||||
- `streamlit_app.py` - основной файл Streamlit приложения
|
|
||||||
- `run_streamlit.py` - скрипт запуска Streamlit
|
|
||||||
- `Procfile` - конфигурация для Heroku (Streamlit)
|
|
||||||
- `runtime.txt` - версия Python для Heroku
|
|
||||||
- `manifest.yml` - манифест приложения
|
|
||||||
- `.streamlit/` - папка с конфигурацией Streamlit
|
|
||||||
|
|
||||||
### Зависимости:
|
|
||||||
- Удален `streamlit>=1.28.0` из `python_parser/requirements.txt`
|
|
||||||
|
|
||||||
## 🎯 Результат:
|
|
||||||
|
|
||||||
### `python_parser/` - теперь содержит ТОЛЬКО:
|
|
||||||
- FastAPI приложение
|
|
||||||
- Адаптеры для парсеров
|
|
||||||
- Основную бизнес-логику
|
|
||||||
- Dockerfile для FastAPI
|
|
||||||
- Зависимости только для FastAPI
|
|
||||||
|
|
||||||
### `streamlit_app/` - содержит ТОЛЬКО:
|
|
||||||
- Streamlit приложение
|
|
||||||
- Dockerfile для Streamlit
|
|
||||||
- Зависимости только для Streamlit
|
|
||||||
- Конфигурацию Streamlit
|
|
||||||
|
|
||||||
## 🔄 Полное разделение достигнуто:
|
|
||||||
|
|
||||||
- **FastAPI** и **Streamlit** теперь полностью независимы
|
|
||||||
- Каждый сервис имеет свои собственные зависимости
|
|
||||||
- Docker образы собираются отдельно
|
|
||||||
- Запускаются через единый `docker-compose.yml`
|
|
||||||
|
|
||||||
---
|
|
||||||
**Статус**: ✅ Проект полностью очищен и разделен
|
|
||||||
1002
PARSER_DEVELOPMENT_GUIDE.md
Normal file
1002
PARSER_DEVELOPMENT_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
fastapi:
|
fastapi:
|
||||||
build: ./python_parser
|
image: python:3.11-slim
|
||||||
container_name: svodka_fastapi_dev
|
container_name: svodka_fastapi_dev
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -24,9 +24,20 @@ services:
|
|||||||
- MINIO_SECRET_KEY=minioadmin
|
- MINIO_SECRET_KEY=minioadmin
|
||||||
- MINIO_SECURE=false
|
- MINIO_SECURE=false
|
||||||
- MINIO_BUCKET=svodka-data
|
- MINIO_BUCKET=svodka-data
|
||||||
|
volumes:
|
||||||
|
# Монтируем исходный код для автоматической перезагрузки
|
||||||
|
- ./python_parser:/app
|
||||||
|
# Монтируем requirements.txt для установки зависимостей
|
||||||
|
- ./python_parser/requirements.txt:/app/requirements.txt
|
||||||
|
working_dir: /app
|
||||||
depends_on:
|
depends_on:
|
||||||
- minio
|
- minio
|
||||||
restart: unless-stopped
|
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:
|
streamlit:
|
||||||
image: python:3.11-slim
|
image: python:3.11-slim
|
||||||
|
|||||||
BIN
monitoring_tar_correct.zip
Normal file
BIN
monitoring_tar_correct.zip
Normal file
Binary file not shown.
BIN
monitoring_tar_test.zip
Normal file
BIN
monitoring_tar_test.zip
Normal file
Binary file not shown.
135
python_parser/SCHEMA_INTEGRATION.md
Normal file
135
python_parser/SCHEMA_INTEGRATION.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Интеграция схем Pydantic с парсерами
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Этот документ описывает решение для устранения дублирования логики между схемами Pydantic и парсерами. Теперь схемы Pydantic являются единым источником правды для определения параметров парсеров.
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Ранее в парсерах дублировалась информация о параметрах:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# В парсере
|
||||||
|
self.register_getter(
|
||||||
|
name="single_og",
|
||||||
|
method=self._get_single_og,
|
||||||
|
required_params=["id", "codes", "columns"], # Дублирование
|
||||||
|
optional_params=["search"], # Дублирование
|
||||||
|
description="Получение данных по одному ОГ"
|
||||||
|
)
|
||||||
|
|
||||||
|
# В схеме
|
||||||
|
class SvodkaPMSingleOGRequest(BaseModel):
|
||||||
|
id: OGID = Field(...) # Обязательное поле
|
||||||
|
codes: List[int] = Field(...) # Обязательное поле
|
||||||
|
columns: List[str] = Field(...) # Обязательное поле
|
||||||
|
search: Optional[str] = Field(None) # Необязательное поле
|
||||||
|
```
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
### 1. Утилиты для работы со схемами
|
||||||
|
|
||||||
|
Создан модуль `core/schema_utils.py` с функциями:
|
||||||
|
|
||||||
|
- `get_required_fields_from_schema()` - извлекает обязательные поля
|
||||||
|
- `get_optional_fields_from_schema()` - извлекает необязательные поля
|
||||||
|
- `register_getter_from_schema()` - регистрирует геттер с использованием схемы
|
||||||
|
- `validate_params_with_schema()` - валидирует параметры с помощью схемы
|
||||||
|
|
||||||
|
### 2. Обновленные парсеры
|
||||||
|
|
||||||
|
Теперь парсеры используют схемы как единый источник правды:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
# Используем схемы Pydantic как единый источник правды
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="single_og",
|
||||||
|
method=self._get_single_og,
|
||||||
|
schema_class=SvodkaPMSingleOGRequest,
|
||||||
|
description="Получение данных по одному ОГ"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Валидация параметров
|
||||||
|
|
||||||
|
Методы геттеров теперь автоматически валидируют параметры:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _get_single_og(self, params: dict):
|
||||||
|
"""Получение данных по одному ОГ"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
|
||||||
|
|
||||||
|
og_id = validated_params["id"]
|
||||||
|
codes = validated_params["codes"]
|
||||||
|
columns = validated_params["columns"]
|
||||||
|
search = validated_params.get("search")
|
||||||
|
|
||||||
|
# ... остальная логика
|
||||||
|
```
|
||||||
|
|
||||||
|
## Преимущества
|
||||||
|
|
||||||
|
1. **Единый источник правды** - информация о параметрах хранится только в схемах Pydantic
|
||||||
|
2. **Автоматическая валидация** - параметры автоматически валидируются с помощью Pydantic
|
||||||
|
3. **Синхронизация** - изменения в схемах автоматически отражаются в парсерах
|
||||||
|
4. **Типобезопасность** - использование типов Pydantic обеспечивает типобезопасность
|
||||||
|
5. **Документация** - Swagger документация автоматически генерируется из схем
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
Решение работает с:
|
||||||
|
- Pydantic v1 (через `__fields__`)
|
||||||
|
- Pydantic v2 (через `model_fields` и `is_required()`)
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Для новых парсеров
|
||||||
|
|
||||||
|
1. Создайте схему Pydantic с нужными полями
|
||||||
|
2. Используйте `register_getter_from_schema()` для регистрации геттера
|
||||||
|
3. Используйте `validate_params_with_schema()` в методах геттеров
|
||||||
|
|
||||||
|
### Для существующих парсеров
|
||||||
|
|
||||||
|
1. Убедитесь, что у вас есть соответствующая схема Pydantic
|
||||||
|
2. Замените ручную регистрацию геттеров на `register_getter_from_schema()`
|
||||||
|
3. Добавьте валидацию параметров в методы геттеров
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
### Схема с обязательными и необязательными полями
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ExampleRequest(BaseModel):
|
||||||
|
required_field: str = Field(..., description="Обязательное поле")
|
||||||
|
optional_field: Optional[str] = Field(None, description="Необязательное поле")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Регистрация геттера
|
||||||
|
|
||||||
|
```python
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="example_getter",
|
||||||
|
method=self._example_method,
|
||||||
|
schema_class=ExampleRequest,
|
||||||
|
description="Пример геттера"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Валидация в методе
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _example_method(self, params: dict):
|
||||||
|
validated_params = validate_params_with_schema(params, ExampleRequest)
|
||||||
|
# validated_params содержит валидированные данные
|
||||||
|
```
|
||||||
|
|
||||||
|
## Заключение
|
||||||
|
|
||||||
|
Это решение устраняет дублирование кода и обеспечивает единообразие между API схемами и парсерами. Теперь изменения в схемах автоматически отражаются в парсерах, что упрощает поддержку и развитие системы.
|
||||||
Binary file not shown.
BIN
python_parser/adapters/monitoring_tar_test.zip
Normal file
BIN
python_parser/adapters/monitoring_tar_test.zip
Normal file
Binary file not shown.
88
python_parser/adapters/parsers/README_svodka_pm.md
Normal file
88
python_parser/adapters/parsers/README_svodka_pm.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Парсер Сводки ПМ
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Парсер для обработки сводок ПМ (план и факт) с поддержкой множественных геттеров. Наследуется от `ParserPort` и реализует архитектуру hexagonal architecture.
|
||||||
|
|
||||||
|
## Доступные геттеры
|
||||||
|
|
||||||
|
### 1. `get_single_og`
|
||||||
|
Получение данных по одному ОГ из сводки ПМ.
|
||||||
|
|
||||||
|
**Обязательные параметры:**
|
||||||
|
- `id` (str): ID ОГ (например, "SNPZ", "KNPZ")
|
||||||
|
- `codes` (list): Список кодов показателей (например, [78, 79, 81, 82])
|
||||||
|
- `columns` (list): Список столбцов для извлечения (например, ["ПП", "БП", "СЭБ"])
|
||||||
|
|
||||||
|
**Необязательные параметры:**
|
||||||
|
- `search` (str): Значение для поиска в столбцах
|
||||||
|
|
||||||
|
**Пример использования:**
|
||||||
|
```python
|
||||||
|
parser = SvodkaPMParser()
|
||||||
|
params = {
|
||||||
|
"id": "SNPZ",
|
||||||
|
"codes": [78, 79, 81, 82],
|
||||||
|
"columns": ["ПП", "БП", "СЭБ"]
|
||||||
|
}
|
||||||
|
result = parser.get_value("get_single_og", params)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `get_total_ogs`
|
||||||
|
Получение данных по всем ОГ из сводки ПМ.
|
||||||
|
|
||||||
|
**Обязательные параметры:**
|
||||||
|
- `codes` (list): Список кодов показателей
|
||||||
|
- `columns` (list): Список столбцов для извлечения
|
||||||
|
|
||||||
|
**Необязательные параметры:**
|
||||||
|
- `search` (str): Значение для поиска в столбцах
|
||||||
|
|
||||||
|
**Пример использования:**
|
||||||
|
```python
|
||||||
|
parser = SvodkaPMParser()
|
||||||
|
params = {
|
||||||
|
"codes": [78, 79, 81, 82],
|
||||||
|
"columns": ["ПП", "БП", "СЭБ"]
|
||||||
|
}
|
||||||
|
result = parser.get_value("get_total_ogs", params)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддерживаемые столбцы
|
||||||
|
|
||||||
|
- **ПП, БП**: Данные из файлов плана
|
||||||
|
- **ТБ, СЭБ, НЭБ**: Данные из файлов факта
|
||||||
|
|
||||||
|
## Структура файлов
|
||||||
|
|
||||||
|
Парсер ожидает следующую структуру файлов:
|
||||||
|
- `data/pm_fact/svodka_fact_pm_{OG_ID}.xlsx` или `.xlsm`
|
||||||
|
- `data/pm_plan/svodka_plan_pm_{OG_ID}.xlsx` или `.xlsm`
|
||||||
|
|
||||||
|
Где `{OG_ID}` - это ID ОГ (например, SNPZ, KNPZ и т.д.)
|
||||||
|
|
||||||
|
## Формат результата
|
||||||
|
|
||||||
|
Результат возвращается в формате JSON со следующей структурой:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ПП": {
|
||||||
|
"78": 123.45,
|
||||||
|
"79": 234.56
|
||||||
|
},
|
||||||
|
"БП": {
|
||||||
|
"78": 111.11,
|
||||||
|
"79": 222.22
|
||||||
|
},
|
||||||
|
"СЭБ": {
|
||||||
|
"78": 333.33,
|
||||||
|
"79": 444.44
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
- Если файл плана/факта не найден, соответствующие столбцы будут пустыми
|
||||||
|
- Если код показателя не найден, возвращается 0
|
||||||
|
- Валидация параметров выполняется автоматически
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
from .monitoring_fuel import MonitoringFuelParser
|
from .monitoring_fuel import MonitoringFuelParser
|
||||||
|
from .monitoring_tar import MonitoringTarParser
|
||||||
from .svodka_ca import SvodkaCAParser
|
from .svodka_ca import SvodkaCAParser
|
||||||
from .svodka_pm import SvodkaPMParser
|
from .svodka_pm import SvodkaPMParser
|
||||||
|
from .svodka_repair_ca import SvodkaRepairCAParser
|
||||||
|
from .statuses_repair_ca import StatusesRepairCAParser
|
||||||
|
from .oper_spravka_tech_pos import OperSpravkaTechPosParser
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MonitoringFuelParser',
|
'MonitoringFuelParser',
|
||||||
|
'MonitoringTarParser',
|
||||||
'SvodkaCAParser',
|
'SvodkaCAParser',
|
||||||
'SvodkaPMParser'
|
'SvodkaPMParser',
|
||||||
|
'SvodkaRepairCAParser',
|
||||||
|
'StatusesRepairCAParser',
|
||||||
|
'OperSpravkaTechPosParser'
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,15 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import re
|
import re
|
||||||
from typing import Dict
|
import zipfile
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Tuple
|
||||||
from core.ports import ParserPort
|
from core.ports import ParserPort
|
||||||
from adapters.pconfig import data_to_json, get_object_by_name
|
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
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringFuelParser(ParserPort):
|
class MonitoringFuelParser(ParserPort):
|
||||||
@@ -11,6 +17,166 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
|
|
||||||
name = "Мониторинг топлива"
|
name = "Мониторинг топлива"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
# Используем схемы Pydantic как единый источник правды
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="total_by_columns",
|
||||||
|
method=self._get_total_by_columns,
|
||||||
|
schema_class=MonitoringFuelTotalRequest,
|
||||||
|
description="Агрегация данных по колонкам"
|
||||||
|
)
|
||||||
|
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="month_by_code",
|
||||||
|
method=self._get_month_by_code,
|
||||||
|
schema_class=MonitoringFuelMonthRequest,
|
||||||
|
description="Получение данных за конкретный месяц"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_total_by_columns(self, params: dict):
|
||||||
|
"""Агрегация данных по колонкам"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, MonitoringFuelTotalRequest)
|
||||||
|
|
||||||
|
columns = validated_params["columns"]
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
||||||
|
# Данные из парсинга
|
||||||
|
data_source = self.data_dict
|
||||||
|
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
||||||
|
data_source = self._df_to_data_dict()
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Агрегируем данные по колонкам
|
||||||
|
df_means, _ = self.aggregate_by_columns(data_source, columns)
|
||||||
|
|
||||||
|
# Преобразуем в JSON-совместимый формат
|
||||||
|
result = {}
|
||||||
|
for idx, row in df_means.iterrows():
|
||||||
|
result[str(idx)] = {}
|
||||||
|
for col in columns:
|
||||||
|
value = row.get(col)
|
||||||
|
if pd.isna(value) or value == float('inf') or value == float('-inf'):
|
||||||
|
result[str(idx)][col] = None
|
||||||
|
else:
|
||||||
|
result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_month_by_code(self, params: dict):
|
||||||
|
"""Получение данных за конкретный месяц"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, MonitoringFuelMonthRequest)
|
||||||
|
|
||||||
|
month = validated_params["month"]
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
||||||
|
# Данные из парсинга
|
||||||
|
data_source = self.data_dict
|
||||||
|
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
||||||
|
data_source = self._df_to_data_dict()
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Получаем данные за конкретный месяц
|
||||||
|
df_month = self.get_month(data_source, month)
|
||||||
|
|
||||||
|
# Преобразуем в JSON-совместимый формат
|
||||||
|
result = {}
|
||||||
|
for idx, row in df_month.iterrows():
|
||||||
|
result[str(idx)] = {}
|
||||||
|
for col in df_month.columns:
|
||||||
|
value = row[col]
|
||||||
|
if pd.isna(value) or value == float('inf') or value == float('-inf'):
|
||||||
|
result[str(idx)][col] = None
|
||||||
|
else:
|
||||||
|
result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _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:
|
||||||
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
# Парсим данные и сохраняем словарь для использования в геттерах
|
||||||
|
self.data_dict = self.parse_monitoring_fuel_files(file_path, params)
|
||||||
|
|
||||||
|
# Преобразуем словарь в DataFrame для совместимости с services.py
|
||||||
|
if self.data_dict:
|
||||||
|
# Создаем DataFrame с информацией о месяцах и данных
|
||||||
|
data_rows = []
|
||||||
|
for month, df_data in self.data_dict.items():
|
||||||
|
if df_data is not None and not df_data.empty:
|
||||||
|
data_rows.append({
|
||||||
|
'month': month,
|
||||||
|
'rows_count': len(df_data),
|
||||||
|
'data': df_data
|
||||||
|
})
|
||||||
|
|
||||||
|
if data_rows:
|
||||||
|
df = pd.DataFrame(data_rows)
|
||||||
|
self.df = df
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Если данных нет, возвращаем пустой DataFrame
|
||||||
|
self.df = pd.DataFrame()
|
||||||
|
return self.df
|
||||||
|
|
||||||
|
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||||||
|
"""Парсинг ZIP архива с файлами мониторинга топлива"""
|
||||||
|
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
|
||||||
|
file_list = zip_ref.namelist()
|
||||||
|
for month in range(1, 13):
|
||||||
|
|
||||||
|
mm = f"{month:02d}"
|
||||||
|
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
|
||||||
|
candidates = [f for f in file_list if file_temp in f]
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
file = candidates[0]
|
||||||
|
|
||||||
|
logger.info(f'Загрузка {file}')
|
||||||
|
with zip_ref.open(file) as excel_file:
|
||||||
|
try:
|
||||||
|
df = self.parse_single(excel_file, 'Мониторинг потребления')
|
||||||
|
df_monitorings[mm] = df
|
||||||
|
|
||||||
|
logger.info(f"✅ Данные за месяц {mm} загружены")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при загрузке файла {file_temp}: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Файл не найден: {file_temp}")
|
||||||
|
|
||||||
|
return df_monitorings
|
||||||
|
|
||||||
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
|
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
|
||||||
"""Определение индекса заголовка в Excel по ключевому слову"""
|
"""Определение индекса заголовка в Excel по ключевому слову"""
|
||||||
# Читаем первые max_rows строк без заголовков
|
# Читаем первые max_rows строк без заголовков
|
||||||
@@ -18,13 +184,14 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
file_path,
|
file_path,
|
||||||
sheet_name=sheet,
|
sheet_name=sheet,
|
||||||
header=None,
|
header=None,
|
||||||
nrows=max_rows
|
nrows=max_rows,
|
||||||
|
engine='openpyxl'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||||
for idx, row in df_temp.iterrows():
|
for idx, row in df_temp.iterrows():
|
||||||
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
||||||
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
||||||
return idx + 1 # возвращаем индекс строки (0-based)
|
return idx + 1 # возвращаем индекс строки (0-based)
|
||||||
|
|
||||||
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
||||||
@@ -40,7 +207,8 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
sheet_name=sheet,
|
sheet_name=sheet,
|
||||||
header=header_num,
|
header=header_num,
|
||||||
usecols=None,
|
usecols=None,
|
||||||
index_col=None
|
index_col=None,
|
||||||
|
engine='openpyxl'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Удаление полностью пустых столбцов ===
|
# === Удаление полностью пустых столбцов ===
|
||||||
@@ -64,46 +232,19 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
# Проверяем, что колонка 'name' существует
|
# Проверяем, что колонка 'name' существует
|
||||||
if 'name' in df_full.columns:
|
if 'name' in df_full.columns:
|
||||||
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
||||||
df_full['id'] = df_full['name'].apply(get_object_by_name)
|
# df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
|
||||||
|
# Временно используем name как id
|
||||||
|
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)
|
||||||
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
logger.debug(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
||||||
return df_full
|
return df_full
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
|
||||||
import zipfile
|
|
||||||
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
|
|
||||||
|
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
|
||||||
|
|
||||||
file_list = zip_ref.namelist()
|
|
||||||
for month in range(1, 13):
|
|
||||||
|
|
||||||
mm = f"{month:02d}"
|
|
||||||
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
|
|
||||||
candidates = [f for f in file_list if file_temp in f]
|
|
||||||
|
|
||||||
if len(candidates) == 1:
|
|
||||||
file = candidates[0]
|
|
||||||
|
|
||||||
print(f'Загрузка {file}')
|
|
||||||
with zip_ref.open(file) as excel_file:
|
|
||||||
try:
|
|
||||||
df = self.parse_single(excel_file, 'Мониторинг потребления')
|
|
||||||
df_monitorings[mm] = df
|
|
||||||
|
|
||||||
print(f"✅ Данные за месяц {mm} загружены")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при загрузке файла {file_temp}: {e}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Файл не найден: {file_temp}")
|
|
||||||
|
|
||||||
return df_monitorings
|
|
||||||
|
|
||||||
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns):
|
|
||||||
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
||||||
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
||||||
means = {} # Для хранения средних
|
means = {} # Для хранения средних
|
||||||
@@ -113,7 +254,7 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
|
|
||||||
for file_key, df in df_dict.items():
|
for file_key, df in df_dict.items():
|
||||||
if col not in df.columns:
|
if col not in df.columns:
|
||||||
print(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
logger.warning(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Берём колонку, оставляем id как индекс
|
# Берём колонку, оставляем id как индекс
|
||||||
@@ -165,7 +306,7 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
|
|
||||||
for file, df in df_dict.items():
|
for file, df in df_dict.items():
|
||||||
if column not in df.columns:
|
if column not in df.columns:
|
||||||
print(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
logger.warning(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Берём колонку и сохраняем как Series с именем месяца
|
# Берём колонку и сохраняем как Series с именем месяца
|
||||||
@@ -185,22 +326,3 @@ class MonitoringFuelParser(ParserPort):
|
|||||||
total.name = 'mean'
|
total.name = 'mean'
|
||||||
|
|
||||||
return total, df_combined
|
return total, df_combined
|
||||||
|
|
||||||
def get_value(self, df, params):
|
|
||||||
mode = params.get("mode", "total")
|
|
||||||
columns = params.get("columns", None)
|
|
||||||
month = params.get("month", None)
|
|
||||||
data = None
|
|
||||||
if mode == "total":
|
|
||||||
if not columns:
|
|
||||||
raise ValueError("Отсутствуют идентификаторы столбцов")
|
|
||||||
df_means, _ = self.aggregate_by_columns(df, columns)
|
|
||||||
data = df_means.to_dict(orient='index')
|
|
||||||
elif mode == "month":
|
|
||||||
if not month:
|
|
||||||
raise ValueError("Отсутствуют идентификатор месяца")
|
|
||||||
df_month = self.get_month(df, month)
|
|
||||||
data = df_month.to_dict(orient='index')
|
|
||||||
|
|
||||||
json_result = data_to_json(data)
|
|
||||||
return json_result
|
|
||||||
|
|||||||
306
python_parser/adapters/parsers/monitoring_tar.py
Normal file
306
python_parser/adapters/parsers/monitoring_tar.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from core.ports import ParserPort
|
||||||
|
from adapters.pconfig import find_header_row, SNPZ_IDS, data_to_json
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringTarParser(ParserPort):
|
||||||
|
"""Парсер для мониторинга ТЭР (топливно-энергетических ресурсов)"""
|
||||||
|
|
||||||
|
name = "monitoring_tar"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.data_dict = {}
|
||||||
|
self.df = None
|
||||||
|
|
||||||
|
# Регистрируем геттеры
|
||||||
|
self.register_getter('get_tar_data', self._get_tar_data_wrapper, required_params=['mode'])
|
||||||
|
self.register_getter('get_tar_full_data', self._get_tar_full_data_wrapper, required_params=[])
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: Dict[str, Any] = None) -> pd.DataFrame:
|
||||||
|
"""Парсит ZIP архив с файлами мониторинга ТЭР"""
|
||||||
|
logger.debug(f"🔍 MonitoringTarParser.parse вызван с файлом: {file_path}")
|
||||||
|
|
||||||
|
if not file_path.endswith('.zip'):
|
||||||
|
raise ValueError("MonitoringTarParser поддерживает только ZIP архивы")
|
||||||
|
|
||||||
|
# Обрабатываем ZIP архив
|
||||||
|
result = self._parse_zip_archive(file_path)
|
||||||
|
|
||||||
|
# Конвертируем результат в DataFrame для совместимости с ReportService
|
||||||
|
if result:
|
||||||
|
data_list = []
|
||||||
|
for id, data in result.items():
|
||||||
|
data_list.append({
|
||||||
|
'id': id,
|
||||||
|
'data': data,
|
||||||
|
'records_count': len(data.get('total', [])) + len(data.get('last_day', []))
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(data_list)
|
||||||
|
logger.debug(f"🔍 Создан DataFrame с {len(df)} записями")
|
||||||
|
return df
|
||||||
|
else:
|
||||||
|
logger.debug("🔍 Возвращаем пустой DataFrame")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
def _parse_zip_archive(self, zip_path: str) -> Dict[str, Any]:
|
||||||
|
"""Парсит ZIP архив с файлами мониторинга ТЭР"""
|
||||||
|
logger.info(f"📦 Обработка ZIP архива: {zip_path}")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(temp_dir)
|
||||||
|
|
||||||
|
# Ищем файлы мониторинга ТЭР
|
||||||
|
tar_files = []
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file in files:
|
||||||
|
# Поддерживаем файлы svodka_tar_*.xlsx (основные) и monitoring_*.xlsm (альтернативные)
|
||||||
|
if (file.startswith('svodka_tar_') and file.endswith('.xlsx')) or (file.startswith('monitoring_') and file.endswith('.xlsm')):
|
||||||
|
tar_files.append(os.path.join(root, file))
|
||||||
|
|
||||||
|
if not tar_files:
|
||||||
|
raise ValueError("В архиве не найдены файлы мониторинга ТЭР")
|
||||||
|
|
||||||
|
logger.info(f"📁 Найдено {len(tar_files)} файлов мониторинга ТЭР")
|
||||||
|
|
||||||
|
# Обрабатываем каждый файл
|
||||||
|
all_data = {}
|
||||||
|
for file_path in tar_files:
|
||||||
|
logger.info(f"📁 Обработка файла: {file_path}")
|
||||||
|
|
||||||
|
# Извлекаем номер месяца из имени файла
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
month_str = self._extract_month_from_filename(filename)
|
||||||
|
logger.debug(f"📅 Месяц: {month_str}")
|
||||||
|
|
||||||
|
# Парсим файл
|
||||||
|
file_data = self._parse_single_file(file_path, month_str)
|
||||||
|
if file_data:
|
||||||
|
all_data.update(file_data)
|
||||||
|
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
def _extract_month_from_filename(self, filename: str) -> str:
|
||||||
|
"""Извлекает номер месяца из имени файла"""
|
||||||
|
# Для файлов типа svodka_tar_SNPZ_01.xlsx или monitoring_SNPZ_01.xlsm
|
||||||
|
parts = filename.split('_')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
month_part = parts[-1].split('.')[0] # Убираем расширение
|
||||||
|
if month_part.isdigit():
|
||||||
|
return month_part
|
||||||
|
return "01" # По умолчанию
|
||||||
|
|
||||||
|
def _parse_single_file(self, file_path: str, month_str: str) -> Dict[str, Any]:
|
||||||
|
"""Парсит один файл мониторинга ТЭР"""
|
||||||
|
try:
|
||||||
|
excel_file = pd.ExcelFile(file_path)
|
||||||
|
available_sheets = excel_file.sheet_names
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Не удалось открыть Excel-файл {file_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Словарь для хранения данных: id -> {'total': [], 'last_day': []}
|
||||||
|
df_svodka_tar = {}
|
||||||
|
|
||||||
|
# Определяем тип файла и обрабатываем соответственно
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
|
||||||
|
if filename.startswith('svodka_tar_'):
|
||||||
|
# Обрабатываем файлы svodka_tar_*.xlsx с SNPZ_IDS
|
||||||
|
for name, id in SNPZ_IDS.items():
|
||||||
|
if name not in available_sheets:
|
||||||
|
logger.warning(f"🟡 Лист '{name}' отсутствует в файле {file_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Парсим оба типа строк
|
||||||
|
result = self._parse_monitoring_tar_single(file_path, name, month_str)
|
||||||
|
|
||||||
|
# Инициализируем структуру для id
|
||||||
|
if id not in df_svodka_tar:
|
||||||
|
df_svodka_tar[id] = {'total': [], 'last_day': []}
|
||||||
|
|
||||||
|
if isinstance(result['total'], pd.DataFrame) and not result['total'].empty:
|
||||||
|
df_svodka_tar[id]['total'].append(result['total'])
|
||||||
|
|
||||||
|
if isinstance(result['last_day'], pd.DataFrame) and not result['last_day'].empty:
|
||||||
|
df_svodka_tar[id]['last_day'].append(result['last_day'])
|
||||||
|
|
||||||
|
elif filename.startswith('monitoring_'):
|
||||||
|
# Обрабатываем файлы monitoring_*.xlsm с альтернативными листами
|
||||||
|
monitoring_sheets = {
|
||||||
|
'Мониторинг потребления': 'SNPZ.MONITORING',
|
||||||
|
'Исходные данные': 'SNPZ.SOURCE_DATA'
|
||||||
|
}
|
||||||
|
|
||||||
|
for sheet_name, id in monitoring_sheets.items():
|
||||||
|
if sheet_name not in available_sheets:
|
||||||
|
logger.warning(f"🟡 Лист '{sheet_name}' отсутствует в файле {file_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Парсим оба типа строк
|
||||||
|
result = self._parse_monitoring_tar_single(file_path, sheet_name, month_str)
|
||||||
|
|
||||||
|
# Инициализируем структуру для id
|
||||||
|
if id not in df_svodka_tar:
|
||||||
|
df_svodka_tar[id] = {'total': [], 'last_day': []}
|
||||||
|
|
||||||
|
if isinstance(result['total'], pd.DataFrame) and not result['total'].empty:
|
||||||
|
df_svodka_tar[id]['total'].append(result['total'])
|
||||||
|
|
||||||
|
if isinstance(result['last_day'], pd.DataFrame) and not result['last_day'].empty:
|
||||||
|
df_svodka_tar[id]['last_day'].append(result['last_day'])
|
||||||
|
|
||||||
|
# Агрегация: объединяем списки в DataFrame
|
||||||
|
for id, data in df_svodka_tar.items():
|
||||||
|
if data['total']:
|
||||||
|
df_svodka_tar[id]['total'] = pd.concat(data['total'], ignore_index=True)
|
||||||
|
else:
|
||||||
|
df_svodka_tar[id]['total'] = pd.DataFrame()
|
||||||
|
|
||||||
|
if data['last_day']:
|
||||||
|
df_svodka_tar[id]['last_day'] = pd.concat(data['last_day'], ignore_index=True)
|
||||||
|
else:
|
||||||
|
df_svodka_tar[id]['last_day'] = pd.DataFrame()
|
||||||
|
|
||||||
|
logger.info(f"✅ Агрегировано: {len(df_svodka_tar[id]['total'])} 'total' и "
|
||||||
|
f"{len(df_svodka_tar[id]['last_day'])} 'last_day' записей для id='{id}'")
|
||||||
|
|
||||||
|
return df_svodka_tar
|
||||||
|
|
||||||
|
def _parse_monitoring_tar_single(self, file: str, sheet: str, month_str: str) -> Dict[str, Any]:
|
||||||
|
"""Парсит один файл и лист"""
|
||||||
|
try:
|
||||||
|
# Проверяем наличие листа
|
||||||
|
if sheet not in pd.ExcelFile(file).sheet_names:
|
||||||
|
logger.warning(f"🟡 Лист '{sheet}' не найден в файле {file}")
|
||||||
|
return {'total': None, 'last_day': None}
|
||||||
|
|
||||||
|
# Определяем номер заголовка в зависимости от типа файла
|
||||||
|
filename = os.path.basename(file)
|
||||||
|
if filename.startswith('svodka_tar_'):
|
||||||
|
# Для файлов svodka_tar_*.xlsx ищем заголовок по значению "1"
|
||||||
|
header_num = find_header_row(file, sheet, search_value="1")
|
||||||
|
if header_num is None:
|
||||||
|
logger.error(f"❌ Не найдена строка с заголовком '1' в файле {file}, лист '{sheet}'")
|
||||||
|
return {'total': None, 'last_day': None}
|
||||||
|
elif filename.startswith('monitoring_'):
|
||||||
|
# Для файлов monitoring_*.xlsm заголовок находится в строке 5
|
||||||
|
header_num = 5
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ Неизвестный тип файла: {filename}")
|
||||||
|
return {'total': None, 'last_day': None}
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Используем заголовок в строке {header_num} для листа '{sheet}'")
|
||||||
|
|
||||||
|
# Читаем с двумя уровнями заголовков
|
||||||
|
df = pd.read_excel(
|
||||||
|
file,
|
||||||
|
sheet_name=sheet,
|
||||||
|
header=header_num,
|
||||||
|
index_col=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Убираем мультииндекс: оставляем первый уровень
|
||||||
|
df.columns = df.columns.get_level_values(0)
|
||||||
|
|
||||||
|
# Удаляем строки, где все значения — NaN
|
||||||
|
df = df.dropna(how='all').reset_index(drop=True)
|
||||||
|
if df.empty:
|
||||||
|
logger.warning(f"🟡 Нет данных после очистки в файле {file}, лист '{sheet}'")
|
||||||
|
return {'total': None, 'last_day': None}
|
||||||
|
|
||||||
|
# === 1. Обработка строки "Всего" ===
|
||||||
|
first_col = df.columns[0]
|
||||||
|
mask_total = df[first_col].astype(str).str.strip() == "Всего"
|
||||||
|
df_total = df[mask_total].copy()
|
||||||
|
|
||||||
|
if not df_total.empty:
|
||||||
|
# Заменяем "Всего" на номер месяца в первой колонке
|
||||||
|
df_total.loc[:, first_col] = df_total[first_col].astype(str).str.replace("Всего", month_str, regex=False)
|
||||||
|
df_total = df_total.reset_index(drop=True)
|
||||||
|
else:
|
||||||
|
df_total = pd.DataFrame()
|
||||||
|
|
||||||
|
# === 2. Обработка последней строки (не пустая) ===
|
||||||
|
# Берём последнюю строку из исходного df (не включая "Всего", если она внизу)
|
||||||
|
# Исключим строку "Всего" из "последней строки", если она есть
|
||||||
|
df_no_total = df[~mask_total].dropna(how='all')
|
||||||
|
if not df_no_total.empty:
|
||||||
|
df_last_day = df_no_total.tail(1).copy()
|
||||||
|
df_last_day = df_last_day.reset_index(drop=True)
|
||||||
|
else:
|
||||||
|
df_last_day = pd.DataFrame()
|
||||||
|
|
||||||
|
return {'total': df_total, 'last_day': df_last_day}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при обработке файла {file}, лист '{sheet}': {e}")
|
||||||
|
return {'total': None, 'last_day': None}
|
||||||
|
|
||||||
|
def _get_tar_data_wrapper(self, params: Dict[str, Any] = None) -> str:
|
||||||
|
"""Обертка для получения данных мониторинга ТЭР с фильтрацией по режиму"""
|
||||||
|
logger.debug(f"🔍 _get_tar_data_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Получаем режим из параметров
|
||||||
|
mode = params.get('mode', 'total') if params else 'total'
|
||||||
|
|
||||||
|
# Фильтруем данные по режиму
|
||||||
|
filtered_data = {}
|
||||||
|
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из MinIO
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
id = row['id']
|
||||||
|
data = row['data']
|
||||||
|
if isinstance(data, dict) and mode in data:
|
||||||
|
filtered_data[id] = data[mode]
|
||||||
|
else:
|
||||||
|
filtered_data[id] = pd.DataFrame()
|
||||||
|
elif hasattr(self, 'data_dict') and self.data_dict:
|
||||||
|
# Локальные данные
|
||||||
|
for id, data in self.data_dict.items():
|
||||||
|
if isinstance(data, dict) and mode in data:
|
||||||
|
filtered_data[id] = data[mode]
|
||||||
|
else:
|
||||||
|
filtered_data[id] = pd.DataFrame()
|
||||||
|
|
||||||
|
# Конвертируем в JSON
|
||||||
|
try:
|
||||||
|
result_json = data_to_json(filtered_data)
|
||||||
|
return result_json
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при конвертации данных в JSON: {e}")
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
def _get_tar_full_data_wrapper(self, params: Dict[str, Any] = None) -> str:
|
||||||
|
"""Обертка для получения всех данных мониторинга ТЭР"""
|
||||||
|
logger.debug(f"🔍 _get_tar_full_data_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Получаем все данные
|
||||||
|
full_data = {}
|
||||||
|
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из MinIO
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
id = row['id']
|
||||||
|
data = row['data']
|
||||||
|
full_data[id] = data
|
||||||
|
elif hasattr(self, 'data_dict') and self.data_dict:
|
||||||
|
# Локальные данные
|
||||||
|
full_data = self.data_dict
|
||||||
|
|
||||||
|
# Конвертируем в JSON
|
||||||
|
try:
|
||||||
|
result_json = data_to_json(full_data)
|
||||||
|
return result_json
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при конвертации данных в JSON: {e}")
|
||||||
|
return "{}"
|
||||||
285
python_parser/adapters/parsers/oper_spravka_tech_pos.py
Normal file
285
python_parser/adapters/parsers/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
from core.ports import ParserPort
|
||||||
|
from adapters.pconfig import find_header_row, get_object_by_name, data_to_json
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OperSpravkaTechPosParser(ParserPort):
|
||||||
|
"""Парсер для операционных справок технологических позиций"""
|
||||||
|
|
||||||
|
name = "oper_spravka_tech_pos"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.data_dict = {}
|
||||||
|
self.df = None
|
||||||
|
|
||||||
|
# Регистрируем геттер
|
||||||
|
self.register_getter('get_tech_pos', self._get_tech_pos_wrapper, required_params=['id'])
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: Dict[str, Any] = None) -> pd.DataFrame:
|
||||||
|
"""Парсит ZIP архив с файлами операционных справок технологических позиций"""
|
||||||
|
logger.debug(f"🔍 OperSpravkaTechPosParser.parse вызван с файлом: {file_path}")
|
||||||
|
|
||||||
|
if not file_path.endswith('.zip'):
|
||||||
|
raise ValueError("OperSpravkaTechPosParser поддерживает только ZIP архивы")
|
||||||
|
|
||||||
|
# Обрабатываем ZIP архив
|
||||||
|
result = self._parse_zip_archive(file_path)
|
||||||
|
|
||||||
|
# Конвертируем результат в DataFrame для совместимости с ReportService
|
||||||
|
if result:
|
||||||
|
data_list = []
|
||||||
|
for id, data in result.items():
|
||||||
|
if data is not None and not data.empty:
|
||||||
|
records = data.to_dict(orient='records')
|
||||||
|
data_list.append({
|
||||||
|
'id': id,
|
||||||
|
'data': records,
|
||||||
|
'records_count': len(records)
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(data_list)
|
||||||
|
logger.debug(f"🔍 Создан DataFrame с {len(df)} записями")
|
||||||
|
return df
|
||||||
|
else:
|
||||||
|
logger.debug("🔍 Возвращаем пустой DataFrame")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
def _parse_zip_archive(self, zip_path: str) -> Dict[str, pd.DataFrame]:
|
||||||
|
"""Парсит ZIP архив с файлами операционных справок"""
|
||||||
|
logger.info(f"📦 Обработка ZIP архива: {zip_path}")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(temp_dir)
|
||||||
|
|
||||||
|
# Ищем файлы операционных справок
|
||||||
|
tech_pos_files = []
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file in files:
|
||||||
|
if (file.startswith('oper_spavka_tech_pos_') or
|
||||||
|
file.startswith('oper_spravka_tech_pos_')) and file.endswith(('.xlsx', '.xls', '.xlsm')):
|
||||||
|
tech_pos_files.append(os.path.join(root, file))
|
||||||
|
|
||||||
|
if not tech_pos_files:
|
||||||
|
raise ValueError("В архиве не найдены файлы операционных справок технологических позиций")
|
||||||
|
|
||||||
|
logger.info(f"📁 Найдено {len(tech_pos_files)} файлов операционных справок")
|
||||||
|
|
||||||
|
# Обрабатываем каждый файл
|
||||||
|
all_data = {}
|
||||||
|
for file_path in tech_pos_files:
|
||||||
|
logger.info(f"📁 Обработка файла: {file_path}")
|
||||||
|
|
||||||
|
# Извлекаем ID ОГ из имени файла
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
og_id = self._extract_og_id_from_filename(filename)
|
||||||
|
logger.debug(f"🏭 ОГ ID: {og_id}")
|
||||||
|
|
||||||
|
# Парсим файл
|
||||||
|
file_data = self._parse_single_file(file_path)
|
||||||
|
if file_data:
|
||||||
|
all_data.update(file_data)
|
||||||
|
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
def _extract_og_id_from_filename(self, filename: str) -> str:
|
||||||
|
"""Извлекает ID ОГ из имени файла"""
|
||||||
|
# Для файлов типа oper_spavka_tech_pos_SNPZ.xlsx
|
||||||
|
parts = filename.split('_')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
og_id = parts[-1].split('.')[0] # Убираем расширение
|
||||||
|
return og_id
|
||||||
|
return "UNKNOWN"
|
||||||
|
|
||||||
|
def _parse_single_file(self, file_path: str) -> Dict[str, pd.DataFrame]:
|
||||||
|
"""Парсит один файл операционной справки"""
|
||||||
|
try:
|
||||||
|
# Находим актуальный лист
|
||||||
|
actual_sheet = self._find_actual_sheet_num(file_path)
|
||||||
|
logger.debug(f"📅 Актуальный лист: {actual_sheet}")
|
||||||
|
|
||||||
|
# Находим заголовок
|
||||||
|
header_row = self._find_header_row(file_path, actual_sheet)
|
||||||
|
logger.debug(f"📋 Заголовок найден в строке {header_row}")
|
||||||
|
|
||||||
|
# Парсим данные
|
||||||
|
df = self._parse_tech_pos_data(file_path, actual_sheet, header_row)
|
||||||
|
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
# Извлекаем ID ОГ из имени файла
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
og_id = self._extract_og_id_from_filename(filename)
|
||||||
|
return {og_id: df}
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Нет данных в файле {file_path}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при обработке файла {file_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _find_actual_sheet_num(self, file_path: str) -> str:
|
||||||
|
"""Поиск номера актуального листа"""
|
||||||
|
current_day = datetime.now().day
|
||||||
|
current_month = datetime.now().month
|
||||||
|
|
||||||
|
actual_sheet = f"{current_day:02d}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Читаем все листы от 1 до текущего дня
|
||||||
|
all_sheets = {}
|
||||||
|
for day in range(1, current_day + 1):
|
||||||
|
sheet_num = f"{day:02d}"
|
||||||
|
try:
|
||||||
|
df_temp = pd.read_excel(file_path, sheet_name=sheet_num, usecols=[1], nrows=2, header=None)
|
||||||
|
all_sheets[sheet_num] = df_temp
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Идем от текущего дня к 1
|
||||||
|
for day in range(current_day, 0, -1):
|
||||||
|
sheet_num = f"{day:02d}"
|
||||||
|
if sheet_num in all_sheets:
|
||||||
|
df_temp = all_sheets[sheet_num]
|
||||||
|
if df_temp.shape[0] > 1:
|
||||||
|
date_str = df_temp.iloc[1, 0] # B2
|
||||||
|
|
||||||
|
if pd.notna(date_str):
|
||||||
|
try:
|
||||||
|
date = pd.to_datetime(date_str)
|
||||||
|
# Проверяем совпадение месяца даты с текущим месяцем
|
||||||
|
if date.month == current_month:
|
||||||
|
actual_sheet = sheet_num
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка при поиске актуального листа: {e}")
|
||||||
|
|
||||||
|
return actual_sheet
|
||||||
|
|
||||||
|
def _find_header_row(self, file_path: str, sheet_name: str, search_value: str = "Загрузка основных процессов") -> int:
|
||||||
|
"""Определение индекса заголовка в Excel по ключевому слову"""
|
||||||
|
try:
|
||||||
|
# Читаем первый столбец
|
||||||
|
df_temp = pd.read_excel(file_path, sheet_name=sheet_name, usecols=[0])
|
||||||
|
|
||||||
|
# Ищем строку с искомым значением
|
||||||
|
for idx, row in df_temp.iterrows():
|
||||||
|
if row.astype(str).str.contains(search_value, case=False, regex=False).any():
|
||||||
|
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
||||||
|
return idx + 1 # возвращаем индекс строки (0-based), который будет использован как `header=`
|
||||||
|
|
||||||
|
raise ValueError(f"Не найдена строка с заголовком '{search_value}'.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при поиске заголовка: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _parse_tech_pos_data(self, file_path: str, sheet_name: str, header_row: int) -> pd.DataFrame:
|
||||||
|
"""Парсинг данных технологических позиций"""
|
||||||
|
try:
|
||||||
|
valid_processes = ['Первичная переработка', 'Гидроочистка топлив', 'Риформирование', 'Изомеризация']
|
||||||
|
|
||||||
|
df_temp = pd.read_excel(
|
||||||
|
file_path,
|
||||||
|
sheet_name=sheet_name,
|
||||||
|
header=header_row + 1, # Исправлено: добавляем +1 как в оригинале
|
||||||
|
usecols=range(1, 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Прочитано {len(df_temp)} строк из Excel")
|
||||||
|
logger.debug(f"🔍 Колонки: {list(df_temp.columns)}")
|
||||||
|
|
||||||
|
# Фильтруем по валидным процессам
|
||||||
|
df_cleaned = df_temp[
|
||||||
|
df_temp['Процесс'].str.strip().isin(valid_processes) &
|
||||||
|
df_temp['Процесс'].notna()
|
||||||
|
].copy()
|
||||||
|
|
||||||
|
logger.debug(f"🔍 После фильтрации осталось {len(df_cleaned)} строк")
|
||||||
|
|
||||||
|
if df_cleaned.empty:
|
||||||
|
logger.warning("⚠️ Нет данных после фильтрации по процессам")
|
||||||
|
logger.debug(f"🔍 Доступные процессы в данных: {df_temp['Процесс'].unique()}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
df_cleaned['Процесс'] = df_cleaned['Процесс'].astype(str).str.strip()
|
||||||
|
|
||||||
|
# Добавляем ID установки
|
||||||
|
if 'Установка' in df_cleaned.columns:
|
||||||
|
df_cleaned['id'] = df_cleaned['Установка'].apply(get_object_by_name)
|
||||||
|
logger.debug(f"🔍 Добавлены ID установок: {df_cleaned['id'].unique()}")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Колонка 'Установка' не найдена")
|
||||||
|
|
||||||
|
logger.info(f"✅ Получено {len(df_cleaned)} записей")
|
||||||
|
return df_cleaned
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при парсинге данных: {e}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
def _get_tech_pos_wrapper(self, params: Dict[str, Any] = None) -> str:
|
||||||
|
"""Обертка для получения данных технологических позиций"""
|
||||||
|
logger.debug(f"🔍 _get_tech_pos_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Получаем ID ОГ из параметров
|
||||||
|
og_id = params.get('id') if params else None
|
||||||
|
if not og_id:
|
||||||
|
logger.error("❌ Не указан ID ОГ")
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
# Получаем данные
|
||||||
|
tech_pos_data = {}
|
||||||
|
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из MinIO
|
||||||
|
logger.debug(f"🔍 Ищем данные для ОГ '{og_id}' в DataFrame с {len(self.df)} записями")
|
||||||
|
available_ogs = self.df['id'].tolist()
|
||||||
|
logger.debug(f"🔍 Доступные ОГ в данных: {available_ogs}")
|
||||||
|
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
if row['id'] == og_id:
|
||||||
|
tech_pos_data = row['data']
|
||||||
|
logger.info(f"✅ Найдены данные для ОГ '{og_id}': {len(tech_pos_data)} записей")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"❌ Данные для ОГ '{og_id}' не найдены")
|
||||||
|
elif hasattr(self, 'data_dict') and self.data_dict:
|
||||||
|
# Локальные данные
|
||||||
|
logger.debug(f"🔍 Ищем данные для ОГ '{og_id}' в data_dict")
|
||||||
|
available_ogs = list(self.data_dict.keys())
|
||||||
|
logger.debug(f"🔍 Доступные ОГ в data_dict: {available_ogs}")
|
||||||
|
|
||||||
|
if og_id in self.data_dict:
|
||||||
|
tech_pos_data = self.data_dict[og_id].to_dict(orient='records')
|
||||||
|
logger.info(f"✅ Найдены данные для ОГ '{og_id}': {len(tech_pos_data)} записей")
|
||||||
|
else:
|
||||||
|
logger.warning(f"❌ Данные для ОГ '{og_id}' не найдены в data_dict")
|
||||||
|
|
||||||
|
# Конвертируем в список записей
|
||||||
|
try:
|
||||||
|
if isinstance(tech_pos_data, pd.DataFrame):
|
||||||
|
# Если это DataFrame, конвертируем в список словарей
|
||||||
|
result_list = tech_pos_data.to_dict(orient='records')
|
||||||
|
logger.debug(f"🔍 Конвертировано в список: {len(result_list)} записей")
|
||||||
|
return result_list
|
||||||
|
elif isinstance(tech_pos_data, list):
|
||||||
|
# Если уже список, возвращаем как есть
|
||||||
|
logger.debug(f"🔍 Уже список: {len(tech_pos_data)} записей")
|
||||||
|
return tech_pos_data
|
||||||
|
else:
|
||||||
|
logger.warning(f"🔍 Неожиданный тип данных: {type(tech_pos_data)}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при конвертации данных: {e}")
|
||||||
|
return []
|
||||||
345
python_parser/adapters/parsers/statuses_repair_ca.py
Normal file
345
python_parser/adapters/parsers/statuses_repair_ca.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List, Tuple, Optional
|
||||||
|
from core.ports import ParserPort
|
||||||
|
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
||||||
|
from app.schemas.statuses_repair_ca import StatusesRepairCARequest
|
||||||
|
from adapters.pconfig import find_header_row, get_og_by_name, data_to_json
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusesRepairCAParser(ParserPort):
|
||||||
|
"""Парсер для статусов ремонта СА"""
|
||||||
|
|
||||||
|
name = "Статусы ремонта СА"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="get_repair_statuses",
|
||||||
|
method=self._get_repair_statuses_wrapper,
|
||||||
|
schema_class=StatusesRepairCARequest,
|
||||||
|
description="Получение статусов ремонта по ОГ и ключам"
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: dict) -> Dict[str, Any]:
|
||||||
|
"""Парсинг файла статусов ремонта СА"""
|
||||||
|
logger.debug(f"🔍 StatusesRepairCAParser.parse вызван с файлом: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Определяем тип файла
|
||||||
|
if file_path.endswith('.zip'):
|
||||||
|
return self._parse_zip_file(file_path)
|
||||||
|
elif file_path.endswith(('.xlsx', '.xls')):
|
||||||
|
return self._parse_excel_file(file_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Неподдерживаемый формат файла: {file_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при парсинге файла {file_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _parse_zip_file(self, zip_path: str) -> Dict[str, Any]:
|
||||||
|
"""Парсинг ZIP архива"""
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(temp_dir)
|
||||||
|
|
||||||
|
# Ищем Excel файл в архиве
|
||||||
|
excel_files = []
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file in files:
|
||||||
|
if file.endswith(('.xlsx', '.xls')):
|
||||||
|
excel_files.append(os.path.join(root, file))
|
||||||
|
|
||||||
|
if not excel_files:
|
||||||
|
raise ValueError("В архиве не найдено Excel файлов")
|
||||||
|
|
||||||
|
# Берем первый найденный Excel файл
|
||||||
|
excel_file = excel_files[0]
|
||||||
|
logger.debug(f"🔍 Найден Excel файл в архиве: {excel_file}")
|
||||||
|
|
||||||
|
return self._parse_excel_file(excel_file)
|
||||||
|
|
||||||
|
def _parse_excel_file(self, file_path: str) -> Dict[str, Any]:
|
||||||
|
"""Парсинг Excel файла"""
|
||||||
|
logger.debug(f"🔍 Парсинг Excel файла: {file_path}")
|
||||||
|
|
||||||
|
# Парсим данные
|
||||||
|
df_statuses = self._parse_statuses_repair_ca(file_path, 0)
|
||||||
|
|
||||||
|
if df_statuses.empty:
|
||||||
|
logger.warning("⚠️ Нет данных после парсинга")
|
||||||
|
return {"data": [], "records_count": 0}
|
||||||
|
|
||||||
|
# Преобразуем в список словарей для хранения
|
||||||
|
data_list = self._data_to_structured_json(df_statuses)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"data": data_list,
|
||||||
|
"records_count": len(data_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Устанавливаем данные в парсер для использования в геттерах
|
||||||
|
self.data_dict = result
|
||||||
|
|
||||||
|
logger.info(f"✅ Парсинг завершен. Получено {len(data_list)} записей")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_statuses_repair_ca(self, file: str, sheet: int, header_num: Optional[int] = None) -> pd.DataFrame:
|
||||||
|
"""Парсинг отчетов статусов ремонта"""
|
||||||
|
|
||||||
|
# === ШАГ 1: Создание MultiIndex ===
|
||||||
|
columns_level_1 = [
|
||||||
|
'id',
|
||||||
|
'ОГ',
|
||||||
|
'Дата начала ремонта',
|
||||||
|
'Готовность к КР',
|
||||||
|
'Отставание / опережение подготовки к КР',
|
||||||
|
'Заключение договоров на СМР',
|
||||||
|
'Поставка МТР'
|
||||||
|
]
|
||||||
|
|
||||||
|
sub_columns_cmp = {
|
||||||
|
'ДВ': ['всего', 'плановая дата', 'факт', '%'],
|
||||||
|
'Сметы': ['всего', 'плановая дата', 'факт', '%'],
|
||||||
|
'Формирование лотов': ['всего', 'плановая дата', 'факт', '%'],
|
||||||
|
'Договор': ['всего', 'плановая дата', 'факт', '%']
|
||||||
|
}
|
||||||
|
|
||||||
|
sub_columns_mtp = {
|
||||||
|
'Выполнение плана на текущую дату': ['инициирования закупок', 'заключения договоров', 'поставки'],
|
||||||
|
'На складе, позиций': ['всего', 'поставлено', '%', 'динамика за прошедшую неделю, поз.']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Формируем MultiIndex — ВСЕ кортежи длиной 3
|
||||||
|
cols = []
|
||||||
|
for col1 in columns_level_1:
|
||||||
|
if col1 == 'id':
|
||||||
|
cols.append((col1, '', ''))
|
||||||
|
elif col1 == 'ОГ':
|
||||||
|
cols.append((col1, '', ''))
|
||||||
|
elif col1 == 'Дата начала ремонта':
|
||||||
|
cols.append((col1, '', ''))
|
||||||
|
elif col1 == 'Готовность к КР':
|
||||||
|
cols.extend([(col1, 'План', ''), (col1, 'Факт', '')])
|
||||||
|
elif col1 == 'Отставание / опережение подготовки к КР':
|
||||||
|
cols.extend([
|
||||||
|
(col1, 'Отставание / опережение', ''),
|
||||||
|
(col1, 'Динамика за прошедшую неделю', '')
|
||||||
|
])
|
||||||
|
elif col1 == 'Заключение договоров на СМР':
|
||||||
|
for subcol, sub_sub_cols in sub_columns_cmp.items():
|
||||||
|
for ssc in sub_sub_cols:
|
||||||
|
cols.append((col1, subcol, ssc))
|
||||||
|
elif col1 == 'Поставка МТР':
|
||||||
|
for subcol, sub_sub_cols in sub_columns_mtp.items():
|
||||||
|
for ssc in sub_sub_cols:
|
||||||
|
cols.append((col1, subcol, ssc))
|
||||||
|
else:
|
||||||
|
cols.append((col1, '', ''))
|
||||||
|
|
||||||
|
# Создаем MultiIndex
|
||||||
|
multi_index = pd.MultiIndex.from_tuples(cols, names=['Level1', 'Level2', 'Level3'])
|
||||||
|
|
||||||
|
# === ШАГ 2: Читаем данные из Excel ===
|
||||||
|
if header_num is None:
|
||||||
|
header_num = find_header_row(file, sheet, search_value="ОГ")
|
||||||
|
|
||||||
|
df_data = pd.read_excel(
|
||||||
|
file,
|
||||||
|
skiprows=header_num + 3,
|
||||||
|
header=None,
|
||||||
|
index_col=0,
|
||||||
|
engine='openpyxl'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Убираем строки с пустыми данными
|
||||||
|
df_data.dropna(how='all', inplace=True)
|
||||||
|
|
||||||
|
# Применяем функцию get_og_by_name для 'id'
|
||||||
|
df_data['id'] = df_data.iloc[:, 0].copy()
|
||||||
|
df_data['id'] = df_data['id'].apply(get_og_by_name)
|
||||||
|
|
||||||
|
# Перемещаем 'id' на первое место
|
||||||
|
cols = ['id'] + [col for col in df_data.columns if col != 'id']
|
||||||
|
df_data = df_data[cols]
|
||||||
|
|
||||||
|
# Удаляем строки с пустым id
|
||||||
|
df_data = df_data.dropna(subset=['id'])
|
||||||
|
df_data = df_data[df_data['id'].astype(str).str.strip() != '']
|
||||||
|
|
||||||
|
# Сбрасываем индекс
|
||||||
|
df_data = df_data.reset_index(drop=True)
|
||||||
|
|
||||||
|
# Выбираем 4-ю колонку (индекс 3) для фильтрации
|
||||||
|
col_index = 3
|
||||||
|
numeric_series = pd.to_numeric(df_data.iloc[:, col_index], errors='coerce')
|
||||||
|
|
||||||
|
# Фильтруем: оставляем только строки, где значение — число
|
||||||
|
mask = pd.notna(numeric_series)
|
||||||
|
df_data = df_data[mask].copy()
|
||||||
|
|
||||||
|
# === ШАГ 3: Применяем MultiIndex к данным ===
|
||||||
|
df_data.columns = multi_index
|
||||||
|
|
||||||
|
return df_data
|
||||||
|
|
||||||
|
def _data_to_structured_json(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||||
|
"""Преобразование DataFrame с MultiIndex в структурированный JSON"""
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result_list = []
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
result = {}
|
||||||
|
for col in df.columns:
|
||||||
|
value = row[col]
|
||||||
|
# Пропускаем NaN
|
||||||
|
if pd.isna(value):
|
||||||
|
value = None
|
||||||
|
|
||||||
|
# Распаковываем уровни
|
||||||
|
level1, level2, level3 = col
|
||||||
|
|
||||||
|
# Убираем пустые/неинформативные значения
|
||||||
|
level1 = str(level1).strip() if level1 else ""
|
||||||
|
level2 = str(level2).strip() if level2 else None
|
||||||
|
level3 = str(level3).strip() if level3 else None
|
||||||
|
|
||||||
|
# Обработка id и ОГ — выносим на верх
|
||||||
|
if level1 == "id":
|
||||||
|
result["id"] = value
|
||||||
|
elif level1 == "ОГ":
|
||||||
|
result["name"] = value
|
||||||
|
else:
|
||||||
|
# Группируем по Level1
|
||||||
|
if level1 not in result:
|
||||||
|
result[level1] = {}
|
||||||
|
|
||||||
|
# Вложенные уровни
|
||||||
|
if level2 and level3:
|
||||||
|
if level2 not in result[level1]:
|
||||||
|
result[level1][level2] = {}
|
||||||
|
result[level1][level2][level3] = value
|
||||||
|
elif level2:
|
||||||
|
result[level1][level2] = value
|
||||||
|
else:
|
||||||
|
result[level1] = value
|
||||||
|
|
||||||
|
result_list.append(result)
|
||||||
|
|
||||||
|
return result_list
|
||||||
|
|
||||||
|
def _get_repair_statuses_wrapper(self, params: dict):
|
||||||
|
"""Обертка для получения статусов ремонта"""
|
||||||
|
logger.debug(f"🔍 _get_repair_statuses_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Валидация параметров
|
||||||
|
validated_params = validate_params_with_schema(params, StatusesRepairCARequest)
|
||||||
|
|
||||||
|
ids = validated_params.get('ids')
|
||||||
|
keys = validated_params.get('keys')
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Запрошенные ОГ: {ids}")
|
||||||
|
logger.debug(f"🔍 Запрошенные ключи: {keys}")
|
||||||
|
|
||||||
|
# Получаем данные из парсера
|
||||||
|
if hasattr(self, 'df') and self.df is not None:
|
||||||
|
# Данные загружены из MinIO
|
||||||
|
if isinstance(self.df, dict):
|
||||||
|
# Это словарь (как в других парсерах)
|
||||||
|
data_source = self.df.get('data', [])
|
||||||
|
elif hasattr(self.df, 'columns') and 'data' in self.df.columns:
|
||||||
|
# Это DataFrame
|
||||||
|
data_source = []
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
if row['data']:
|
||||||
|
data_source.extend(row['data'])
|
||||||
|
else:
|
||||||
|
data_source = []
|
||||||
|
elif hasattr(self, 'data_dict') and self.data_dict:
|
||||||
|
# Данные из локального парсинга
|
||||||
|
data_source = self.data_dict.get('data', [])
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Нет данных в парсере")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Используем данные с {len(data_source)} записями")
|
||||||
|
|
||||||
|
# Фильтруем данные
|
||||||
|
filtered_data = self._filter_statuses_data(data_source, ids, keys)
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Отфильтровано {len(filtered_data)} записей")
|
||||||
|
return filtered_data
|
||||||
|
|
||||||
|
def _filter_statuses_data(self, data_source: List[Dict], ids: Optional[List[str]], keys: Optional[List[List[str]]]) -> List[Dict]:
|
||||||
|
"""Фильтрация данных по ОГ и ключам"""
|
||||||
|
if not data_source:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Если не указаны фильтры, возвращаем все данные
|
||||||
|
if not ids and not keys:
|
||||||
|
return data_source
|
||||||
|
|
||||||
|
filtered_data = []
|
||||||
|
|
||||||
|
for item in data_source:
|
||||||
|
# Фильтр по ОГ
|
||||||
|
if ids is not None:
|
||||||
|
item_id = item.get('id')
|
||||||
|
if item_id not in ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Если указаны ключи, извлекаем только нужные поля
|
||||||
|
if keys is not None:
|
||||||
|
filtered_item = self._extract_keys_from_item(item, keys)
|
||||||
|
if filtered_item:
|
||||||
|
filtered_data.append(filtered_item)
|
||||||
|
else:
|
||||||
|
filtered_data.append(item)
|
||||||
|
|
||||||
|
return filtered_data
|
||||||
|
|
||||||
|
def _extract_keys_from_item(self, item: Dict[str, Any], keys: List[List[str]]) -> Dict[str, Any]:
|
||||||
|
"""Извлечение указанных ключей из элемента"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Всегда добавляем id и name
|
||||||
|
if 'id' in item:
|
||||||
|
result['id'] = item['id']
|
||||||
|
if 'name' in item:
|
||||||
|
result['name'] = item['name']
|
||||||
|
|
||||||
|
# Извлекаем указанные ключи
|
||||||
|
for key_path in keys:
|
||||||
|
if not key_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = item
|
||||||
|
for key in key_path:
|
||||||
|
if isinstance(value, dict) and key in value:
|
||||||
|
value = value[key]
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
|
break
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
# Строим вложенную структуру
|
||||||
|
current = result
|
||||||
|
for i, key in enumerate(key_path):
|
||||||
|
if i == len(key_path) - 1:
|
||||||
|
current[key] = value
|
||||||
|
else:
|
||||||
|
if key not in current:
|
||||||
|
current[key] = {}
|
||||||
|
current = current[key]
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -1,18 +1,229 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import logging
|
||||||
|
|
||||||
from core.ports import ParserPort
|
from core.ports import ParserPort
|
||||||
|
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
||||||
|
from app.schemas.svodka_ca import SvodkaCARequest
|
||||||
from adapters.pconfig import get_og_by_name
|
from adapters.pconfig import get_og_by_name
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SvodkaCAParser(ParserPort):
|
class SvodkaCAParser(ParserPort):
|
||||||
"""Парсер для сводки СА"""
|
"""Парсер для сводок СА"""
|
||||||
|
|
||||||
name = "Сводка СА"
|
name = "Сводки СА"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
# Используем схемы Pydantic как единый источник правды
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="get_ca_data",
|
||||||
|
method=self._get_data_wrapper,
|
||||||
|
schema_class=SvodkaCARequest,
|
||||||
|
description="Получение данных по режимам и таблицам"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_data_wrapper(self, params: dict):
|
||||||
|
"""Получение данных по режимам и таблицам"""
|
||||||
|
logger.debug(f"🔍 _get_data_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, SvodkaCARequest)
|
||||||
|
|
||||||
|
modes = validated_params["modes"]
|
||||||
|
tables = validated_params["tables"]
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Запрошенные режимы: {modes}")
|
||||||
|
logger.debug(f"🔍 Запрошенные таблицы: {tables}")
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
||||||
|
# Данные из парсинга
|
||||||
|
data_source = self.data_dict
|
||||||
|
logger.debug(f"🔍 Используем data_dict с режимами: {list(data_source.keys())}")
|
||||||
|
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
||||||
|
data_source = self._df_to_data_dict()
|
||||||
|
logger.debug(f"🔍 Используем df, преобразованный в data_dict с режимами: {list(data_source.keys())}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"🔍 Нет данных! data_dict={getattr(self, 'data_dict', 'None')}, df={getattr(self, 'df', 'None')}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Фильтруем данные по запрошенным режимам и таблицам
|
||||||
|
result_data = {}
|
||||||
|
for mode in modes:
|
||||||
|
if mode in data_source:
|
||||||
|
result_data[mode] = {}
|
||||||
|
available_tables = list(data_source[mode].keys())
|
||||||
|
logger.debug(f"🔍 Режим '{mode}' содержит таблицы: {available_tables}")
|
||||||
|
for table_name, table_data in data_source[mode].items():
|
||||||
|
# Ищем таблицы по частичному совпадению
|
||||||
|
for requested_table in tables:
|
||||||
|
if requested_table in table_name:
|
||||||
|
result_data[mode][table_name] = table_data
|
||||||
|
logger.debug(f"🔍 Добавлена таблица '{table_name}' (совпадение с '{requested_table}') с {len(table_data)} записями")
|
||||||
|
break # Найдено совпадение, переходим к следующей таблице
|
||||||
|
else:
|
||||||
|
logger.warning(f"🔍 Режим '{mode}' не найден в data_source")
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Итоговый результат содержит режимы: {list(result_data.keys())}")
|
||||||
|
return result_data
|
||||||
|
|
||||||
|
def _df_to_data_dict(self):
|
||||||
|
"""Преобразование DataFrame обратно в словарь данных"""
|
||||||
|
if not hasattr(self, 'df') or self.df is None or self.df.empty:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data_dict = {}
|
||||||
|
|
||||||
|
# Группируем данные по режимам и таблицам
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
mode = row.get('mode')
|
||||||
|
table = row.get('table')
|
||||||
|
data = row.get('data')
|
||||||
|
|
||||||
|
if mode and table and data is not None:
|
||||||
|
if mode not in data_dict:
|
||||||
|
data_dict[mode] = {}
|
||||||
|
data_dict[mode][table] = data
|
||||||
|
|
||||||
|
return data_dict
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
logger.debug(f"🔍 SvodkaCAParser.parse вызван с файлом: {file_path}")
|
||||||
|
|
||||||
|
# Парсим данные и сохраняем словарь для использования в геттерах
|
||||||
|
self.data_dict = self.parse_svodka_ca(file_path, params)
|
||||||
|
|
||||||
|
# Преобразуем словарь в DataFrame для совместимости с services.py
|
||||||
|
# Создаем простой DataFrame с информацией о загруженных данных
|
||||||
|
if self.data_dict:
|
||||||
|
# Создаем DataFrame с информацией о режимах и таблицах
|
||||||
|
data_rows = []
|
||||||
|
for mode, tables in self.data_dict.items():
|
||||||
|
for table_name, table_data in tables.items():
|
||||||
|
if table_data:
|
||||||
|
data_rows.append({
|
||||||
|
'mode': mode,
|
||||||
|
'table': table_name,
|
||||||
|
'rows_count': len(table_data),
|
||||||
|
'data': table_data
|
||||||
|
})
|
||||||
|
|
||||||
|
if data_rows:
|
||||||
|
df = pd.DataFrame(data_rows)
|
||||||
|
self.df = df
|
||||||
|
logger.debug(f"🔍 Создан DataFrame с {len(data_rows)} записями")
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Если данных нет, возвращаем пустой DataFrame
|
||||||
|
self.df = pd.DataFrame()
|
||||||
|
logger.debug(f"🔍 Возвращаем пустой DataFrame")
|
||||||
|
return self.df
|
||||||
|
|
||||||
|
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
|
||||||
|
"""Парсинг сводки СА - работает с тремя листами: План, Факт, Норматив"""
|
||||||
|
logger.debug(f"🔍 Начинаем парсинг сводки СА из файла: {file_path}")
|
||||||
|
|
||||||
|
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
|
||||||
|
|
||||||
|
# Выгружаем План
|
||||||
|
inclusion_list_plan = {
|
||||||
|
"ТиП, %",
|
||||||
|
"Топливо итого, тонн",
|
||||||
|
"Топливо итого, %",
|
||||||
|
"Топливо на технологию, тонн",
|
||||||
|
"Топливо на технологию, %",
|
||||||
|
"Топливо на энергетику, тонн",
|
||||||
|
"Топливо на энергетику, %",
|
||||||
|
"Потери итого, тонн",
|
||||||
|
"Потери итого, %",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
||||||
|
"в т.ч. Неидентифицированные потери, тонн**",
|
||||||
|
"в т.ч. Неидентифицированные потери, %**"
|
||||||
|
}
|
||||||
|
|
||||||
|
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan)
|
||||||
|
logger.debug(f"🔍 Объединённый и отсортированный План: {df_ca_plan.shape if df_ca_plan is not None else 'None'}")
|
||||||
|
|
||||||
|
# Выгружаем Факт
|
||||||
|
inclusion_list_fact = {
|
||||||
|
"ТиП, %",
|
||||||
|
"Топливо итого, тонн",
|
||||||
|
"Топливо итого, %",
|
||||||
|
"Топливо на технологию, тонн",
|
||||||
|
"Топливо на технологию, %",
|
||||||
|
"Топливо на энергетику, тонн",
|
||||||
|
"Топливо на энергетику, %",
|
||||||
|
"Потери итого, тонн",
|
||||||
|
"Потери итого, %",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, тонн",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, %",
|
||||||
|
"в т.ч. Неидентифицированные потери, тонн",
|
||||||
|
"в т.ч. Неидентифицированные потери, %"
|
||||||
|
}
|
||||||
|
|
||||||
|
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact)
|
||||||
|
logger.debug(f"🔍 Объединённый и отсортированный Факт: {df_ca_fact.shape if df_ca_fact is not None else 'None'}")
|
||||||
|
|
||||||
|
# Выгружаем Норматив
|
||||||
|
inclusion_list_normativ = {
|
||||||
|
"Топливо итого, тонн",
|
||||||
|
"Топливо итого, %",
|
||||||
|
"Топливо на технологию, тонн",
|
||||||
|
"Топливо на технологию, %",
|
||||||
|
"Топливо на энергетику, тонн",
|
||||||
|
"Топливо на энергетику, %",
|
||||||
|
"Потери итого, тонн",
|
||||||
|
"Потери итого, %",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
||||||
|
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
||||||
|
"в т.ч. Неидентифицированные потери, тонн**",
|
||||||
|
"в т.ч. Неидентифицированные потери, %**"
|
||||||
|
}
|
||||||
|
|
||||||
|
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
|
||||||
|
logger.debug(f"🔍 Объединённый и отсортированный Норматив: {df_ca_normativ.shape if df_ca_normativ is not None else 'None'}")
|
||||||
|
|
||||||
|
# Преобразуем DataFrame в словарь по режимам и таблицам
|
||||||
|
data_dict = {}
|
||||||
|
|
||||||
|
# Обрабатываем План
|
||||||
|
if df_ca_plan is not None and not df_ca_plan.empty:
|
||||||
|
data_dict['plan'] = {}
|
||||||
|
for table_name, group_df in df_ca_plan.groupby('table'):
|
||||||
|
table_data = group_df.drop('table', axis=1)
|
||||||
|
data_dict['plan'][table_name] = table_data.to_dict('records')
|
||||||
|
|
||||||
|
# Обрабатываем Факт
|
||||||
|
if df_ca_fact is not None and not df_ca_fact.empty:
|
||||||
|
data_dict['fact'] = {}
|
||||||
|
for table_name, group_df in df_ca_fact.groupby('table'):
|
||||||
|
table_data = group_df.drop('table', axis=1)
|
||||||
|
data_dict['fact'][table_name] = table_data.to_dict('records')
|
||||||
|
|
||||||
|
# Обрабатываем Норматив
|
||||||
|
if df_ca_normativ is not None and not df_ca_normativ.empty:
|
||||||
|
data_dict['normativ'] = {}
|
||||||
|
for table_name, group_df in df_ca_normativ.groupby('table'):
|
||||||
|
table_data = group_df.drop('table', axis=1)
|
||||||
|
data_dict['normativ'][table_name] = table_data.to_dict('records')
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Итоговый data_dict содержит режимы: {list(data_dict.keys())}")
|
||||||
|
for mode, tables in data_dict.items():
|
||||||
|
logger.debug(f"🔍 Режим '{mode}' содержит таблицы: {list(tables.keys())}")
|
||||||
|
|
||||||
|
return data_dict
|
||||||
|
|
||||||
def extract_all_tables(self, file_path, sheet_name=0):
|
def extract_all_tables(self, file_path, sheet_name=0):
|
||||||
"""Извлекает все таблицы из Excel файла"""
|
"""Извлечение всех таблиц из Excel файла"""
|
||||||
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
|
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None, engine='openpyxl')
|
||||||
df_filled = df.fillna('')
|
df_filled = df.fillna('')
|
||||||
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
||||||
|
|
||||||
@@ -83,8 +294,8 @@ class SvodkaCAParser(ParserPort):
|
|||||||
return None
|
return None
|
||||||
return name.strip()
|
return name.strip()
|
||||||
|
|
||||||
def parse_sheet(self, file_path, sheet_name, inclusion_list):
|
def parse_sheet(self, file_path: str, sheet_name: str, inclusion_list: set) -> pd.DataFrame:
|
||||||
"""Собственно функция парсинга отчета СА"""
|
"""Парсинг листа Excel"""
|
||||||
# === Извлечение и фильтрация ===
|
# === Извлечение и фильтрация ===
|
||||||
tables = self.extract_all_tables(file_path, sheet_name)
|
tables = self.extract_all_tables(file_path, sheet_name)
|
||||||
|
|
||||||
@@ -161,7 +372,7 @@ class SvodkaCAParser(ParserPort):
|
|||||||
|
|
||||||
# Проверяем, что колонка 'name' существует
|
# Проверяем, что колонка 'name' существует
|
||||||
if 'name' not in df_cleaned.columns:
|
if 'name' not in df_cleaned.columns:
|
||||||
print(
|
logger.debug(
|
||||||
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
|
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
|
||||||
continue # или обработать по-другому
|
continue # или обработать по-другому
|
||||||
else:
|
else:
|
||||||
@@ -190,77 +401,6 @@ class SvodkaCAParser(ParserPort):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
|
||||||
"""Парсинг файла сводки СА"""
|
|
||||||
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
|
|
||||||
# Выгружаем План в df_ca_plan
|
|
||||||
inclusion_list_plan = {
|
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) # ЭТО ДАТАФРЕЙМ ПЛАНА В СВОДКЕ ЦА
|
|
||||||
print(f"\n--- Объединённый и отсортированный План: {df_ca_plan.shape} ---")
|
|
||||||
|
|
||||||
# Выгружаем Факт
|
|
||||||
inclusion_list_fact = {
|
|
||||||
"ТиП, %",
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн",
|
|
||||||
"в т.ч. Неидентифицированные потери, %"
|
|
||||||
}
|
|
||||||
|
|
||||||
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) # ЭТО ДАТАФРЕЙМ ФАКТА В СВОДКЕ ЦА
|
|
||||||
print(f"\n--- Объединённый и отсортированный Факт: {df_ca_fact.shape} ---")
|
|
||||||
|
|
||||||
# Выгружаем План в df_ca_normativ
|
|
||||||
inclusion_list_normativ = {
|
|
||||||
"Топливо итого, тонн",
|
|
||||||
"Топливо итого, %",
|
|
||||||
"Топливо на технологию, тонн",
|
|
||||||
"Топливо на технологию, %",
|
|
||||||
"Топливо на энергетику, тонн",
|
|
||||||
"Топливо на энергетику, %",
|
|
||||||
"Потери итого, тонн",
|
|
||||||
"Потери итого, %",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
|
|
||||||
"в т.ч. Идентифицированные безвозвратные потери, %**",
|
|
||||||
"в т.ч. Неидентифицированные потери, тонн**",
|
|
||||||
"в т.ч. Неидентифицированные потери, %**"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ЭТО ДАТАФРЕЙМ НОРМАТИВА В СВОДКЕ ЦА
|
|
||||||
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
|
|
||||||
|
|
||||||
print(f"\n--- Объединённый и отсортированный Норматив: {df_ca_normativ.shape} ---")
|
|
||||||
|
|
||||||
df_dict = {
|
|
||||||
"plan": df_ca_plan,
|
|
||||||
"fact": df_ca_fact,
|
|
||||||
"normativ": df_ca_normativ
|
|
||||||
}
|
|
||||||
return df_dict
|
|
||||||
|
|
||||||
def data_dict_to_json(self, data_dict):
|
def data_dict_to_json(self, data_dict):
|
||||||
''' Служебная функция для парсинга словаря в json. '''
|
''' Служебная функция для парсинга словаря в json. '''
|
||||||
def convert_types(obj):
|
def convert_types(obj):
|
||||||
@@ -308,17 +448,3 @@ class SvodkaCAParser(ParserPort):
|
|||||||
filtered_df = df[df['table'].isin(table_values)].copy()
|
filtered_df = df[df['table'].isin(table_values)].copy()
|
||||||
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
||||||
return result_dict
|
return result_dict
|
||||||
|
|
||||||
def get_value(self, df: pd.DataFrame, params: dict):
|
|
||||||
|
|
||||||
modes = params.get("modes")
|
|
||||||
tables = params.get("tables")
|
|
||||||
if not isinstance(modes, list):
|
|
||||||
raise ValueError("Поле 'modes' должно быть списком")
|
|
||||||
if not isinstance(tables, list):
|
|
||||||
raise ValueError("Поле 'tables' должно быть списком")
|
|
||||||
# Собираем данные
|
|
||||||
data_dict = {}
|
|
||||||
for mode in modes:
|
|
||||||
data_dict[mode] = self.get_data(df, mode, tables)
|
|
||||||
return self.data_dict_to_json(data_dict)
|
|
||||||
|
|||||||
326
python_parser/adapters/parsers/svodka_pm copy.py
Normal file
326
python_parser/adapters/parsers/svodka_pm copy.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.ports import ParserPort
|
||||||
|
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
||||||
|
from app.schemas.svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
|
||||||
|
from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
|
||||||
|
|
||||||
|
|
||||||
|
class SvodkaPMParser(ParserPort):
|
||||||
|
"""Парсер для сводок ПМ (план и факт)"""
|
||||||
|
|
||||||
|
name = "Сводки ПМ"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
# Используем схемы Pydantic как единый источник правды
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="single_og",
|
||||||
|
method=self._get_single_og,
|
||||||
|
schema_class=SvodkaPMSingleOGRequest,
|
||||||
|
description="Получение данных по одному ОГ"
|
||||||
|
)
|
||||||
|
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="total_ogs",
|
||||||
|
method=self._get_total_ogs,
|
||||||
|
schema_class=SvodkaPMTotalOGsRequest,
|
||||||
|
description="Получение данных по всем ОГ"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_single_og(self, params: dict):
|
||||||
|
"""Получение данных по одному ОГ"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
|
||||||
|
|
||||||
|
og_id = validated_params["id"]
|
||||||
|
codes = validated_params["codes"]
|
||||||
|
columns = validated_params["columns"]
|
||||||
|
search = validated_params.get("search")
|
||||||
|
|
||||||
|
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику
|
||||||
|
# TODO: Переделать под новую архитектуру
|
||||||
|
return self.get_svodka_og(self.df, og_id, codes, columns, search)
|
||||||
|
|
||||||
|
def _get_total_ogs(self, params: dict):
|
||||||
|
"""Получение данных по всем ОГ"""
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, SvodkaPMTotalOGsRequest)
|
||||||
|
|
||||||
|
codes = validated_params["codes"]
|
||||||
|
columns = validated_params["columns"]
|
||||||
|
search = validated_params.get("search")
|
||||||
|
|
||||||
|
# TODO: Переделать под новую архитектуру
|
||||||
|
return self.get_svodka_total(self.df, codes, columns, search)
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
# Сохраняем DataFrame для использования в геттерах
|
||||||
|
self.df = self.parse_svodka_pm_files(file_path, params)
|
||||||
|
return self.df
|
||||||
|
|
||||||
|
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
|
||||||
|
"""Определения индекса заголовка в excel по ключевому слову"""
|
||||||
|
# Читаем первые max_rows строк без заголовков
|
||||||
|
df_temp = pd.read_excel(
|
||||||
|
file,
|
||||||
|
sheet_name=sheet,
|
||||||
|
header=None,
|
||||||
|
nrows=max_rows,
|
||||||
|
engine='openpyxl'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||||
|
for idx, row in df_temp.iterrows():
|
||||||
|
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
||||||
|
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
||||||
|
return idx # 0-based index — то, что нужно для header=
|
||||||
|
|
||||||
|
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
||||||
|
|
||||||
|
def parse_svodka_pm(self, file, sheet, header_num=None):
|
||||||
|
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
|
||||||
|
# Автоопределение header_num, если не передан
|
||||||
|
if header_num is None:
|
||||||
|
header_num = self.find_header_row(file, sheet, search_value="Итого")
|
||||||
|
|
||||||
|
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
||||||
|
df_probe = pd.read_excel(
|
||||||
|
file,
|
||||||
|
sheet_name=sheet,
|
||||||
|
header=header_num,
|
||||||
|
usecols=None,
|
||||||
|
nrows=2,
|
||||||
|
engine='openpyxl'
|
||||||
|
)
|
||||||
|
|
||||||
|
if df_probe.shape[0] == 0:
|
||||||
|
raise ValueError("Файл пуст или не содержит данных.")
|
||||||
|
|
||||||
|
first_data_row = df_probe.iloc[0]
|
||||||
|
|
||||||
|
# Находим столбец с 'INDICATOR_ID'
|
||||||
|
indicator_cols = first_data_row[first_data_row == 'INDICATOR_ID']
|
||||||
|
if len(indicator_cols) == 0:
|
||||||
|
raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.')
|
||||||
|
|
||||||
|
indicator_col_name = indicator_cols.index[0]
|
||||||
|
print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}")
|
||||||
|
|
||||||
|
# Читаем весь лист
|
||||||
|
df_full = pd.read_excel(
|
||||||
|
file,
|
||||||
|
sheet_name=sheet,
|
||||||
|
header=header_num,
|
||||||
|
usecols=None,
|
||||||
|
index_col=None,
|
||||||
|
engine='openpyxl'
|
||||||
|
)
|
||||||
|
|
||||||
|
if indicator_col_name not in df_full.columns:
|
||||||
|
raise ValueError(f"Столбец {indicator_col_name} отсутствует при полной загрузке.")
|
||||||
|
|
||||||
|
# Перемещаем INDICATOR_ID в начало и делаем индексом
|
||||||
|
cols = [indicator_col_name] + [col for col in df_full.columns if col != indicator_col_name]
|
||||||
|
df_full = df_full[cols]
|
||||||
|
df_full.set_index(indicator_col_name, inplace=True)
|
||||||
|
|
||||||
|
# Обрезаем до "Итого" + 1
|
||||||
|
header_list = [str(h).strip() for h in df_full.columns]
|
||||||
|
try:
|
||||||
|
itogo_idx = header_list.index("Итого")
|
||||||
|
num_cols_needed = itogo_idx + 2
|
||||||
|
except ValueError:
|
||||||
|
print('Столбец "Итого" не найден. Оставляем все столбцы.')
|
||||||
|
num_cols_needed = len(header_list)
|
||||||
|
|
||||||
|
num_cols_needed = min(num_cols_needed, len(header_list))
|
||||||
|
df_final = df_full.iloc[:, :num_cols_needed]
|
||||||
|
|
||||||
|
# === Удаление полностью пустых столбцов ===
|
||||||
|
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
|
||||||
|
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
|
||||||
|
non_empty_mask = df_clean.notna().any()
|
||||||
|
df_final = df_final.loc[:, non_empty_mask]
|
||||||
|
|
||||||
|
# === Обработка заголовков: Unnamed и "Итого" → "Итого" ===
|
||||||
|
new_columns = []
|
||||||
|
last_good_name = None
|
||||||
|
for col in df_final.columns:
|
||||||
|
col_str = str(col).strip()
|
||||||
|
|
||||||
|
# Проверяем, является ли колонка пустой/некорректной
|
||||||
|
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
|
||||||
|
|
||||||
|
if is_empty_or_unnamed:
|
||||||
|
# Если это пустая колонка, используем последнее хорошее имя
|
||||||
|
if last_good_name:
|
||||||
|
new_columns.append(last_good_name)
|
||||||
|
else:
|
||||||
|
# Если нет хорошего имени, используем имя по умолчанию
|
||||||
|
new_columns.append(f"col_{len(new_columns)}")
|
||||||
|
else:
|
||||||
|
# Это хорошая колонка
|
||||||
|
last_good_name = col_str
|
||||||
|
new_columns.append(col_str)
|
||||||
|
|
||||||
|
# Убеждаемся, что количество столбцов совпадает
|
||||||
|
if len(new_columns) != len(df_final.columns):
|
||||||
|
# Если количество не совпадает, обрезаем или дополняем
|
||||||
|
if len(new_columns) > len(df_final.columns):
|
||||||
|
new_columns = new_columns[:len(df_final.columns)]
|
||||||
|
else:
|
||||||
|
# Дополняем недостающие столбцы
|
||||||
|
while len(new_columns) < len(df_final.columns):
|
||||||
|
new_columns.append(f"col_{len(new_columns)}")
|
||||||
|
|
||||||
|
# Применяем новые заголовки
|
||||||
|
df_final.columns = new_columns
|
||||||
|
|
||||||
|
return df_final
|
||||||
|
|
||||||
|
def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict:
|
||||||
|
"""Парсинг ZIP архива со сводками ПМ"""
|
||||||
|
import zipfile
|
||||||
|
pm_dict = {
|
||||||
|
"facts": {},
|
||||||
|
"plans": {}
|
||||||
|
}
|
||||||
|
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
||||||
|
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
file_list = zip_ref.namelist()
|
||||||
|
for name, id in OG_IDS.items():
|
||||||
|
if id == 'BASH':
|
||||||
|
continue # пропускаем BASH
|
||||||
|
|
||||||
|
current_fact = replace_id_in_path(excel_fact_template, id)
|
||||||
|
fact_candidates = [f for f in file_list if current_fact in f]
|
||||||
|
if len(fact_candidates) == 1:
|
||||||
|
print(f'Загрузка {current_fact}')
|
||||||
|
with zip_ref.open(fact_candidates[0]) as excel_file:
|
||||||
|
pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
||||||
|
print(f"✅ Факт загружен: {current_fact}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Файл не найден (Факт): {current_fact}")
|
||||||
|
pm_dict['facts'][id] = None
|
||||||
|
|
||||||
|
current_plan = replace_id_in_path(excel_plan_template, id)
|
||||||
|
plan_candidates = [f for f in file_list if current_plan in f]
|
||||||
|
if len(plan_candidates) == 1:
|
||||||
|
print(f'Загрузка {current_plan}')
|
||||||
|
with zip_ref.open(plan_candidates[0]) as excel_file:
|
||||||
|
pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
||||||
|
print(f"✅ План загружен: {current_plan}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Файл не найден (План): {current_plan}")
|
||||||
|
pm_dict['plans'][id] = None
|
||||||
|
|
||||||
|
return pm_dict
|
||||||
|
|
||||||
|
def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None):
|
||||||
|
''' Служебная функция получения значения по коду и столбцу '''
|
||||||
|
row_index = code
|
||||||
|
|
||||||
|
mask_value = df_svodka.iloc[0] == code
|
||||||
|
if search_value is None:
|
||||||
|
mask_name = df_svodka.columns != 'Итого'
|
||||||
|
else:
|
||||||
|
mask_name = df_svodka.columns == search_value
|
||||||
|
|
||||||
|
# Убедимся, что маски совпадают по длине
|
||||||
|
if len(mask_value) != len(mask_name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
final_mask = mask_value & mask_name # булевая маска по позициям столбцов
|
||||||
|
col_positions = final_mask.values # numpy array или Series булевых значений
|
||||||
|
|
||||||
|
if not final_mask.any():
|
||||||
|
print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
if row_index in df_svodka.index:
|
||||||
|
# Получаем позицию строки
|
||||||
|
row_loc = df_svodka.index.get_loc(row_index)
|
||||||
|
|
||||||
|
# Извлекаем значения по позициям столбцов
|
||||||
|
values = df_svodka.iloc[row_loc, col_positions]
|
||||||
|
|
||||||
|
# Преобразуем в числовой формат
|
||||||
|
numeric_values = pd.to_numeric(values, errors='coerce')
|
||||||
|
|
||||||
|
# Агрегация данных (NaN игнорируются)
|
||||||
|
if search_value is None:
|
||||||
|
return numeric_values
|
||||||
|
else:
|
||||||
|
return numeric_values.iloc[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
|
||||||
|
''' Служебная функция получения данных по одному ОГ '''
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Безопасно получаем данные, проверяя их наличие
|
||||||
|
fact_df = pm_dict.get('facts', {}).get(id) if 'facts' in pm_dict else None
|
||||||
|
plan_df = pm_dict.get('plans', {}).get(id) if 'plans' in pm_dict else None
|
||||||
|
|
||||||
|
# Определяем, какие столбцы из какого датафрейма брать
|
||||||
|
for col in columns:
|
||||||
|
col_result = {}
|
||||||
|
|
||||||
|
if col in ['ПП', 'БП']:
|
||||||
|
if plan_df is None:
|
||||||
|
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
|
||||||
|
col_result = {code: None for code in codes}
|
||||||
|
else:
|
||||||
|
for code in codes:
|
||||||
|
val = self.get_svodka_value(plan_df, code, col, search_value)
|
||||||
|
col_result[code] = val
|
||||||
|
|
||||||
|
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
||||||
|
if fact_df is None:
|
||||||
|
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
|
||||||
|
col_result = {code: None for code in codes}
|
||||||
|
else:
|
||||||
|
for code in codes:
|
||||||
|
val = self.get_svodka_value(fact_df, code, col, search_value)
|
||||||
|
col_result[code] = val
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
||||||
|
col_result = {code: None for code in codes}
|
||||||
|
|
||||||
|
result[col] = col_result
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
|
||||||
|
''' Служебная функция агрегации данные по всем ОГ '''
|
||||||
|
total_result = {}
|
||||||
|
|
||||||
|
for name, og_id in OG_IDS.items():
|
||||||
|
if og_id == 'BASH':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# print(f"📊 Обработка: {name} ({og_id})")
|
||||||
|
try:
|
||||||
|
data = self.get_svodka_og(
|
||||||
|
pm_dict,
|
||||||
|
og_id,
|
||||||
|
codes,
|
||||||
|
columns,
|
||||||
|
search_value
|
||||||
|
)
|
||||||
|
total_result[og_id] = data
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
|
||||||
|
total_result[og_id] = None
|
||||||
|
|
||||||
|
return total_result
|
||||||
|
|
||||||
|
# Убираем старый метод get_value, так как он теперь в базовом классе
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
from core.ports import ParserPort
|
from core.ports import ParserPort
|
||||||
from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
|
from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SvodkaPMParser(ParserPort):
|
class SvodkaPMParser(ParserPort):
|
||||||
@@ -9,39 +20,141 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
name = "Сводки ПМ"
|
name = "Сводки ПМ"
|
||||||
|
|
||||||
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
|
def __init__(self):
|
||||||
"""Определения индекса заголовка в excel по ключевому слову"""
|
super().__init__()
|
||||||
# Читаем первые max_rows строк без заголовков
|
self._register_default_getters()
|
||||||
df_temp = pd.read_excel(
|
|
||||||
file,
|
def _register_default_getters(self):
|
||||||
sheet_name=sheet,
|
"""Регистрация геттеров для Сводки ПМ"""
|
||||||
header=None,
|
self.register_getter(
|
||||||
nrows=max_rows
|
name="single_og",
|
||||||
|
method=self._get_single_og,
|
||||||
|
required_params=["id", "codes", "columns"],
|
||||||
|
optional_params=["search"],
|
||||||
|
description="Получение данных по одному ОГ из сводки ПМ"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
self.register_getter(
|
||||||
for idx, row in df_temp.iterrows():
|
name="total_ogs",
|
||||||
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
method=self._get_total_ogs,
|
||||||
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
required_params=["codes", "columns"],
|
||||||
return idx # 0-based index — то, что нужно для header=
|
optional_params=["search"],
|
||||||
|
description="Получение данных по всем ОГ из сводки ПМ"
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||||||
|
"""Парсинг ZIP архива со сводками ПМ и возврат словаря с DataFrame"""
|
||||||
|
# Проверяем расширение файла
|
||||||
|
if not file_path.lower().endswith('.zip'):
|
||||||
|
raise ValueError(f"Ожидается ZIP архив: {file_path}")
|
||||||
|
|
||||||
|
# Создаем временную директорию для разархивирования
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Разархивируем файл
|
||||||
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(temp_dir)
|
||||||
|
logger.info(f"📦 Архив разархивирован в: {temp_dir}")
|
||||||
|
|
||||||
|
# Посмотрим, что находится в архиве
|
||||||
|
logger.debug(f"🔍 Содержимое архива:")
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
level = root.replace(temp_dir, '').count(os.sep)
|
||||||
|
indent = ' ' * 2 * level
|
||||||
|
logger.debug(f"{indent}{os.path.basename(root)}/")
|
||||||
|
subindent = ' ' * 2 * (level + 1)
|
||||||
|
for file in files:
|
||||||
|
logger.debug(f"{subindent}{file}")
|
||||||
|
|
||||||
|
# Создаем словари для хранения данных как в оригинале
|
||||||
|
df_pm_facts = {} # Словарь с данными факта, ключ - ID ОГ
|
||||||
|
df_pm_plans = {} # Словарь с данными плана, ключ - ID ОГ
|
||||||
|
|
||||||
|
# Ищем файлы в архиве (адаптируемся к реальной структуре)
|
||||||
|
fact_files = []
|
||||||
|
plan_files = []
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file in files:
|
||||||
|
if file.lower().endswith(('.xlsx', '.xlsm')):
|
||||||
|
full_path = os.path.join(root, file)
|
||||||
|
if 'fact' in file.lower() or 'факт' in file.lower():
|
||||||
|
fact_files.append(full_path)
|
||||||
|
elif 'plan' in file.lower() or 'план' in file.lower():
|
||||||
|
plan_files.append(full_path)
|
||||||
|
|
||||||
|
logger.info(f"📊 Найдено файлов факта: {len(fact_files)}")
|
||||||
|
logger.info(f"📊 Найдено файлов плана: {len(plan_files)}")
|
||||||
|
|
||||||
|
# Обрабатываем найденные файлы
|
||||||
|
for fact_file in fact_files:
|
||||||
|
# Извлекаем ID ОГ из имени файла
|
||||||
|
filename = os.path.basename(fact_file)
|
||||||
|
# Ищем паттерн типа svodka_fact_pm_SNPZ.xlsm
|
||||||
|
if 'svodka_fact_pm_' in filename:
|
||||||
|
og_id = filename.replace('svodka_fact_pm_', '').replace('.xlsx', '').replace('.xlsm', '')
|
||||||
|
if og_id in SINGLE_OGS:
|
||||||
|
logger.info(f'📊 Загрузка факта: {fact_file} (ОГ: {og_id})')
|
||||||
|
df_pm_facts[og_id] = self._parse_svodka_pm(fact_file, 'Сводка Нефтепереработка')
|
||||||
|
logger.info(f"✅ Факт загружен для {og_id}")
|
||||||
|
|
||||||
|
for plan_file in plan_files:
|
||||||
|
# Извлекаем ID ОГ из имени файла
|
||||||
|
filename = os.path.basename(plan_file)
|
||||||
|
# Ищем паттерн типа svodka_plan_pm_SNPZ.xlsm
|
||||||
|
if 'svodka_plan_pm_' in filename:
|
||||||
|
og_id = filename.replace('svodka_plan_pm_', '').replace('.xlsx', '').replace('.xlsm', '')
|
||||||
|
if og_id in SINGLE_OGS:
|
||||||
|
logger.info(f'📊 Загрузка плана: {plan_file} (ОГ: {og_id})')
|
||||||
|
df_pm_plans[og_id] = self._parse_svodka_pm(plan_file, 'Сводка Нефтепереработка')
|
||||||
|
logger.info(f"✅ План загружен для {og_id}")
|
||||||
|
|
||||||
|
# Инициализируем None для ОГ, для которых файлы не найдены
|
||||||
|
for og_id in SINGLE_OGS:
|
||||||
|
if og_id == 'BASH':
|
||||||
|
continue
|
||||||
|
if og_id not in df_pm_facts:
|
||||||
|
df_pm_facts[og_id] = None
|
||||||
|
if og_id not in df_pm_plans:
|
||||||
|
df_pm_plans[og_id] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Возвращаем словарь с данными (как в оригинале)
|
||||||
|
result = {
|
||||||
|
'df_pm_facts': df_pm_facts,
|
||||||
|
'df_pm_plans': df_pm_plans
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"🎯 Обработано ОГ: {len([k for k, v in df_pm_facts.items() if v is not None])} факт, {len([k for k, v in df_pm_plans.items() if v is not None])} план")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Удаляем временную директорию
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"🗑️ Временная директория удалена: {temp_dir}")
|
||||||
|
|
||||||
|
def _parse_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame:
|
||||||
|
"""Парсинг отчетов одного ОГ для БП, ПП и факта"""
|
||||||
|
try:
|
||||||
|
# Автоопределение header_num, если не передан
|
||||||
|
if header_num is None:
|
||||||
|
header_num = find_header_row(file_path, sheet_name, search_value="Итого")
|
||||||
|
|
||||||
|
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
||||||
|
df_probe = pd.read_excel(
|
||||||
|
file_path,
|
||||||
|
sheet_name=sheet_name,
|
||||||
|
header=header_num,
|
||||||
|
usecols=None,
|
||||||
|
nrows=2,
|
||||||
|
engine='openpyxl' # Явно указываем движок
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Ошибка при чтении файла {file_path}: {str(e)}")
|
||||||
|
|
||||||
if df_probe.shape[0] == 0:
|
if df_probe.shape[0] == 0:
|
||||||
raise ValueError("Файл пуст или не содержит данных.")
|
raise ValueError("Файл пуст или не содержит данных.")
|
||||||
|
|
||||||
@@ -53,15 +166,15 @@ class SvodkaPMParser(ParserPort):
|
|||||||
raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.')
|
raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.')
|
||||||
|
|
||||||
indicator_col_name = indicator_cols.index[0]
|
indicator_col_name = indicator_cols.index[0]
|
||||||
print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}")
|
|
||||||
|
|
||||||
# Читаем весь лист
|
# Читаем весь лист
|
||||||
df_full = pd.read_excel(
|
df_full = pd.read_excel(
|
||||||
file,
|
file_path,
|
||||||
sheet_name=sheet,
|
sheet_name=sheet_name,
|
||||||
header=header_num,
|
header=header_num,
|
||||||
usecols=None,
|
usecols=None,
|
||||||
index_col=None
|
index_col=None,
|
||||||
|
engine='openpyxl' # Явно указываем движок
|
||||||
)
|
)
|
||||||
|
|
||||||
if indicator_col_name not in df_full.columns:
|
if indicator_col_name not in df_full.columns:
|
||||||
@@ -78,19 +191,18 @@ class SvodkaPMParser(ParserPort):
|
|||||||
itogo_idx = header_list.index("Итого")
|
itogo_idx = header_list.index("Итого")
|
||||||
num_cols_needed = itogo_idx + 2
|
num_cols_needed = itogo_idx + 2
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print('Столбец "Итого" не найден. Оставляем все столбцы.')
|
|
||||||
num_cols_needed = len(header_list)
|
num_cols_needed = len(header_list)
|
||||||
|
|
||||||
num_cols_needed = min(num_cols_needed, len(header_list))
|
num_cols_needed = min(num_cols_needed, len(header_list))
|
||||||
df_final = df_full.iloc[:, :num_cols_needed]
|
df_final = df_full.iloc[:, :num_cols_needed]
|
||||||
|
|
||||||
# === Удаление полностью пустых столбцов ===
|
# Удаление полностью пустых столбцов
|
||||||
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
|
df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True)
|
||||||
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
|
df_clean = df_clean.where(pd.notnull(df_clean), pd.NA)
|
||||||
non_empty_mask = df_clean.notna().any()
|
non_empty_mask = df_clean.notna().any()
|
||||||
df_final = df_final.loc[:, non_empty_mask]
|
df_final = df_final.loc[:, non_empty_mask]
|
||||||
|
|
||||||
# === Обработка заголовков: Unnamed и "Итого" → "Итого" ===
|
# Обработка заголовков: Unnamed и "Итого" → "Итого"
|
||||||
new_columns = []
|
new_columns = []
|
||||||
last_good_name = None
|
last_good_name = None
|
||||||
for col in df_final.columns:
|
for col in df_final.columns:
|
||||||
@@ -102,106 +214,149 @@ class SvodkaPMParser(ParserPort):
|
|||||||
# Проверяем, начинается ли на "Итого"
|
# Проверяем, начинается ли на "Итого"
|
||||||
if col_str.startswith('Итого'):
|
if col_str.startswith('Итого'):
|
||||||
current_name = 'Итого'
|
current_name = 'Итого'
|
||||||
last_good_name = current_name # обновляем last_good_name
|
last_good_name = current_name
|
||||||
new_columns.append(current_name)
|
new_columns.append(current_name)
|
||||||
elif is_empty_or_unnamed:
|
elif is_empty_or_unnamed:
|
||||||
# Используем последнее хорошее имя
|
# Используем последнее хорошее имя
|
||||||
new_columns.append(last_good_name)
|
new_columns.append(last_good_name)
|
||||||
else:
|
else:
|
||||||
# Имя, полученное из exel
|
# Имя, полученное из excel
|
||||||
last_good_name = col_str
|
last_good_name = col_str
|
||||||
new_columns.append(col_str)
|
new_columns.append(col_str)
|
||||||
|
|
||||||
df_final.columns = new_columns
|
df_final.columns = new_columns
|
||||||
|
|
||||||
print(f"Окончательное количество столбцов: {len(df_final.columns)}")
|
|
||||||
return df_final
|
return df_final
|
||||||
|
|
||||||
def parse(self, file_path: str, params: dict) -> dict:
|
def _get_svodka_value(self, df_svodka: pd.DataFrame, og_id: str, code: int, search_value: Optional[str] = None):
|
||||||
import zipfile
|
"""Служебная функция для простой выборке по сводке"""
|
||||||
pm_dict = {
|
logger.debug(f"🔍 Ищем код '{code}' для ОГ '{og_id}' в DataFrame с {len(df_svodka)} строками")
|
||||||
"facts": {},
|
logger.debug(f"🔍 Первая строка данных: {df_svodka.iloc[0].tolist()}")
|
||||||
"plans": {}
|
logger.debug(f"🔍 Доступные индексы: {list(df_svodka.index)}")
|
||||||
}
|
logger.debug(f"🔍 Доступные столбцы: {list(df_svodka.columns)}")
|
||||||
excel_fact_template = 'svodka_fact_pm_ID.xlsm'
|
|
||||||
excel_plan_template = 'svodka_plan_pm_ID.xlsx'
|
|
||||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
|
||||||
file_list = zip_ref.namelist()
|
|
||||||
for name, id in OG_IDS.items():
|
|
||||||
if id == 'BASH':
|
|
||||||
continue # пропускаем BASH
|
|
||||||
|
|
||||||
current_fact = replace_id_in_path(excel_fact_template, id)
|
# Проверяем, есть ли код в индексе
|
||||||
fact_candidates = [f for f in file_list if current_fact in f]
|
if code not in df_svodka.index:
|
||||||
if len(fact_candidates) == 1:
|
logger.warning(f"⚠️ Код '{code}' не найден в индексе")
|
||||||
print(f'Загрузка {current_fact}')
|
|
||||||
with zip_ref.open(fact_candidates[0]) as excel_file:
|
|
||||||
pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
|
||||||
print(f"✅ Факт загружен: {current_fact}")
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Файл не найден (Факт): {current_fact}")
|
|
||||||
pm_dict['facts'][id] = None
|
|
||||||
|
|
||||||
current_plan = replace_id_in_path(excel_plan_template, id)
|
|
||||||
plan_candidates = [f for f in file_list if current_plan in f]
|
|
||||||
if len(plan_candidates) == 1:
|
|
||||||
print(f'Загрузка {current_plan}')
|
|
||||||
with zip_ref.open(plan_candidates[0]) as excel_file:
|
|
||||||
pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка')
|
|
||||||
print(f"✅ План загружен: {current_plan}")
|
|
||||||
else:
|
|
||||||
print(f"⚠️ Файл не найден (План): {current_plan}")
|
|
||||||
pm_dict['plans'][id] = None
|
|
||||||
|
|
||||||
return pm_dict
|
|
||||||
|
|
||||||
def get_svodka_value(self, df_svodka, id, code, search_value=None):
|
|
||||||
''' Служебная функция для простой выборке по сводке '''
|
|
||||||
row_index = id
|
|
||||||
|
|
||||||
mask_value = df_svodka.iloc[0] == code
|
|
||||||
if search_value is None:
|
|
||||||
mask_name = df_svodka.columns != 'Итого'
|
|
||||||
else:
|
|
||||||
mask_name = df_svodka.columns == search_value
|
|
||||||
|
|
||||||
# Убедимся, что маски совпадают по длине
|
|
||||||
if len(mask_value) != len(mask_name):
|
|
||||||
raise ValueError(
|
|
||||||
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
final_mask = mask_value & mask_name # булевая маска по позициям столбцов
|
|
||||||
col_positions = final_mask.values # numpy array или Series булевых значений
|
|
||||||
|
|
||||||
if not final_mask.any():
|
|
||||||
print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# Получаем позицию строки с кодом
|
||||||
|
code_row_loc = df_svodka.index.get_loc(code)
|
||||||
|
logger.debug(f"🔍 Код '{code}' в позиции {code_row_loc}")
|
||||||
|
|
||||||
|
# Определяем позиции для поиска
|
||||||
|
if search_value is None:
|
||||||
|
# Ищем все позиции кроме "Итого" и None (первый столбец с заголовком)
|
||||||
|
target_positions = []
|
||||||
|
for i, col_name in enumerate(df_svodka.iloc[0]):
|
||||||
|
if col_name != 'Итого' and col_name is not None:
|
||||||
|
target_positions.append(i)
|
||||||
else:
|
else:
|
||||||
if row_index in df_svodka.index:
|
# Ищем позиции в первой строке, где есть нужное название
|
||||||
# Получаем позицию строки
|
target_positions = []
|
||||||
row_loc = df_svodka.index.get_loc(row_index)
|
for i, col_name in enumerate(df_svodka.iloc[0]):
|
||||||
|
if col_name == search_value:
|
||||||
|
target_positions.append(i)
|
||||||
|
|
||||||
# Извлекаем значения по позициям столбцов
|
logger.debug(f"🔍 Найдены позиции для '{search_value}': {target_positions[:5]}...")
|
||||||
values = df_svodka.iloc[row_loc, col_positions]
|
logger.debug(f"🔍 Позиции в первой строке: {target_positions[:5]}...")
|
||||||
|
|
||||||
# Преобразуем в числовой формат
|
logger.debug(f"🔍 Ищем столбцы с названием '{search_value}'")
|
||||||
numeric_values = pd.to_numeric(values, errors='coerce')
|
logger.debug(f"🔍 Целевые позиции: {target_positions[:10]}...")
|
||||||
|
|
||||||
# Агрегация данных (NaN игнорируются)
|
if not target_positions:
|
||||||
if search_value is None:
|
logger.warning(f"⚠️ Позиции '{search_value}' не найдены")
|
||||||
return numeric_values
|
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:
|
else:
|
||||||
return numeric_values.iloc[0]
|
values.append(0)
|
||||||
else:
|
else:
|
||||||
return None
|
values.append(value)
|
||||||
|
|
||||||
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
|
|
||||||
''' Служебная функция получения данных по одному ОГ '''
|
|
||||||
|
# Преобразуем в числовой формат
|
||||||
|
numeric_values = pd.to_numeric(values, errors='coerce')
|
||||||
|
logger.debug(f"🔍 Числовые значения (первые 5): {numeric_values.tolist()[:5]}")
|
||||||
|
|
||||||
|
# Попробуем альтернативное преобразование
|
||||||
|
try:
|
||||||
|
# Если pandas не может преобразовать, попробуем вручную
|
||||||
|
manual_values = []
|
||||||
|
for v in values:
|
||||||
|
if pd.isna(v) or v is None:
|
||||||
|
manual_values.append(0)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Пробуем преобразовать в float
|
||||||
|
manual_values.append(float(str(v).replace(',', '.')))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
manual_values.append(0)
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Ручное преобразование (первые 5): {manual_values[:5]}")
|
||||||
|
numeric_values = pd.Series(manual_values)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка при ручном преобразовании: {e}")
|
||||||
|
# Используем исходные значения
|
||||||
|
numeric_values = pd.Series([0 if pd.isna(v) or v is None else v for v in values])
|
||||||
|
|
||||||
|
# Агрегация данных (NaN игнорируются)
|
||||||
|
if search_value is None:
|
||||||
|
# Возвращаем массив всех значений (игнорируя NaN)
|
||||||
|
if len(numeric_values) > 0:
|
||||||
|
# Фильтруем NaN значения и возвращаем как список
|
||||||
|
valid_values = numeric_values.dropna()
|
||||||
|
if len(valid_values) > 0:
|
||||||
|
return valid_values.tolist()
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
# Возвращаем массив всех значений (игнорируя NaN)
|
||||||
|
if len(numeric_values) > 0:
|
||||||
|
# Фильтруем NaN значения и возвращаем как список
|
||||||
|
valid_values = numeric_values.dropna()
|
||||||
|
if len(valid_values) > 0:
|
||||||
|
return valid_values.tolist()
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None):
|
||||||
|
"""Служебная функция получения данных по одному ОГ"""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
fact_df = pm_dict['facts'][id]
|
# Получаем данные из сохраненных словарей (через self.df)
|
||||||
plan_df = pm_dict['plans'][id]
|
if not hasattr(self, 'df') or self.df is None:
|
||||||
|
logger.error("❌ Данные не загружены. Сначала загрузите ZIP архив.")
|
||||||
|
return {col: {str(code): None for code in codes} for col in columns}
|
||||||
|
|
||||||
|
# Извлекаем словари из сохраненных данных
|
||||||
|
df_pm_facts = self.df.get('df_pm_facts', {})
|
||||||
|
df_pm_plans = self.df.get('df_pm_plans', {})
|
||||||
|
|
||||||
|
# Получаем данные для конкретного ОГ
|
||||||
|
fact_df = df_pm_facts.get(og_id)
|
||||||
|
plan_df = df_pm_plans.get(og_id)
|
||||||
|
|
||||||
|
logger.debug(f"🔍 ===== НАЧАЛО ОБРАБОТКИ ОГ {og_id} =====")
|
||||||
|
logger.debug(f"🔍 Коды: {codes}")
|
||||||
|
logger.debug(f"🔍 Столбцы: {columns}")
|
||||||
|
logger.debug(f"🔍 Получены данные для {og_id}: факт={'✅' if fact_df is not None else '❌'}, план={'✅' if plan_df is not None else '❌'}")
|
||||||
|
|
||||||
# Определяем, какие столбцы из какого датафрейма брать
|
# Определяем, какие столбцы из какого датафрейма брать
|
||||||
for col in columns:
|
for col in columns:
|
||||||
@@ -209,67 +364,91 @@ class SvodkaPMParser(ParserPort):
|
|||||||
|
|
||||||
if col in ['ПП', 'БП']:
|
if col in ['ПП', 'БП']:
|
||||||
if plan_df is None:
|
if plan_df is None:
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
|
logger.warning(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}")
|
||||||
else:
|
else:
|
||||||
|
logger.debug(f"🔍 ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====")
|
||||||
for code in codes:
|
for code in codes:
|
||||||
val = self.get_svodka_value(plan_df, code, col, search_value)
|
logger.debug(f"🔍 --- Код {code} для {col} ---")
|
||||||
col_result[code] = val
|
val = self._get_svodka_value(plan_df, og_id, code, col)
|
||||||
|
col_result[str(code)] = val
|
||||||
|
logger.debug(f"🔍 ===== ЗАВЕРШИЛИ ОБРАБОТКУ '{col}' =====")
|
||||||
|
|
||||||
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
||||||
if fact_df is None:
|
if fact_df is None:
|
||||||
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
|
logger.warning(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}")
|
||||||
else:
|
else:
|
||||||
for code in codes:
|
for code in codes:
|
||||||
val = self.get_svodka_value(fact_df, code, col, search_value)
|
val = self._get_svodka_value(fact_df, og_id, code, col)
|
||||||
col_result[code] = val
|
col_result[str(code)] = val
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
logger.warning(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
||||||
col_result = {code: None for code in codes}
|
col_result = {str(code): None for code in codes}
|
||||||
|
|
||||||
result[col] = col_result
|
result[col] = col_result
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
|
def _get_single_og(self, params: Dict[str, Any]) -> str:
|
||||||
''' Служебная функция агрегации данные по всем ОГ '''
|
"""API функция для получения данных по одному ОГ"""
|
||||||
total_result = {}
|
# Если на входе строка — парсим как JSON
|
||||||
|
if isinstance(params, str):
|
||||||
for name, og_id in OG_IDS.items():
|
|
||||||
if og_id == 'BASH':
|
|
||||||
continue
|
|
||||||
|
|
||||||
# print(f"📊 Обработка: {name} ({og_id})")
|
|
||||||
try:
|
try:
|
||||||
data = self.get_svodka_og(
|
params = json.loads(params)
|
||||||
pm_dict,
|
except json.JSONDecodeError as e:
|
||||||
og_id,
|
raise ValueError(f"Некорректный JSON: {e}")
|
||||||
codes,
|
|
||||||
columns,
|
|
||||||
search_value
|
|
||||||
)
|
|
||||||
total_result[og_id] = data
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
|
|
||||||
total_result[og_id] = None
|
|
||||||
|
|
||||||
return total_result
|
# Проверяем структуру
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
||||||
|
|
||||||
def get_value(self, df, params):
|
|
||||||
og_id = params.get("id")
|
og_id = params.get("id")
|
||||||
codes = params.get("codes")
|
codes = params.get("codes")
|
||||||
columns = params.get("columns")
|
columns = params.get("columns")
|
||||||
search = params.get("search")
|
search = params.get("search")
|
||||||
mode = params.get("mode", "total")
|
|
||||||
if not isinstance(codes, list):
|
if not isinstance(codes, list):
|
||||||
raise ValueError("Поле 'codes' должно быть списком")
|
raise ValueError("Поле 'codes' должно быть списком")
|
||||||
if not isinstance(columns, list):
|
if not isinstance(columns, list):
|
||||||
raise ValueError("Поле 'columns' должно быть списком")
|
raise ValueError("Поле 'columns' должно быть списком")
|
||||||
data = None
|
|
||||||
if mode == "single":
|
data = self._get_svodka_og(og_id, codes, columns, search)
|
||||||
if not og_id:
|
|
||||||
raise ValueError("Отсутствует идентификатор ОГ")
|
|
||||||
data = self.get_svodka_og(df, og_id, codes, columns, search)
|
|
||||||
elif mode == "total":
|
|
||||||
data = self.get_svodka_total(df, codes, columns, search)
|
|
||||||
json_result = data_to_json(data)
|
json_result = data_to_json(data)
|
||||||
return json_result
|
return json_result
|
||||||
|
|
||||||
|
def _get_total_ogs(self, params: Dict[str, Any]) -> str:
|
||||||
|
"""API функция для получения данных по всем ОГ"""
|
||||||
|
# Если на входе строка — парсим как JSON
|
||||||
|
if isinstance(params, str):
|
||||||
|
try:
|
||||||
|
params = json.loads(params)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"❌Некорректный JSON: {e}")
|
||||||
|
|
||||||
|
# Проверяем структуру
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
||||||
|
|
||||||
|
codes = params.get("codes")
|
||||||
|
columns = params.get("columns")
|
||||||
|
search = params.get("search")
|
||||||
|
|
||||||
|
if not isinstance(codes, list):
|
||||||
|
raise ValueError("Поле 'codes' должно быть списком")
|
||||||
|
if not isinstance(columns, list):
|
||||||
|
raise ValueError("Поле 'columns' должно быть списком")
|
||||||
|
|
||||||
|
total_result = {}
|
||||||
|
|
||||||
|
for og_id in SINGLE_OGS:
|
||||||
|
if og_id == 'BASH':
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = self._get_svodka_og(og_id, codes, columns, search)
|
||||||
|
total_result[og_id] = data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при обработке {og_id}: {e}")
|
||||||
|
total_result[og_id] = None
|
||||||
|
|
||||||
|
json_result = data_to_json(total_result)
|
||||||
|
return json_result
|
||||||
381
python_parser/adapters/parsers/svodka_repair_ca.py
Normal file
381
python_parser/adapters/parsers/svodka_repair_ca.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
|
from core.ports import ParserPort
|
||||||
|
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
||||||
|
from app.schemas.svodka_repair_ca import SvodkaRepairCARequest
|
||||||
|
from adapters.pconfig import SINGLE_OGS, find_header_row, get_og_by_name
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SvodkaRepairCAParser(ParserPort):
|
||||||
|
"""Парсер для сводок ремонта СА"""
|
||||||
|
|
||||||
|
name = "Сводки ремонта СА"
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию"""
|
||||||
|
register_getter_from_schema(
|
||||||
|
parser_instance=self,
|
||||||
|
getter_name="get_repair_data",
|
||||||
|
method=self._get_repair_data_wrapper,
|
||||||
|
schema_class=SvodkaRepairCARequest,
|
||||||
|
description="Получение данных о ремонтных работах"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_repair_data_wrapper(self, params: dict):
|
||||||
|
"""Получение данных о ремонтных работах"""
|
||||||
|
logger.debug(f"🔍 _get_repair_data_wrapper вызван с параметрами: {params}")
|
||||||
|
|
||||||
|
# Валидируем параметры с помощью схемы Pydantic
|
||||||
|
validated_params = validate_params_with_schema(params, SvodkaRepairCARequest)
|
||||||
|
|
||||||
|
og_ids = validated_params.get("og_ids")
|
||||||
|
repair_types = validated_params.get("repair_types")
|
||||||
|
include_planned = validated_params.get("include_planned", True)
|
||||||
|
include_factual = validated_params.get("include_factual", True)
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Запрошенные ОГ: {og_ids}")
|
||||||
|
logger.debug(f"🔍 Запрошенные типы ремонта: {repair_types}")
|
||||||
|
logger.debug(f"🔍 Включать плановые: {include_planned}, фактические: {include_factual}")
|
||||||
|
|
||||||
|
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||||
|
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
||||||
|
# Данные из парсинга
|
||||||
|
data_source = self.data_dict
|
||||||
|
logger.debug(f"🔍 Используем data_dict с {len(data_source)} записями")
|
||||||
|
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||||
|
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
||||||
|
data_source = self._df_to_data_dict()
|
||||||
|
logger.debug(f"🔍 Используем df, преобразованный в data_dict с {len(data_source)} записями")
|
||||||
|
else:
|
||||||
|
logger.warning(f"🔍 Нет данных! data_dict={getattr(self, 'data_dict', 'None')}, df={getattr(self, 'df', 'None')}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Группируем данные по ОГ (как в оригинале)
|
||||||
|
grouped_data = {}
|
||||||
|
|
||||||
|
for item in data_source:
|
||||||
|
og_id = item.get('id')
|
||||||
|
if not og_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверяем фильтры
|
||||||
|
if og_ids is not None and og_id not in og_ids:
|
||||||
|
continue
|
||||||
|
if repair_types is not None and item.get('type') not in repair_types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Фильтрация по плановым/фактическим данным
|
||||||
|
filtered_item = item.copy()
|
||||||
|
if not include_planned:
|
||||||
|
filtered_item.pop('plan', None)
|
||||||
|
if not include_factual:
|
||||||
|
filtered_item.pop('fact', None)
|
||||||
|
|
||||||
|
# Убираем поле 'id' из записи, так как оно уже в ключе
|
||||||
|
filtered_item.pop('id', None)
|
||||||
|
|
||||||
|
# Добавляем в группу по ОГ
|
||||||
|
if og_id not in grouped_data:
|
||||||
|
grouped_data[og_id] = []
|
||||||
|
grouped_data[og_id].append(filtered_item)
|
||||||
|
|
||||||
|
total_records = sum(len(v) for v in grouped_data.values())
|
||||||
|
logger.debug(f"🔍 Отфильтровано {total_records} записей из {len(data_source)}")
|
||||||
|
logger.debug(f"🔍 Группировано по {len(grouped_data)} ОГ: {list(grouped_data.keys())}")
|
||||||
|
return grouped_data
|
||||||
|
|
||||||
|
def _df_to_data_dict(self):
|
||||||
|
"""Преобразование DataFrame обратно в словарь данных"""
|
||||||
|
if not hasattr(self, 'df') or self.df is None or self.df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Если df содержит данные в формате списка записей
|
||||||
|
if 'data' in self.df.columns:
|
||||||
|
# Извлекаем данные из колонки 'data'
|
||||||
|
all_data = []
|
||||||
|
for _, row in self.df.iterrows():
|
||||||
|
data = row.get('data')
|
||||||
|
if data and isinstance(data, list):
|
||||||
|
all_data.extend(data)
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
|
logger.debug(f"🔍 SvodkaRepairCAParser.parse вызван с файлом: {file_path}")
|
||||||
|
|
||||||
|
# Определяем, это ZIP архив или одиночный файл
|
||||||
|
if file_path.lower().endswith('.zip'):
|
||||||
|
# Обрабатываем ZIP архив
|
||||||
|
self.data_dict = self._parse_zip_archive(file_path, params)
|
||||||
|
else:
|
||||||
|
# Обрабатываем одиночный файл
|
||||||
|
self.data_dict = self._parse_single_file(file_path, params)
|
||||||
|
|
||||||
|
# Преобразуем словарь в DataFrame для совместимости с services.py
|
||||||
|
if self.data_dict:
|
||||||
|
# Создаем DataFrame с информацией о загруженных данных
|
||||||
|
data_rows = []
|
||||||
|
for i, item in enumerate(self.data_dict):
|
||||||
|
data_rows.append({
|
||||||
|
'index': i,
|
||||||
|
'data': [item], # Обертываем в список для совместимости
|
||||||
|
'records_count': 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if data_rows:
|
||||||
|
df = pd.DataFrame(data_rows)
|
||||||
|
self.df = df
|
||||||
|
logger.debug(f"🔍 Создан DataFrame с {len(data_rows)} записями")
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Если данных нет, возвращаем пустой DataFrame
|
||||||
|
self.df = pd.DataFrame()
|
||||||
|
logger.debug(f"🔍 Возвращаем пустой DataFrame")
|
||||||
|
return self.df
|
||||||
|
|
||||||
|
def _parse_zip_archive(self, file_path: str, params: dict) -> List[Dict]:
|
||||||
|
"""Парсинг ZIP архива с файлами ремонта СА"""
|
||||||
|
logger.info(f"🔍 Парсинг ZIP архива: {file_path}")
|
||||||
|
|
||||||
|
all_data = []
|
||||||
|
temp_dir = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем временную директорию
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
logger.debug(f"📦 Архив разархивирован в: {temp_dir}")
|
||||||
|
|
||||||
|
# Разархивируем файл
|
||||||
|
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(temp_dir)
|
||||||
|
|
||||||
|
# Ищем Excel файлы в архиве
|
||||||
|
excel_files = []
|
||||||
|
for root, dirs, files in os.walk(temp_dir):
|
||||||
|
for file in files:
|
||||||
|
if file.lower().endswith(('.xlsx', '.xlsm', '.xls')):
|
||||||
|
excel_files.append(os.path.join(root, file))
|
||||||
|
|
||||||
|
logger.info(f"📊 Найдено Excel файлов: {len(excel_files)}")
|
||||||
|
|
||||||
|
# Обрабатываем каждый найденный файл
|
||||||
|
for excel_file in excel_files:
|
||||||
|
logger.info(f"📊 Обработка файла: {excel_file}")
|
||||||
|
file_data = self._parse_single_file(excel_file, params)
|
||||||
|
if file_data:
|
||||||
|
all_data.extend(file_data)
|
||||||
|
|
||||||
|
logger.info(f"🎯 Всего обработано записей: {len(all_data)}")
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при обработке ZIP архива: {e}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
# Удаляем временную директорию
|
||||||
|
if temp_dir:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"🗑️ Временная директория удалена: {temp_dir}")
|
||||||
|
|
||||||
|
def _parse_single_file(self, file_path: str, params: dict) -> List[Dict]:
|
||||||
|
"""Парсинг одиночного Excel файла"""
|
||||||
|
logger.debug(f"🔍 Парсинг файла: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем параметры
|
||||||
|
sheet_name = params.get('sheet_name', 0) # По умолчанию первый лист
|
||||||
|
header_num = params.get('header_num', None)
|
||||||
|
|
||||||
|
# Автоопределение header_num, если не передан
|
||||||
|
if header_num is None:
|
||||||
|
header_num = find_header_row(file_path, sheet_name, search_value="ОГ")
|
||||||
|
if header_num is None:
|
||||||
|
logger.error(f"❌ Не найден заголовок в файле {file_path}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.debug(f"🔍 Заголовок найден в строке {header_num}")
|
||||||
|
|
||||||
|
# Читаем Excel файл
|
||||||
|
df = pd.read_excel(
|
||||||
|
file_path,
|
||||||
|
sheet_name=sheet_name,
|
||||||
|
header=header_num,
|
||||||
|
usecols=None,
|
||||||
|
index_col=None
|
||||||
|
)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
logger.error(f"❌ Файл {file_path} пуст")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if "ОГ" not in df.columns:
|
||||||
|
logger.warning(f"⚠️ Предупреждение: Колонка 'ОГ' не найдена в файле {file_path}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Обрабатываем данные
|
||||||
|
return self._process_repair_data(df)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при парсинге файла {file_path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _process_repair_data(self, df: pd.DataFrame) -> List[Dict]:
|
||||||
|
"""Обработка данных о ремонте"""
|
||||||
|
logger.debug(f"🔍 Обработка данных с {len(df)} строками")
|
||||||
|
|
||||||
|
# Шаг 1: Нормализация ОГ
|
||||||
|
def safe_replace(val):
|
||||||
|
if pd.notna(val) and isinstance(val, str) and val.strip():
|
||||||
|
cleaned_val = val.strip()
|
||||||
|
result = get_og_by_name(cleaned_val)
|
||||||
|
if result and pd.notna(result) and result != "" and result != "UNKNOWN":
|
||||||
|
return result
|
||||||
|
return val
|
||||||
|
|
||||||
|
df["ОГ"] = df["ОГ"].apply(safe_replace)
|
||||||
|
|
||||||
|
# Шаг 2: Приведение к NA и forward fill
|
||||||
|
og_series = df["ОГ"].map(
|
||||||
|
lambda x: pd.NA if (isinstance(x, str) and x.strip() == "") or pd.isna(x) else x
|
||||||
|
)
|
||||||
|
df["ОГ"] = og_series.ffill()
|
||||||
|
|
||||||
|
# Шаг 3: Фильтрация по валидным ОГ
|
||||||
|
valid_og_values = set(SINGLE_OGS)
|
||||||
|
mask_og = df["ОГ"].notna() & df["ОГ"].isin(valid_og_values)
|
||||||
|
df = df[mask_og].copy()
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
logger.info(f"❌ Нет данных после фильтрации по ОГ")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Шаг 4: Удаление строк без "Вид простоя"
|
||||||
|
if "Вид простоя" in df.columns:
|
||||||
|
downtime_clean = df["Вид простоя"].astype(str).str.strip()
|
||||||
|
mask_downtime = (downtime_clean != "") & (downtime_clean != "nan")
|
||||||
|
df = df[mask_downtime].copy()
|
||||||
|
else:
|
||||||
|
logger.info("⚠️ Предупреждение: Колонка 'Вид простоя' не найдена.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Шаг 5: Удаление ненужных колонок
|
||||||
|
cols_to_drop = []
|
||||||
|
for col in df.columns:
|
||||||
|
if col.strip().lower() in ["п/п", "пп", "п.п.", "№"]:
|
||||||
|
cols_to_drop.append(col)
|
||||||
|
elif "НАЛИЧИЕ ПОДРЯДЧИКА" in col.upper() and "ОСНОВНЫЕ РАБОТЫ" in col.upper():
|
||||||
|
cols_to_drop.append(col)
|
||||||
|
|
||||||
|
df.drop(columns=list(set(cols_to_drop)), inplace=True, errors='ignore')
|
||||||
|
|
||||||
|
# Шаг 6: Переименование первых 8 колонок по порядку
|
||||||
|
if df.shape[1] < 8:
|
||||||
|
logger.info(f"⚠️ Внимание: В DataFrame только {df.shape[1]} колонок, требуется минимум 8.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
new_names = ["id", "name", "type", "start_date", "end_date", "plan", "fact", "downtime"]
|
||||||
|
|
||||||
|
# Сохраняем оставшиеся колонки (если больше 8)
|
||||||
|
remaining_cols = df.columns[8:].tolist() # Все, что после 8-й
|
||||||
|
renamed_cols = new_names + remaining_cols
|
||||||
|
df.columns = renamed_cols
|
||||||
|
|
||||||
|
# меняем прочерки на null
|
||||||
|
df = df.replace("-", None)
|
||||||
|
|
||||||
|
# Сброс индекса
|
||||||
|
df.reset_index(drop=True, inplace=True)
|
||||||
|
|
||||||
|
# Шаг 7: Преобразование в список словарей
|
||||||
|
result_data = []
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
try:
|
||||||
|
# Извлекаем основные поля (теперь с правильными именами)
|
||||||
|
og_id = row.get('id')
|
||||||
|
name = row.get('name', '')
|
||||||
|
repair_type = row.get('type', '')
|
||||||
|
|
||||||
|
# Обрабатываем даты
|
||||||
|
start_date = self._parse_date(row.get('start_date'))
|
||||||
|
end_date = self._parse_date(row.get('end_date'))
|
||||||
|
|
||||||
|
# Обрабатываем числовые значения
|
||||||
|
plan = self._parse_numeric(row.get('plan'))
|
||||||
|
fact = self._parse_numeric(row.get('fact'))
|
||||||
|
downtime = self._parse_downtime(row.get('downtime'))
|
||||||
|
|
||||||
|
# Создаем запись
|
||||||
|
record = {
|
||||||
|
"id": og_id,
|
||||||
|
"name": str(name) if pd.notna(name) else "",
|
||||||
|
"type": str(repair_type) if pd.notna(repair_type) else "",
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"plan": plan,
|
||||||
|
"fact": fact,
|
||||||
|
"downtime": downtime
|
||||||
|
}
|
||||||
|
|
||||||
|
result_data.append(record)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"⚠️ Ошибка при обработке строки: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"✅ Обработано {len(result_data)} записей")
|
||||||
|
return result_data
|
||||||
|
|
||||||
|
def _parse_date(self, value) -> Optional[str]:
|
||||||
|
"""Парсинг даты"""
|
||||||
|
if pd.isna(value) or value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Пытаемся преобразовать строку в дату
|
||||||
|
date_obj = pd.to_datetime(value, errors='coerce')
|
||||||
|
if pd.notna(date_obj):
|
||||||
|
return date_obj.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
elif hasattr(value, 'strftime'):
|
||||||
|
# Это уже объект даты
|
||||||
|
return value.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_numeric(self, value) -> Optional[float]:
|
||||||
|
"""Парсинг числового значения"""
|
||||||
|
if pd.isna(value) or value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
# Заменяем запятую на точку для русских чисел
|
||||||
|
cleaned = value.replace(',', '.').strip()
|
||||||
|
return float(cleaned) if cleaned else None
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_downtime(self, value) -> Optional[str]:
|
||||||
|
"""Парсинг данных о простое"""
|
||||||
|
if pd.isna(value) or value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(value).strip() if str(value).strip() else None
|
||||||
@@ -3,6 +3,11 @@ from functools import lru_cache
|
|||||||
import json
|
import json
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
OG_IDS = {
|
OG_IDS = {
|
||||||
"Комсомольский НПЗ": "KNPZ",
|
"Комсомольский НПЗ": "KNPZ",
|
||||||
@@ -22,8 +27,37 @@ OG_IDS = {
|
|||||||
"Красноленинский НПЗ": "KLNPZ",
|
"Красноленинский НПЗ": "KLNPZ",
|
||||||
"Пурнефтепереработка": "PurNP",
|
"Пурнефтепереработка": "PurNP",
|
||||||
"ЯНОС": "YANOS",
|
"ЯНОС": "YANOS",
|
||||||
|
"Уфанефтехим": "UNH",
|
||||||
|
"РНПК": "RNPK",
|
||||||
|
"КмсНПЗ": "KNPZ",
|
||||||
|
"АНХК": "ANHK",
|
||||||
|
"НК НПЗ": "NovKuybNPZ",
|
||||||
|
"КНПЗ": "KuybNPZ",
|
||||||
|
"СНПЗ": "CyzNPZ",
|
||||||
|
"Нижневаторское НПО": "NVNPO",
|
||||||
|
"ПурНП": "PurNP",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SINGLE_OGS = [
|
||||||
|
"KNPZ",
|
||||||
|
"ANHK",
|
||||||
|
"AchNPZ",
|
||||||
|
"BASH",
|
||||||
|
"UNPZ",
|
||||||
|
"UNH",
|
||||||
|
"NOV",
|
||||||
|
"NovKuybNPZ",
|
||||||
|
"KuybNPZ",
|
||||||
|
"CyzNPZ",
|
||||||
|
"TuapsNPZ",
|
||||||
|
"SNPZ",
|
||||||
|
"RNPK",
|
||||||
|
"NVNPO",
|
||||||
|
"KLNPZ",
|
||||||
|
"PurNP",
|
||||||
|
"YANOS",
|
||||||
|
]
|
||||||
|
|
||||||
SNPZ_IDS = {
|
SNPZ_IDS = {
|
||||||
"Висбрекинг": "SNPZ.VISB",
|
"Висбрекинг": "SNPZ.VISB",
|
||||||
"Изомеризация": "SNPZ.IZOM",
|
"Изомеризация": "SNPZ.IZOM",
|
||||||
@@ -40,7 +74,18 @@ SNPZ_IDS = {
|
|||||||
|
|
||||||
|
|
||||||
def replace_id_in_path(file_path, new_id):
|
def replace_id_in_path(file_path, new_id):
|
||||||
return file_path.replace('ID', str(new_id))
|
# Заменяем 'ID' на новое значение
|
||||||
|
modified_path = file_path.replace('ID', str(new_id)) + '.xlsx'
|
||||||
|
|
||||||
|
# Проверяем, существует ли файл
|
||||||
|
if not os.path.exists(modified_path):
|
||||||
|
# Меняем расширение на .xlsm
|
||||||
|
directory, filename = os.path.split(modified_path)
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
new_filename = name + '.xlsm'
|
||||||
|
modified_path = os.path.join(directory, new_filename)
|
||||||
|
|
||||||
|
return modified_path
|
||||||
|
|
||||||
|
|
||||||
def get_table_name(exel):
|
def get_table_name(exel):
|
||||||
@@ -109,6 +154,25 @@ def get_id_by_name(name, dictionary):
|
|||||||
return best_match
|
return best_match
|
||||||
|
|
||||||
|
|
||||||
|
def find_header_row(file, sheet, search_value="Итого", max_rows=50):
|
||||||
|
''' Определения индекса заголовка в exel по ключевому слову '''
|
||||||
|
# Читаем первые max_rows строк без заголовков
|
||||||
|
df_temp = pd.read_excel(
|
||||||
|
file,
|
||||||
|
sheet_name=sheet,
|
||||||
|
header=None,
|
||||||
|
nrows=max_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||||
|
for idx, row in df_temp.iterrows():
|
||||||
|
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
|
||||||
|
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
||||||
|
return idx # 0-based index — то, что нужно для header=
|
||||||
|
|
||||||
|
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
||||||
|
|
||||||
|
|
||||||
def data_to_json(data, indent=2, ensure_ascii=False):
|
def data_to_json(data, indent=2, ensure_ascii=False):
|
||||||
"""
|
"""
|
||||||
Полностью безопасная сериализация данных в JSON.
|
Полностью безопасная сериализация данных в JSON.
|
||||||
@@ -153,11 +217,18 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
|||||||
|
|
||||||
# --- рекурсия по dict и list ---
|
# --- рекурсия по dict и list ---
|
||||||
elif isinstance(obj, dict):
|
elif isinstance(obj, dict):
|
||||||
return {
|
# Обрабатываем только значения, ключи оставляем как строки
|
||||||
key: convert_obj(value)
|
converted = {}
|
||||||
for key, value in obj.items()
|
for k, v in obj.items():
|
||||||
if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON)
|
if is_nan_like(k):
|
||||||
}
|
continue # ключи не могут быть null в JSON
|
||||||
|
# Превращаем ключ в строку, но не пытаемся интерпретировать как число
|
||||||
|
key_str = str(k)
|
||||||
|
converted[key_str] = convert_obj(v) # только значение проходит через convert_obj
|
||||||
|
# Если все значения 0.0 — считаем, что данных нет, т.к. возможно ожидается массив.
|
||||||
|
if converted and all(v == 0.0 for v in converted.values()):
|
||||||
|
return None
|
||||||
|
return converted
|
||||||
|
|
||||||
elif isinstance(obj, list):
|
elif isinstance(obj, list):
|
||||||
return [convert_obj(item) for item in obj]
|
return [convert_obj(item) for item in obj]
|
||||||
@@ -175,7 +246,6 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cleaned_data = convert_obj(data)
|
cleaned_data = convert_obj(data)
|
||||||
cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
return json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
||||||
return cleaned_data
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")
|
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from minio import Minio # boto3
|
from minio import Minio # boto3
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from core.ports import StoragePort
|
from core.ports import StoragePort
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MinIOStorageAdapter(StoragePort):
|
class MinIOStorageAdapter(StoragePort):
|
||||||
"""Адаптер для MinIO хранилища"""
|
"""Адаптер для MinIO хранилища"""
|
||||||
@@ -37,8 +41,8 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
# Проверяем bucket только при первом использовании
|
# Проверяем bucket только при первом использовании
|
||||||
self._ensure_bucket_exists()
|
self._ensure_bucket_exists()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Не удалось подключиться к MinIO: {e}")
|
logger.warning(f"⚠️ Не удалось подключиться к MinIO: {e}")
|
||||||
print("MinIO будет недоступен, но приложение продолжит работать")
|
logger.warning("MinIO будет недоступен, но приложение продолжит работать")
|
||||||
return None
|
return None
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@@ -50,16 +54,16 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
try:
|
try:
|
||||||
if not self.client.bucket_exists(self._bucket_name):
|
if not self.client.bucket_exists(self._bucket_name):
|
||||||
self.client.make_bucket(self._bucket_name)
|
self.client.make_bucket(self._bucket_name)
|
||||||
print(f"✅ Bucket '{self._bucket_name}' создан")
|
logger.info(f"✅ Bucket '{self._bucket_name}' создан")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при работе с bucket: {e}")
|
logger.error(f"❌ Ошибка при работе с bucket: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def save_dataframe(self, df: pd.DataFrame, object_id: str) -> bool:
|
def save_dataframe(self, df: pd.DataFrame, object_id: str) -> bool:
|
||||||
"""Сохранение DataFrame в MinIO"""
|
"""Сохранение DataFrame в MinIO"""
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
print("⚠️ MinIO недоступен, данные не сохранены")
|
logger.warning("⚠️ MinIO недоступен, данные не сохранены")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -78,16 +82,16 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
content_type='application/octet-stream'
|
content_type='application/octet-stream'
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"✅ DataFrame успешно сохранен в MinIO: {self._bucket_name}/{object_id}")
|
logger.info(f"✅ DataFrame успешно сохранен в MinIO: {self._bucket_name}/{object_id}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при сохранении в MinIO: {e}")
|
logger.error(f"❌ Ошибка при сохранении в MinIO: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]:
|
def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]:
|
||||||
"""Загрузка DataFrame из MinIO"""
|
"""Загрузка DataFrame из MinIO"""
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
print("⚠️ MinIO недоступен, данные не загружены")
|
logger.warning("⚠️ MinIO недоступен, данные не загружены")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -102,7 +106,7 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
|
|
||||||
return df
|
return df
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при загрузке данных из MinIO: {e}")
|
logger.error(f"❌ Ошибка при загрузке данных из MinIO: {e}")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if 'response' in locals():
|
if 'response' in locals():
|
||||||
@@ -112,15 +116,15 @@ class MinIOStorageAdapter(StoragePort):
|
|||||||
def delete_object(self, object_id: str) -> bool:
|
def delete_object(self, object_id: str) -> bool:
|
||||||
"""Удаление объекта из MinIO"""
|
"""Удаление объекта из MinIO"""
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
print("⚠️ MinIO недоступен, объект не удален")
|
logger.warning("⚠️ MinIO недоступен, объект не удален")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.client.remove_object(self._bucket_name, object_id)
|
self.client.remove_object(self._bucket_name, object_id)
|
||||||
print(f"✅ Объект успешно удален из MinIO: {self._bucket_name}/{object_id}")
|
logger.info(f"✅ Объект успешно удален из MinIO: {self._bucket_name}/{object_id}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка при удалении объекта из MinIO: {e}")
|
logger.error(f"❌ Ошибка при удалении объекта из MinIO: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def object_exists(self, object_id: str) -> bool:
|
def object_exists(self, object_id: str) -> bool:
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import os
|
import os
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import logging
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from fastapi import FastAPI, File, UploadFile, HTTPException, status
|
from fastapi import FastAPI, File, UploadFile, HTTPException, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(name)s:%(lineno)d - %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from adapters.storage import MinIOStorageAdapter
|
from adapters.storage import MinIOStorageAdapter
|
||||||
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
|
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, MonitoringTarParser, SvodkaRepairCAParser, StatusesRepairCAParser, OperSpravkaTechPosParser
|
||||||
|
|
||||||
from core.models import UploadRequest, DataRequest
|
from core.models import UploadRequest, DataRequest
|
||||||
from core.services import ReportService, PARSERS
|
from core.services import ReportService, PARSERS
|
||||||
@@ -18,6 +29,10 @@ from app.schemas import (
|
|||||||
SvodkaCARequest,
|
SvodkaCARequest,
|
||||||
MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
|
MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
|
||||||
)
|
)
|
||||||
|
from app.schemas.oper_spravka_tech_pos import OperSpravkaTechPosRequest, OperSpravkaTechPosResponse
|
||||||
|
from app.schemas.svodka_repair_ca import SvodkaRepairCARequest
|
||||||
|
from app.schemas.statuses_repair_ca import StatusesRepairCARequest
|
||||||
|
from app.schemas.monitoring_tar import MonitoringTarRequest, MonitoringTarFullRequest
|
||||||
|
|
||||||
|
|
||||||
# Парсеры
|
# Парсеры
|
||||||
@@ -25,6 +40,10 @@ PARSERS.update({
|
|||||||
'svodka_pm': SvodkaPMParser,
|
'svodka_pm': SvodkaPMParser,
|
||||||
'svodka_ca': SvodkaCAParser,
|
'svodka_ca': SvodkaCAParser,
|
||||||
'monitoring_fuel': MonitoringFuelParser,
|
'monitoring_fuel': MonitoringFuelParser,
|
||||||
|
'monitoring_tar': MonitoringTarParser,
|
||||||
|
'svodka_repair_ca': SvodkaRepairCAParser,
|
||||||
|
'statuses_repair_ca': StatusesRepairCAParser,
|
||||||
|
'oper_spravka_tech_pos': OperSpravkaTechPosParser,
|
||||||
# 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser,
|
# 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,22 +99,129 @@ async def root():
|
|||||||
description="Возвращает список идентификаторов всех доступных парсеров",
|
description="Возвращает список идентификаторов всех доступных парсеров",
|
||||||
response_model=Dict[str, List[str]],
|
response_model=Dict[str, List[str]],
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"example": {
|
||||||
"parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"]
|
"parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},)
|
},)
|
||||||
async def get_available_parsers():
|
async def get_available_parsers():
|
||||||
"""Получение списка доступных парсеров"""
|
"""Получение списка доступных парсеров"""
|
||||||
parsers = list(PARSERS.keys())
|
parsers = list(PARSERS.keys())
|
||||||
return {"parsers": parsers}
|
return {"parsers": parsers}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/parsers/{parser_name}/available_ogs", tags=["Общее"],
|
||||||
|
summary="Доступные ОГ для парсера",
|
||||||
|
description="Возвращает список доступных ОГ для указанного парсера",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"parser": "svodka_repair_ca",
|
||||||
|
"available_ogs": ["KNPZ", "ANHK", "SNPZ", "BASH"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},)
|
||||||
|
async def get_available_ogs(parser_name: str):
|
||||||
|
"""Получение списка доступных ОГ для парсера"""
|
||||||
|
if parser_name not in PARSERS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Парсер '{parser_name}' не найден")
|
||||||
|
|
||||||
|
parser_class = PARSERS[parser_name]
|
||||||
|
|
||||||
|
# Для парсеров с данными в MinIO возвращаем ОГ из загруженных данных
|
||||||
|
if parser_name in ["svodka_repair_ca", "oper_spravka_tech_pos"]:
|
||||||
|
try:
|
||||||
|
# Создаем экземпляр сервиса и загружаем данные из MinIO
|
||||||
|
report_service = get_report_service()
|
||||||
|
from core.models import DataRequest
|
||||||
|
data_request = DataRequest(report_type=parser_name, get_params={})
|
||||||
|
loaded_data = report_service.get_data(data_request)
|
||||||
|
# Если данные загружены, извлекаем ОГ из них
|
||||||
|
if loaded_data is not None and hasattr(loaded_data, 'data') and loaded_data.data is not None:
|
||||||
|
# Для svodka_repair_ca данные возвращаются в формате словаря по ОГ
|
||||||
|
if parser_name == "svodka_repair_ca":
|
||||||
|
data_value = loaded_data.data.get('value')
|
||||||
|
if isinstance(data_value, dict):
|
||||||
|
available_ogs = list(data_value.keys())
|
||||||
|
return {"parser": parser_name, "available_ogs": available_ogs}
|
||||||
|
# Для oper_spravka_tech_pos данные возвращаются в формате списка
|
||||||
|
elif parser_name == "oper_spravka_tech_pos":
|
||||||
|
# Данные уже в правильном формате, возвращаем их
|
||||||
|
if isinstance(loaded_data.data, list) and loaded_data.data:
|
||||||
|
# Извлекаем уникальные ОГ из данных
|
||||||
|
available_ogs = []
|
||||||
|
for item in loaded_data.data:
|
||||||
|
if isinstance(item, dict) and 'id' in item:
|
||||||
|
available_ogs.append(item['id'])
|
||||||
|
if available_ogs:
|
||||||
|
return {"parser": parser_name, "available_ogs": available_ogs}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ Ошибка при получении ОГ: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# Для других парсеров или если нет данных возвращаем статический список из pconfig
|
||||||
|
from adapters.pconfig import SINGLE_OGS
|
||||||
|
return {"parser": parser_name, "available_ogs": SINGLE_OGS}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/parsers/{parser_name}/getters", tags=["Общее"],
|
||||||
|
summary="Информация о геттерах парсера",
|
||||||
|
description="Возвращает информацию о доступных геттерах для указанного парсера",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"parser": "svodka_pm",
|
||||||
|
"getters": {
|
||||||
|
"single_og": {
|
||||||
|
"required_params": ["id", "codes", "columns"],
|
||||||
|
"optional_params": ["search"],
|
||||||
|
"description": "Получение данных по одному ОГ"
|
||||||
|
},
|
||||||
|
"total_ogs": {
|
||||||
|
"required_params": ["codes", "columns"],
|
||||||
|
"optional_params": ["search"],
|
||||||
|
"description": "Получение данных по всем ОГ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Парсер не найден"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async def get_parser_getters(parser_name: str):
|
||||||
|
"""Получение информации о геттерах парсера"""
|
||||||
|
if parser_name not in PARSERS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Парсер '{parser_name}' не найден"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser_class = PARSERS[parser_name]
|
||||||
|
parser_instance = parser_class()
|
||||||
|
|
||||||
|
getters_info = parser_instance.get_available_getters()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"parser": parser_name,
|
||||||
|
"getters": getters_info
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/server-info", tags=["Общее"],
|
@app.get("/server-info", tags=["Общее"],
|
||||||
summary="Информация о сервере",
|
summary="Информация о сервере",
|
||||||
response_model=ServerInfoResponse,)
|
response_model=ServerInfoResponse,)
|
||||||
@@ -275,7 +401,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'
|
request_dict['mode'] = 'single_og'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='svodka_pm',
|
report_type='svodka_pm',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -329,7 +455,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'
|
request_dict['mode'] = 'total_ogs'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='svodka_pm',
|
report_type='svodka_pm',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -352,40 +478,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],
|
||||||
@@ -508,6 +634,246 @@ async def get_svodka_ca_data(
|
|||||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/svodka_repair_ca/upload", tags=[SvodkaRepairCAParser.name],
|
||||||
|
summary="Загрузка файла отчета сводки ремонта СА",
|
||||||
|
response_model=UploadResponse,
|
||||||
|
responses={
|
||||||
|
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||||
|
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||||
|
},)
|
||||||
|
async def upload_svodka_repair_ca(
|
||||||
|
file: UploadFile = File(..., description="Excel файл или ZIP архив сводки ремонта СА (.xlsx, .xlsm, .xls, .zip)")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Загрузка и обработка Excel файла или ZIP архива отчета сводки ремонта СА
|
||||||
|
|
||||||
|
**Поддерживаемые форматы:**
|
||||||
|
- Excel (.xlsx, .xlsm, .xls)
|
||||||
|
- ZIP архив (.zip)
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем тип файла
|
||||||
|
if not file.filename.lower().endswith(('.xlsx', '.xlsm', '.xls', '.zip')):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
content=UploadErrorResponse(
|
||||||
|
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или ZIP архивы (.zip)",
|
||||||
|
error_code="INVALID_FILE_TYPE",
|
||||||
|
details={
|
||||||
|
"expected_formats": [".xlsx", ".xlsm", ".xls", ".zip"],
|
||||||
|
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||||
|
}
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Читаем содержимое файла
|
||||||
|
file_content = await file.read()
|
||||||
|
|
||||||
|
# Создаем запрос
|
||||||
|
request = UploadRequest(
|
||||||
|
report_type='svodka_repair_ca',
|
||||||
|
file_content=file_content,
|
||||||
|
file_name=file.filename
|
||||||
|
)
|
||||||
|
|
||||||
|
# Загружаем отчет
|
||||||
|
result = report_service.upload_report(request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return UploadResponse(
|
||||||
|
success=True,
|
||||||
|
message=result.message,
|
||||||
|
object_id=result.object_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content=UploadErrorResponse(
|
||||||
|
message=result.message,
|
||||||
|
error_code="ERR_UPLOAD"
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content=UploadErrorResponse(
|
||||||
|
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||||
|
error_code="INTERNAL_SERVER_ERROR"
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/svodka_repair_ca/get_data", tags=[SvodkaRepairCAParser.name],
|
||||||
|
summary="Получение данных из отчета сводки ремонта СА")
|
||||||
|
async def get_svodka_repair_ca_data(
|
||||||
|
request_data: SvodkaRepairCARequest
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получение данных из отчета сводки ремонта СА
|
||||||
|
|
||||||
|
### Структура параметров:
|
||||||
|
- `og_ids`: **Массив ID ОГ** для фильтрации (опциональный)
|
||||||
|
- `repair_types`: **Массив типов ремонта** - `КР`, `КП`, `ТР` (опциональный)
|
||||||
|
- `include_planned`: **Включать плановые данные** (по умолчанию true)
|
||||||
|
- `include_factual`: **Включать фактические данные** (по умолчанию true)
|
||||||
|
|
||||||
|
### Пример тела запроса:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"og_ids": ["SNPZ", "KNPZ"],
|
||||||
|
"repair_types": ["КР", "КП"],
|
||||||
|
"include_planned": true,
|
||||||
|
"include_factual": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем запрос
|
||||||
|
request_dict = request_data.model_dump()
|
||||||
|
request = DataRequest(
|
||||||
|
report_type='svodka_repair_ca',
|
||||||
|
get_params=request_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем данные
|
||||||
|
result = report_service.get_data(request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result.data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail=result.message)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/statuses_repair_ca/upload", tags=[StatusesRepairCAParser.name],
|
||||||
|
summary="Загрузка отчета статусов ремонта СА")
|
||||||
|
async def upload_statuses_repair_ca(
|
||||||
|
file: UploadFile = File(...)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Загрузка отчета статусов ремонта СА
|
||||||
|
|
||||||
|
### Поддерживаемые форматы:
|
||||||
|
- **Excel файлы**: `.xlsx`, `.xlsm`, `.xls`
|
||||||
|
- **ZIP архивы**: `.zip` (содержащие Excel файлы)
|
||||||
|
|
||||||
|
### Пример использования:
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/statuses_repair_ca/upload" \
|
||||||
|
-H "accept: application/json" \
|
||||||
|
-H "Content-Type: multipart/form-data" \
|
||||||
|
-F "file=@statuses_repair_ca.xlsx"
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем тип файла
|
||||||
|
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls', '.zip')):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или архивы (.zip)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Читаем содержимое файла
|
||||||
|
file_content = await file.read()
|
||||||
|
|
||||||
|
# Создаем запрос на загрузку
|
||||||
|
upload_request = UploadRequest(
|
||||||
|
report_type='statuses_repair_ca',
|
||||||
|
file_content=file_content,
|
||||||
|
file_name=file.filename
|
||||||
|
)
|
||||||
|
|
||||||
|
# Загружаем отчет
|
||||||
|
result = report_service.upload_report(upload_request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return UploadResponse(
|
||||||
|
success=True,
|
||||||
|
message="Отчет успешно загружен и обработан",
|
||||||
|
report_id=result.object_id,
|
||||||
|
filename=file.filename
|
||||||
|
).model_dump()
|
||||||
|
else:
|
||||||
|
return UploadErrorResponse(
|
||||||
|
success=False,
|
||||||
|
message=result.message,
|
||||||
|
error_code="ERR_UPLOAD",
|
||||||
|
details=None
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/statuses_repair_ca/get_data", tags=[StatusesRepairCAParser.name],
|
||||||
|
summary="Получение данных из отчета статусов ремонта СА")
|
||||||
|
async def get_statuses_repair_ca_data(
|
||||||
|
request_data: StatusesRepairCARequest
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получение данных из отчета статусов ремонта СА
|
||||||
|
|
||||||
|
### Структура параметров:
|
||||||
|
- `ids`: **Массив ID ОГ** для фильтрации (опциональный)
|
||||||
|
- `keys`: **Массив ключей** для извлечения данных (опциональный)
|
||||||
|
|
||||||
|
### Пример тела запроса:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ids": ["SNPZ", "KNPZ", "ANHK"],
|
||||||
|
"keys": [
|
||||||
|
["Дата начала ремонта"],
|
||||||
|
["Готовность к КР", "Факт"],
|
||||||
|
["Заключение договоров на СМР", "Договор", "%"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем запрос
|
||||||
|
request_dict = request_data.model_dump()
|
||||||
|
request = DataRequest(
|
||||||
|
report_type='statuses_repair_ca',
|
||||||
|
get_params=request_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем данные
|
||||||
|
result = report_service.get_data(request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result.data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail=result.message)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# @app.post("/monitoring_fuel/upload", tags=[MonitoringFuelParser.name])
|
# @app.post("/monitoring_fuel/upload", tags=[MonitoringFuelParser.name])
|
||||||
# async def upload_monitoring_fuel(
|
# async def upload_monitoring_fuel(
|
||||||
# file: UploadFile = File(...),
|
# file: UploadFile = File(...),
|
||||||
@@ -562,38 +928,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])
|
||||||
@@ -756,7 +1122,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'
|
request_dict['mode'] = 'total_by_columns'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='monitoring_fuel',
|
report_type='monitoring_fuel',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -801,7 +1167,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'
|
request_dict['mode'] = 'month_by_code'
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
report_type='monitoring_fuel',
|
report_type='monitoring_fuel',
|
||||||
get_params=request_dict
|
get_params=request_dict
|
||||||
@@ -824,5 +1190,258 @@ async def get_monitoring_fuel_month_by_code(
|
|||||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ====== MONITORING TAR ENDPOINTS ======
|
||||||
|
|
||||||
|
@app.post("/monitoring_tar/upload", tags=[MonitoringTarParser.name],
|
||||||
|
summary="Загрузка отчета мониторинга ТЭР")
|
||||||
|
async def upload_monitoring_tar(
|
||||||
|
file: UploadFile = File(...)
|
||||||
|
):
|
||||||
|
"""Загрузка и обработка отчета мониторинга ТЭР (Топливно-энергетических ресурсов)
|
||||||
|
|
||||||
|
### Поддерживаемые форматы:
|
||||||
|
- **ZIP архивы** с файлами мониторинга ТЭР
|
||||||
|
|
||||||
|
### Структура данных:
|
||||||
|
- Обрабатывает ZIP архивы с файлами по месяцам (svodka_tar_SNPZ_01.xlsx - svodka_tar_SNPZ_12.xlsx)
|
||||||
|
- Извлекает данные по установкам (SNPZ_IDS)
|
||||||
|
- Возвращает два типа данных: 'total' (строки "Всего") и 'last_day' (последние строки)
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем тип файла - только ZIP архивы
|
||||||
|
if not file.filename.endswith('.zip'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Читаем содержимое файла
|
||||||
|
file_content = await file.read()
|
||||||
|
|
||||||
|
# Создаем запрос на загрузку
|
||||||
|
upload_request = UploadRequest(
|
||||||
|
report_type='monitoring_tar',
|
||||||
|
file_content=file_content,
|
||||||
|
file_name=file.filename
|
||||||
|
)
|
||||||
|
|
||||||
|
# Загружаем отчет
|
||||||
|
result = report_service.upload_report(upload_request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return UploadResponse(
|
||||||
|
success=True,
|
||||||
|
message="Отчет успешно загружен и обработан",
|
||||||
|
report_id=result.object_id,
|
||||||
|
filename=file.filename
|
||||||
|
).model_dump()
|
||||||
|
else:
|
||||||
|
return UploadErrorResponse(
|
||||||
|
success=False,
|
||||||
|
message=result.message,
|
||||||
|
error_code="ERR_UPLOAD",
|
||||||
|
details=None
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/monitoring_tar/get_data", tags=[MonitoringTarParser.name],
|
||||||
|
summary="Получение данных из отчета мониторинга ТЭР")
|
||||||
|
async def get_monitoring_tar_data(
|
||||||
|
request_data: MonitoringTarRequest
|
||||||
|
):
|
||||||
|
"""Получение данных из отчета мониторинга ТЭР
|
||||||
|
|
||||||
|
### Структура параметров:
|
||||||
|
- `mode`: **Режим получения данных** (опциональный)
|
||||||
|
- `"total"` - строки "Всего" (агрегированные данные)
|
||||||
|
- `"last_day"` - последние строки данных
|
||||||
|
- Если не указан, возвращаются все данные
|
||||||
|
|
||||||
|
### Пример тела запроса:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "total"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем запрос
|
||||||
|
request_dict = request_data.model_dump()
|
||||||
|
request = DataRequest(
|
||||||
|
report_type='monitoring_tar',
|
||||||
|
get_params=request_dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем данные
|
||||||
|
result = report_service.get_data(request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result.data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail=result.message)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/monitoring_tar/get_full_data", tags=[MonitoringTarParser.name],
|
||||||
|
summary="Получение всех данных из отчета мониторинга ТЭР")
|
||||||
|
async def get_monitoring_tar_full_data():
|
||||||
|
"""Получение всех данных из отчета мониторинга ТЭР без фильтрации
|
||||||
|
|
||||||
|
### Возвращает:
|
||||||
|
- Все данные по всем установкам
|
||||||
|
- И данные 'total', и данные 'last_day'
|
||||||
|
- Полная структура данных мониторинга ТЭР
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем запрос без параметров
|
||||||
|
request = DataRequest(
|
||||||
|
report_type='monitoring_tar',
|
||||||
|
get_params={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем данные
|
||||||
|
result = report_service.get_data(request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result.data
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail=result.message)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ====== OPER SPRAVKA TECH POS ENDPOINTS ======
|
||||||
|
|
||||||
|
@app.post("/oper_spravka_tech_pos/upload", tags=[OperSpravkaTechPosParser.name],
|
||||||
|
summary="Загрузка отчета операционной справки технологических позиций")
|
||||||
|
async def upload_oper_spravka_tech_pos(
|
||||||
|
file: UploadFile = File(...)
|
||||||
|
):
|
||||||
|
"""Загрузка и обработка отчета операционной справки технологических позиций
|
||||||
|
|
||||||
|
### Поддерживаемые форматы:
|
||||||
|
- **ZIP архивы** с файлами операционных справок
|
||||||
|
|
||||||
|
### Структура данных:
|
||||||
|
- Обрабатывает ZIP архивы с файлами операционных справок по технологическим позициям
|
||||||
|
- Извлекает данные по процессам: Первичная переработка, Гидроочистка топлив, Риформирование, Изомеризация
|
||||||
|
- Возвращает данные по установкам с планом и фактом
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем тип файла - только ZIP архивы
|
||||||
|
if not file.filename.endswith('.zip'):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Читаем содержимое файла
|
||||||
|
file_content = await file.read()
|
||||||
|
|
||||||
|
# Создаем запрос на загрузку
|
||||||
|
upload_request = UploadRequest(
|
||||||
|
report_type="oper_spravka_tech_pos",
|
||||||
|
file_name=file.filename,
|
||||||
|
file_content=file_content,
|
||||||
|
parse_params={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Загружаем и обрабатываем отчет
|
||||||
|
result = report_service.upload_report(upload_request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
return UploadResponse(
|
||||||
|
success=True,
|
||||||
|
message="Отчет успешно загружен и обработан",
|
||||||
|
object_id=result.object_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return UploadErrorResponse(
|
||||||
|
success=False,
|
||||||
|
message=result.message,
|
||||||
|
error_code="ERR_UPLOAD",
|
||||||
|
details=None
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oper_spravka_tech_pos/get_data", tags=[OperSpravkaTechPosParser.name],
|
||||||
|
summary="Получение данных операционной справки технологических позиций",
|
||||||
|
response_model=OperSpravkaTechPosResponse)
|
||||||
|
async def get_oper_spravka_tech_pos_data(request: OperSpravkaTechPosRequest):
|
||||||
|
"""Получение данных операционной справки технологических позиций по ОГ
|
||||||
|
|
||||||
|
### Параметры:
|
||||||
|
- **id** (str): ID ОГ (например, 'SNPZ', 'KNPZ')
|
||||||
|
|
||||||
|
### Возвращает:
|
||||||
|
- Данные по технологическим позициям для указанного ОГ
|
||||||
|
- Включает информацию о процессах, установках, плане и факте
|
||||||
|
"""
|
||||||
|
report_service = get_report_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем запрос на получение данных
|
||||||
|
data_request = DataRequest(
|
||||||
|
report_type="oper_spravka_tech_pos",
|
||||||
|
get_params={"id": request.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем данные
|
||||||
|
result = report_service.get_data(data_request)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
# Извлекаем данные из результата
|
||||||
|
value_data = result.data.get("value", []) if isinstance(result.data.get("value"), list) else []
|
||||||
|
logger.debug(f"🔍 API возвращает данные: {type(value_data)}, длина: {len(value_data) if isinstance(value_data, (list, dict)) else 'N/A'}")
|
||||||
|
|
||||||
|
return OperSpravkaTechPosResponse(
|
||||||
|
success=True,
|
||||||
|
data=value_data,
|
||||||
|
message="Данные успешно получены"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return OperSpravkaTechPosResponse(
|
||||||
|
success=False,
|
||||||
|
data=None,
|
||||||
|
message=result.message
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
33
python_parser/app/schemas/monitoring_tar.py
Normal file
33
python_parser/app/schemas/monitoring_tar.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class TarMode(str, Enum):
|
||||||
|
"""Режимы получения данных мониторинга ТЭР"""
|
||||||
|
TOTAL = "total"
|
||||||
|
LAST_DAY = "last_day"
|
||||||
|
|
||||||
|
class MonitoringTarRequest(BaseModel):
|
||||||
|
"""Схема запроса для получения данных мониторинга ТЭР"""
|
||||||
|
mode: Optional[TarMode] = Field(
|
||||||
|
None,
|
||||||
|
description="Режим получения данных: 'total' (строки 'Всего') или 'last_day' (последние строки). Если не указан, возвращаются все данные",
|
||||||
|
example="total"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"mode": "total"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MonitoringTarFullRequest(BaseModel):
|
||||||
|
"""Схема запроса для получения всех данных мониторинга ТЭР"""
|
||||||
|
# Пустая схема - возвращает все данные без фильтрации
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {}
|
||||||
|
}
|
||||||
38
python_parser/app/schemas/oper_spravka_tech_pos.py
Normal file
38
python_parser/app/schemas/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class OperSpravkaTechPosRequest(BaseModel):
|
||||||
|
"""Запрос для получения данных операционной справки технологических позиций"""
|
||||||
|
id: str = Field(..., description="ID ОГ (например, 'SNPZ', 'KNPZ')")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"id": "SNPZ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OperSpravkaTechPosResponse(BaseModel):
|
||||||
|
"""Ответ с данными операционной справки технологических позиций"""
|
||||||
|
success: bool = Field(..., description="Статус успешности операции")
|
||||||
|
data: Optional[List[dict]] = Field(None, description="Данные по технологическим позициям")
|
||||||
|
message: Optional[str] = Field(None, description="Сообщение о результате операции")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"success": True,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"Процесс": "Первичная переработка",
|
||||||
|
"Установка": "ЭЛОУ-АВТ-6",
|
||||||
|
"План, т": 14855.0,
|
||||||
|
"Факт, т": 15149.647,
|
||||||
|
"id": "SNPZ.EAVT6"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"message": "Данные успешно получены"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
python_parser/app/schemas/statuses_repair_ca.py
Normal file
34
python_parser/app/schemas/statuses_repair_ca.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class StatusesRepairCARequest(BaseModel):
|
||||||
|
ids: Optional[List[str]] = Field(
|
||||||
|
None,
|
||||||
|
description="Массив ID ОГ для фильтрации (например, ['SNPZ', 'KNPZ'])",
|
||||||
|
example=["SNPZ", "KNPZ", "ANHK"]
|
||||||
|
)
|
||||||
|
keys: Optional[List[List[str]]] = Field(
|
||||||
|
None,
|
||||||
|
description="Массив ключей для извлечения данных (например, [['Дата начала ремонта'], ['Готовность к КР', 'Факт']])",
|
||||||
|
example=[
|
||||||
|
["Дата начала ремонта"],
|
||||||
|
["Отставание / опережение подготовки к КР", "Отставание / опережение"],
|
||||||
|
["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"],
|
||||||
|
["Готовность к КР", "Факт"],
|
||||||
|
["Заключение договоров на СМР", "Договор", "%"],
|
||||||
|
["Поставка МТР", "На складе, позиций", "%"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"ids": ["SNPZ", "KNPZ", "ANHK"],
|
||||||
|
"keys": [
|
||||||
|
["Дата начала ремонта"],
|
||||||
|
["Готовность к КР", "Факт"],
|
||||||
|
["Заключение договоров на СМР", "Договор", "%"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ class OGID(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class SvodkaPMSingleOGRequest(BaseModel):
|
class SvodkaPMSingleOGRequest(BaseModel):
|
||||||
id: OGID = Field(
|
id: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Идентификатор МА для запрашиваемого ОГ",
|
description="Идентификатор МА для запрашиваемого ОГ",
|
||||||
example="SNPZ"
|
example="SNPZ"
|
||||||
|
|||||||
46
python_parser/app/schemas/svodka_repair_ca.py
Normal file
46
python_parser/app/schemas/svodka_repair_ca.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class RepairType(str, Enum):
|
||||||
|
"""Типы ремонтных работ"""
|
||||||
|
KR = "КР" # Капитальный ремонт
|
||||||
|
KP = "КП" # Капитальный ремонт
|
||||||
|
TR = "ТР" # Текущий ремонт
|
||||||
|
|
||||||
|
|
||||||
|
class SvodkaRepairCARequest(BaseModel):
|
||||||
|
"""Запрос на получение данных сводки ремонта СА"""
|
||||||
|
|
||||||
|
og_ids: Optional[List[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Список ID ОГ для фильтрации. Если не указан, возвращаются данные по всем ОГ",
|
||||||
|
example=["SNPZ", "KNPZ", "BASH"]
|
||||||
|
)
|
||||||
|
|
||||||
|
repair_types: Optional[List[RepairType]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Список типов ремонта для фильтрации. Если не указан, возвращаются все типы",
|
||||||
|
example=[RepairType.KR, RepairType.KP]
|
||||||
|
)
|
||||||
|
|
||||||
|
include_planned: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Включать ли плановые данные"
|
||||||
|
)
|
||||||
|
|
||||||
|
include_factual: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Включать ли фактические данные"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"og_ids": ["SNPZ", "KNPZ"],
|
||||||
|
"repair_types": ["КР", "КП"],
|
||||||
|
"include_planned": True,
|
||||||
|
"include_factual": True
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -2,28 +2,93 @@
|
|||||||
Порты (интерфейсы) для hexagonal architecture
|
Порты (интерфейсы) для hexagonal architecture
|
||||||
"""
|
"""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional
|
from typing import Optional, Dict, List, Any, Callable
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
class ParserPort(ABC):
|
class ParserPort(ABC):
|
||||||
"""Интерфейс для парсеров"""
|
"""Интерфейс для парсеров с поддержкой множественных геттеров"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Инициализация с пустым словарем геттеров"""
|
||||||
|
self.getters: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._register_default_getters()
|
||||||
|
|
||||||
|
def _register_default_getters(self):
|
||||||
|
"""Регистрация геттеров по умолчанию - переопределяется в наследниках"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def register_getter(self, name: str, method: Callable, required_params: List[str],
|
||||||
|
optional_params: List[str] = None, description: str = ""):
|
||||||
|
"""
|
||||||
|
Регистрация нового геттера
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя геттера
|
||||||
|
method: Метод для выполнения
|
||||||
|
required_params: Список обязательных параметров
|
||||||
|
optional_params: Список необязательных параметров
|
||||||
|
description: Описание геттера
|
||||||
|
"""
|
||||||
|
if optional_params is None:
|
||||||
|
optional_params = []
|
||||||
|
|
||||||
|
self.getters[name] = {
|
||||||
|
"method": method,
|
||||||
|
"required_params": required_params,
|
||||||
|
"optional_params": optional_params,
|
||||||
|
"description": description
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_available_getters(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Получение списка доступных геттеров с их описанием"""
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
"required_params": info["required_params"],
|
||||||
|
"optional_params": info["optional_params"],
|
||||||
|
"description": info["description"]
|
||||||
|
}
|
||||||
|
for name, info in self.getters.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавить схему
|
||||||
|
def get_value(self, getter_name: str, params: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Получение значения через указанный геттер
|
||||||
|
|
||||||
|
Args:
|
||||||
|
getter_name: Имя геттера
|
||||||
|
params: Параметры для геттера
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результат выполнения геттера
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Если геттер не найден или параметры неверны
|
||||||
|
"""
|
||||||
|
if getter_name not in self.getters:
|
||||||
|
available = list(self.getters.keys())
|
||||||
|
raise ValueError(f"Геттер '{getter_name}' не найден. Доступные: {available}")
|
||||||
|
|
||||||
|
getter_info = self.getters[getter_name]
|
||||||
|
required = getter_info["required_params"]
|
||||||
|
|
||||||
|
# Проверка обязательных параметров
|
||||||
|
missing = [p for p in required if p not in params]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Отсутствуют обязательные параметры для геттера '{getter_name}': {missing}")
|
||||||
|
|
||||||
|
# Вызов метода геттера
|
||||||
|
try:
|
||||||
|
return getter_info["method"](params)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Ошибка выполнения геттера '{getter_name}': {str(e)}")
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||||
"""Парсинг файла и возврат DataFrame"""
|
"""Парсинг файла и возврат DataFrame"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_value(self, df: pd.DataFrame, params: dict):
|
|
||||||
"""Получение значения из DataFrame по параметрам"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# @abstractmethod
|
|
||||||
# def get_schema(self) -> dict:
|
|
||||||
# """Возвращает схему входных параметров для парсера"""
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
class StoragePort(ABC):
|
class StoragePort(ABC):
|
||||||
"""Интерфейс для хранилища данных"""
|
"""Интерфейс для хранилища данных"""
|
||||||
|
|||||||
140
python_parser/core/schema_utils.py
Normal file
140
python_parser/core/schema_utils.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Упрощенные утилиты для работы со схемами Pydantic
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Type
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Извлекает список обязательных полей из схемы Pydantic
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_class: Класс схемы Pydantic
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список имен обязательных полей
|
||||||
|
"""
|
||||||
|
required_fields = []
|
||||||
|
|
||||||
|
# Используем model_fields для Pydantic v2 или __fields__ для v1
|
||||||
|
if hasattr(schema_class, 'model_fields'):
|
||||||
|
fields = schema_class.model_fields
|
||||||
|
else:
|
||||||
|
fields = schema_class.__fields__
|
||||||
|
|
||||||
|
for field_name, field_info in fields.items():
|
||||||
|
# В Pydantic v2 есть метод is_required()
|
||||||
|
if hasattr(field_info, 'is_required'):
|
||||||
|
if field_info.is_required():
|
||||||
|
required_fields.append(field_name)
|
||||||
|
elif hasattr(field_info, 'required'):
|
||||||
|
if field_info.required:
|
||||||
|
required_fields.append(field_name)
|
||||||
|
else:
|
||||||
|
# Fallback для старых версий - проверяем наличие default
|
||||||
|
has_default = False
|
||||||
|
|
||||||
|
if hasattr(field_info, 'default'):
|
||||||
|
has_default = field_info.default is not ...
|
||||||
|
elif hasattr(field_info, 'default_factory'):
|
||||||
|
has_default = field_info.default_factory is not None
|
||||||
|
|
||||||
|
if not has_default:
|
||||||
|
required_fields.append(field_name)
|
||||||
|
|
||||||
|
return required_fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_optional_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Извлекает список необязательных полей из схемы Pydantic
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_class: Класс схемы Pydantic
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список имен необязательных полей
|
||||||
|
"""
|
||||||
|
optional_fields = []
|
||||||
|
|
||||||
|
# Используем model_fields для Pydantic v2 или __fields__ для v1
|
||||||
|
if hasattr(schema_class, 'model_fields'):
|
||||||
|
fields = schema_class.model_fields
|
||||||
|
else:
|
||||||
|
fields = schema_class.__fields__
|
||||||
|
|
||||||
|
for field_name, field_info in fields.items():
|
||||||
|
# В Pydantic v2 есть метод is_required()
|
||||||
|
if hasattr(field_info, 'is_required'):
|
||||||
|
if not field_info.is_required():
|
||||||
|
optional_fields.append(field_name)
|
||||||
|
elif hasattr(field_info, 'required'):
|
||||||
|
if not field_info.required:
|
||||||
|
optional_fields.append(field_name)
|
||||||
|
else:
|
||||||
|
# Fallback для старых версий - проверяем наличие default
|
||||||
|
has_default = False
|
||||||
|
|
||||||
|
if hasattr(field_info, 'default'):
|
||||||
|
has_default = field_info.default is not ...
|
||||||
|
elif hasattr(field_info, 'default_factory'):
|
||||||
|
has_default = field_info.default_factory is not None
|
||||||
|
|
||||||
|
if has_default:
|
||||||
|
optional_fields.append(field_name)
|
||||||
|
|
||||||
|
return optional_fields
|
||||||
|
|
||||||
|
|
||||||
|
def register_getter_from_schema(parser_instance, getter_name: str, method: callable,
|
||||||
|
schema_class: Type[BaseModel], description: str = ""):
|
||||||
|
"""
|
||||||
|
Регистрирует геттер в парсере, используя схему Pydantic для определения параметров
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parser_instance: Экземпляр парсера
|
||||||
|
getter_name: Имя геттера
|
||||||
|
method: Метод для выполнения
|
||||||
|
schema_class: Класс схемы Pydantic
|
||||||
|
description: Описание геттера (если не указано, берется из docstring метода)
|
||||||
|
"""
|
||||||
|
# Извлекаем параметры из схемы
|
||||||
|
required_params = get_required_fields_from_schema(schema_class)
|
||||||
|
optional_params = get_optional_fields_from_schema(schema_class)
|
||||||
|
|
||||||
|
# Если описание не указано, берем из docstring метода
|
||||||
|
if not description:
|
||||||
|
description = inspect.getdoc(method) or ""
|
||||||
|
|
||||||
|
# Регистрируем геттер
|
||||||
|
parser_instance.register_getter(
|
||||||
|
name=getter_name,
|
||||||
|
method=method,
|
||||||
|
required_params=required_params,
|
||||||
|
optional_params=optional_params,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_params_with_schema(params: Dict[str, Any], schema_class: Type[BaseModel]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Валидирует параметры с помощью схемы Pydantic
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: Словарь параметров
|
||||||
|
schema_class: Класс схемы Pydantic
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Валидированные параметры
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: Если параметры не прошли валидацию
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Создаем экземпляр схемы для валидации
|
||||||
|
validated_data = schema_class(**params)
|
||||||
|
return validated_data.dict()
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Ошибка валидации параметров: {str(e)}")
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
"""
|
"""
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from typing import Dict, Type
|
from typing import Dict, Type
|
||||||
|
|
||||||
from core.models import UploadRequest, UploadResult, DataRequest, DataResult
|
from core.models import UploadRequest, UploadResult, DataRequest, DataResult
|
||||||
from core.ports import ParserPort, StoragePort
|
from core.ports import ParserPort, StoragePort
|
||||||
|
|
||||||
|
# Настройка логгера для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Глобальный словарь парсеров
|
# Глобальный словарь парсеров
|
||||||
PARSERS: Dict[str, Type[ParserPort]] = {}
|
PARSERS: Dict[str, Type[ParserPort]] = {}
|
||||||
@@ -43,7 +47,7 @@ class ReportService:
|
|||||||
try:
|
try:
|
||||||
# Парсим файл
|
# Парсим файл
|
||||||
parse_params = request.parse_params or {}
|
parse_params = request.parse_params or {}
|
||||||
df = parser.parse(temp_file_path, parse_params)
|
parse_result = parser.parse(temp_file_path, parse_params)
|
||||||
|
|
||||||
# Генерируем object_id
|
# Генерируем object_id
|
||||||
object_id = f"nin_excel_data_{request.report_type}"
|
object_id = f"nin_excel_data_{request.report_type}"
|
||||||
@@ -51,10 +55,10 @@ class ReportService:
|
|||||||
# Удаляем старый объект, если он существует и хранилище доступно
|
# Удаляем старый объект, если он существует и хранилище доступно
|
||||||
if self.storage.object_exists(object_id):
|
if self.storage.object_exists(object_id):
|
||||||
self.storage.delete_object(object_id)
|
self.storage.delete_object(object_id)
|
||||||
print(f"Старый объект удален: {object_id}")
|
logger.debug(f"Старый объект удален: {object_id}")
|
||||||
|
|
||||||
# Сохраняем в хранилище
|
# Сохраняем в хранилище
|
||||||
if self.storage.save_dataframe(df, object_id):
|
if self.storage.save_dataframe(parse_result, object_id):
|
||||||
return UploadResult(
|
return UploadResult(
|
||||||
success=True,
|
success=True,
|
||||||
message="Отчет успешно загружен",
|
message="Отчет успешно загружен",
|
||||||
@@ -89,9 +93,9 @@ class ReportService:
|
|||||||
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Загружаем DataFrame из хранилища
|
# Загружаем данные из хранилища
|
||||||
df = self.storage.load_dataframe(object_id)
|
loaded_data = self.storage.load_dataframe(object_id)
|
||||||
if df is None:
|
if loaded_data is None:
|
||||||
return DataResult(
|
return DataResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
||||||
@@ -100,8 +104,110 @@ class ReportService:
|
|||||||
# Получаем парсер
|
# Получаем парсер
|
||||||
parser = get_parser(request.report_type)
|
parser = get_parser(request.report_type)
|
||||||
|
|
||||||
# Получаем значение
|
# Устанавливаем данные в парсер для использования в геттерах
|
||||||
value = parser.get_value(df, request.get_params)
|
parser.df = loaded_data
|
||||||
|
logger.debug(f"🔍 ReportService.get_data - установлены данные в парсер {request.report_type}")
|
||||||
|
|
||||||
|
# Проверяем тип загруженных данных
|
||||||
|
if hasattr(loaded_data, 'shape'):
|
||||||
|
# Это DataFrame
|
||||||
|
logger.debug(f"🔍 DataFrame shape: {loaded_data.shape}")
|
||||||
|
logger.debug(f"🔍 DataFrame columns: {list(loaded_data.columns) if not loaded_data.empty else 'Empty'}")
|
||||||
|
elif isinstance(loaded_data, dict):
|
||||||
|
# Это словарь (для парсера ПМ)
|
||||||
|
logger.debug(f"🔍 Словарь с ключами: {list(loaded_data.keys())}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"🔍 Неизвестный тип данных: {type(loaded_data)}")
|
||||||
|
|
||||||
|
# Получаем параметры запроса
|
||||||
|
get_params = request.get_params or {}
|
||||||
|
|
||||||
|
# Для svodka_ca определяем режим из данных или используем 'fact' по умолчанию
|
||||||
|
if request.report_type == 'svodka_ca':
|
||||||
|
# Извлекаем режим из DataFrame или используем 'fact' по умолчанию
|
||||||
|
if hasattr(parser, 'df') and parser.df is not None and not parser.df.empty:
|
||||||
|
modes_in_df = parser.df['mode'].unique() if 'mode' in parser.df.columns else ['fact']
|
||||||
|
# Используем первый найденный режим или 'fact' по умолчанию
|
||||||
|
default_mode = modes_in_df[0] if len(modes_in_df) > 0 else 'fact'
|
||||||
|
else:
|
||||||
|
default_mode = 'fact'
|
||||||
|
|
||||||
|
# Устанавливаем режим в параметры, если он не указан
|
||||||
|
if 'mode' not in get_params:
|
||||||
|
get_params['mode'] = default_mode
|
||||||
|
|
||||||
|
# Определяем имя геттера
|
||||||
|
if request.report_type == 'svodka_ca':
|
||||||
|
# Для svodka_ca используем геттер get_ca_data
|
||||||
|
getter_name = 'get_ca_data'
|
||||||
|
elif request.report_type == 'svodka_repair_ca':
|
||||||
|
# Для svodka_repair_ca используем геттер get_repair_data
|
||||||
|
getter_name = 'get_repair_data'
|
||||||
|
elif request.report_type == 'statuses_repair_ca':
|
||||||
|
# Для statuses_repair_ca используем геттер get_repair_statuses
|
||||||
|
getter_name = 'get_repair_statuses'
|
||||||
|
elif request.report_type == 'monitoring_tar':
|
||||||
|
# Для monitoring_tar определяем геттер по параметрам
|
||||||
|
if 'mode' in get_params:
|
||||||
|
# Если есть параметр mode, используем get_tar_data
|
||||||
|
getter_name = 'get_tar_data'
|
||||||
|
else:
|
||||||
|
# Если нет параметра mode, используем get_tar_full_data
|
||||||
|
getter_name = 'get_tar_full_data'
|
||||||
|
elif request.report_type == 'monitoring_fuel':
|
||||||
|
# Для monitoring_fuel определяем геттер из параметра mode
|
||||||
|
getter_name = get_params.pop("mode", None)
|
||||||
|
if not getter_name:
|
||||||
|
# Если режим не указан, берем первый доступный
|
||||||
|
available_getters = list(parser.getters.keys())
|
||||||
|
if available_getters:
|
||||||
|
getter_name = available_getters[0]
|
||||||
|
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
||||||
|
else:
|
||||||
|
return DataResult(
|
||||||
|
success=False,
|
||||||
|
message="Парсер не имеет доступных геттеров"
|
||||||
|
)
|
||||||
|
elif request.report_type == 'svodka_pm':
|
||||||
|
# Для svodka_pm определяем геттер из параметра mode
|
||||||
|
getter_name = get_params.pop("mode", None)
|
||||||
|
if not getter_name:
|
||||||
|
# Если режим не указан, берем первый доступный
|
||||||
|
available_getters = list(parser.getters.keys())
|
||||||
|
if available_getters:
|
||||||
|
getter_name = available_getters[0]
|
||||||
|
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
||||||
|
else:
|
||||||
|
return DataResult(
|
||||||
|
success=False,
|
||||||
|
message="Парсер не имеет доступных геттеров"
|
||||||
|
)
|
||||||
|
elif request.report_type == 'oper_spravka_tech_pos':
|
||||||
|
# Для oper_spravka_tech_pos используем геттер get_tech_pos
|
||||||
|
getter_name = 'get_tech_pos'
|
||||||
|
else:
|
||||||
|
# Для других парсеров определяем из параметра mode
|
||||||
|
getter_name = get_params.pop("mode", None)
|
||||||
|
if not getter_name:
|
||||||
|
# Если режим не указан, берем первый доступный
|
||||||
|
available_getters = list(parser.getters.keys())
|
||||||
|
if available_getters:
|
||||||
|
getter_name = available_getters[0]
|
||||||
|
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
||||||
|
else:
|
||||||
|
return DataResult(
|
||||||
|
success=False,
|
||||||
|
message="Парсер не имеет доступных геттеров"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем значение через указанный геттер
|
||||||
|
try:
|
||||||
|
value = parser.get_value(getter_name, get_params)
|
||||||
|
except ValueError as e:
|
||||||
|
return DataResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Ошибка параметров: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Формируем результат
|
# Формируем результат
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|||||||
20
python_parser/test_app.py
Normal file
20
python_parser/test_app.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Простой тест для проверки работы FastAPI
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
app = FastAPI(title="Test API")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "Test API is working"}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
print("Starting test server...")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import io
|
import io
|
||||||
import zipfile
|
import zipfile
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, List
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Конфигурация страницы
|
# Конфигурация страницы
|
||||||
@@ -50,7 +50,12 @@ def get_server_info():
|
|||||||
def upload_file_to_api(endpoint: str, file_data: bytes, filename: str):
|
def upload_file_to_api(endpoint: str, file_data: bytes, filename: str):
|
||||||
"""Загрузка файла на API"""
|
"""Загрузка файла на API"""
|
||||||
try:
|
try:
|
||||||
files = {"zip_file": (filename, file_data, "application/zip")}
|
# Определяем правильное имя поля в зависимости от эндпоинта
|
||||||
|
if "zip" in endpoint:
|
||||||
|
files = {"zip_file": (filename, file_data, "application/zip")}
|
||||||
|
else:
|
||||||
|
files = {"file": (filename, file_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||||
|
|
||||||
response = requests.post(f"{API_BASE_URL}{endpoint}", files=files)
|
response = requests.post(f"{API_BASE_URL}{endpoint}", files=files)
|
||||||
return response.json(), response.status_code
|
return response.json(), response.status_code
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -64,6 +69,20 @@ def make_api_request(endpoint: str, data: Dict[str, Any]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}, 500
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
def get_available_ogs(parser_name: str) -> List[str]:
|
||||||
|
"""Получение доступных ОГ для парсера"""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{API_BASE_URL}/parsers/{parser_name}/available_ogs")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("available_ogs", [])
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Ошибка получения ОГ: {response.status_code}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Ошибка при запросе ОГ: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
st.title("🚀 NIN Excel Parsers API - Демонстрация")
|
st.title("🚀 NIN Excel Parsers API - Демонстрация")
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
@@ -96,10 +115,14 @@ def main():
|
|||||||
st.write(f"• {parser}")
|
st.write(f"• {parser}")
|
||||||
|
|
||||||
# Основные вкладки - по одной на каждый парсер
|
# Основные вкладки - по одной на каждый парсер
|
||||||
tab1, tab2, tab3 = st.tabs([
|
tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs([
|
||||||
"📊 Сводки ПМ",
|
"📊 Сводки ПМ",
|
||||||
"🏭 Сводки СА",
|
"🏭 Сводки СА",
|
||||||
"⛽ Мониторинг топлива"
|
"⛽ Мониторинг топлива",
|
||||||
|
"🔧 Ремонт СА",
|
||||||
|
"📋 Статусы ремонта СА",
|
||||||
|
"⚡ Мониторинг ТЭР",
|
||||||
|
"🏭 Операционные справки"
|
||||||
])
|
])
|
||||||
|
|
||||||
# Вкладка 1: Сводки ПМ - полный функционал
|
# Вкладка 1: Сводки ПМ - полный функционал
|
||||||
@@ -371,6 +394,430 @@ def main():
|
|||||||
else:
|
else:
|
||||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
# Вкладка 4: Ремонт СА
|
||||||
|
with tab4:
|
||||||
|
st.header("🔧 Ремонт СА - Управление ремонтными работами")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
|
||||||
|
uploaded_file = st.file_uploader(
|
||||||
|
"Выберите Excel файл или ZIP архив с данными о ремонте СА",
|
||||||
|
type=['xlsx', 'xlsm', 'xls', 'zip'],
|
||||||
|
key="repair_ca_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_file is not None:
|
||||||
|
if st.button("📤 Загрузить файл", key="repair_ca_upload_btn"):
|
||||||
|
with st.spinner("Загружаю файл..."):
|
||||||
|
file_data = uploaded_file.read()
|
||||||
|
result, status = upload_file_to_api("/svodka_repair_ca/upload", file_data, uploaded_file.name)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Файл успешно загружен")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка загрузки: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("🔍 Получение данных")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("Фильтры")
|
||||||
|
|
||||||
|
# Получаем доступные ОГ динамически
|
||||||
|
available_ogs = get_available_ogs("svodka_repair_ca")
|
||||||
|
|
||||||
|
# Фильтр по ОГ
|
||||||
|
og_ids = st.multiselect(
|
||||||
|
"Выберите ОГ (оставьте пустым для всех)",
|
||||||
|
available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback
|
||||||
|
key="repair_ca_og_ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фильтр по типам ремонта
|
||||||
|
repair_types = st.multiselect(
|
||||||
|
"Выберите типы ремонта (оставьте пустым для всех)",
|
||||||
|
["КР", "КП", "ТР"],
|
||||||
|
key="repair_ca_types"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Включение плановых/фактических данных
|
||||||
|
include_planned = st.checkbox("Включать плановые данные", value=True, key="repair_ca_planned")
|
||||||
|
include_factual = st.checkbox("Включать фактические данные", value=True, key="repair_ca_factual")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("Действия")
|
||||||
|
|
||||||
|
if st.button("🔍 Получить данные о ремонте", key="repair_ca_get_btn"):
|
||||||
|
with st.spinner("Получаю данные..."):
|
||||||
|
data = {
|
||||||
|
"include_planned": include_planned,
|
||||||
|
"include_factual": include_factual
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавляем фильтры только если они выбраны
|
||||||
|
if og_ids:
|
||||||
|
data["og_ids"] = og_ids
|
||||||
|
if repair_types:
|
||||||
|
data["repair_types"] = repair_types
|
||||||
|
|
||||||
|
result, status = make_api_request("/svodka_repair_ca/get_data", data)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
st.success("✅ Данные получены")
|
||||||
|
|
||||||
|
# Отображаем данные в виде таблицы, если возможно
|
||||||
|
if result.get("data") and isinstance(result["data"], list):
|
||||||
|
df_data = []
|
||||||
|
for item in result["data"]:
|
||||||
|
df_data.append({
|
||||||
|
"ID ОГ": item.get("id", ""),
|
||||||
|
"Наименование": item.get("name", ""),
|
||||||
|
"Тип ремонта": item.get("type", ""),
|
||||||
|
"Дата начала": item.get("start_date", ""),
|
||||||
|
"Дата окончания": item.get("end_date", ""),
|
||||||
|
"План": item.get("plan", ""),
|
||||||
|
"Факт": item.get("fact", ""),
|
||||||
|
"Простой": item.get("downtime", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
if df_data:
|
||||||
|
df = pd.DataFrame(df_data)
|
||||||
|
st.dataframe(df, use_container_width=True)
|
||||||
|
else:
|
||||||
|
st.info("📋 Нет данных для отображения")
|
||||||
|
else:
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
# Вкладка 5: Статусы ремонта СА
|
||||||
|
with tab5:
|
||||||
|
st.header("📋 Статусы ремонта СА")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
uploaded_file = st.file_uploader(
|
||||||
|
"Выберите файл статусов ремонта СА",
|
||||||
|
type=['xlsx', 'xlsm', 'xls', 'zip'],
|
||||||
|
key="statuses_repair_ca_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_file is not None:
|
||||||
|
if st.button("📤 Загрузить файл", key="statuses_repair_ca_upload_btn"):
|
||||||
|
with st.spinner("Загружаем файл..."):
|
||||||
|
file_data = uploaded_file.read()
|
||||||
|
result, status_code = upload_file_to_api("/statuses_repair_ca/upload", file_data, uploaded_file.name)
|
||||||
|
|
||||||
|
if status_code == 200:
|
||||||
|
st.success("✅ Файл успешно загружен!")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка загрузки: {result}")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("📊 Получение данных")
|
||||||
|
|
||||||
|
# Получаем доступные ОГ динамически
|
||||||
|
available_ogs = get_available_ogs("statuses_repair_ca")
|
||||||
|
|
||||||
|
# Фильтр по ОГ
|
||||||
|
og_ids = st.multiselect(
|
||||||
|
"Выберите ОГ (оставьте пустым для всех)",
|
||||||
|
available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback
|
||||||
|
key="statuses_repair_ca_og_ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Предустановленные ключи для извлечения
|
||||||
|
st.subheader("🔑 Ключи для извлечения данных")
|
||||||
|
|
||||||
|
# Основные ключи
|
||||||
|
include_basic_keys = st.checkbox("Основные данные", value=True, key="statuses_basic_keys")
|
||||||
|
include_readiness_keys = st.checkbox("Готовность к КР", value=True, key="statuses_readiness_keys")
|
||||||
|
include_contract_keys = st.checkbox("Заключение договоров", value=True, key="statuses_contract_keys")
|
||||||
|
include_supply_keys = st.checkbox("Поставка МТР", value=True, key="statuses_supply_keys")
|
||||||
|
|
||||||
|
# Формируем ключи на основе выбора
|
||||||
|
keys = []
|
||||||
|
if include_basic_keys:
|
||||||
|
keys.append(["Дата начала ремонта"])
|
||||||
|
keys.append(["Отставание / опережение подготовки к КР", "Отставание / опережение"])
|
||||||
|
keys.append(["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"])
|
||||||
|
|
||||||
|
if include_readiness_keys:
|
||||||
|
keys.append(["Готовность к КР", "Факт"])
|
||||||
|
|
||||||
|
if include_contract_keys:
|
||||||
|
keys.append(["Заключение договоров на СМР", "Договор", "%"])
|
||||||
|
|
||||||
|
if include_supply_keys:
|
||||||
|
keys.append(["Поставка МТР", "На складе, позиций", "%"])
|
||||||
|
|
||||||
|
# Кнопка получения данных
|
||||||
|
if st.button("📊 Получить данные", key="statuses_repair_ca_get_data_btn"):
|
||||||
|
if not keys:
|
||||||
|
st.warning("⚠️ Выберите хотя бы одну группу ключей для извлечения")
|
||||||
|
else:
|
||||||
|
with st.spinner("Получаем данные..."):
|
||||||
|
request_data = {
|
||||||
|
"ids": og_ids if og_ids else None,
|
||||||
|
"keys": keys
|
||||||
|
}
|
||||||
|
|
||||||
|
result, status_code = make_api_request("/statuses_repair_ca/get_data", request_data)
|
||||||
|
|
||||||
|
if status_code == 200 and result.get("success"):
|
||||||
|
st.success("✅ Данные успешно получены!")
|
||||||
|
|
||||||
|
data = result.get("data", {}).get("value", [])
|
||||||
|
if data:
|
||||||
|
# Отображаем данные в виде таблицы
|
||||||
|
if isinstance(data, list) and len(data) > 0:
|
||||||
|
# Преобразуем в DataFrame для лучшего отображения
|
||||||
|
df_data = []
|
||||||
|
for item in data:
|
||||||
|
row = {
|
||||||
|
"ID": item.get("id", ""),
|
||||||
|
"Название": item.get("name", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавляем основные поля
|
||||||
|
if "Дата начала ремонта" in item:
|
||||||
|
row["Дата начала ремонта"] = item["Дата начала ремонта"]
|
||||||
|
|
||||||
|
# Добавляем готовность к КР
|
||||||
|
if "Готовность к КР" in item:
|
||||||
|
readiness = item["Готовность к КР"]
|
||||||
|
if isinstance(readiness, dict) and "Факт" in readiness:
|
||||||
|
row["Готовность к КР (Факт)"] = readiness["Факт"]
|
||||||
|
|
||||||
|
# Добавляем отставание/опережение
|
||||||
|
if "Отставание / опережение подготовки к КР" in item:
|
||||||
|
delay = item["Отставание / опережение подготовки к КР"]
|
||||||
|
if isinstance(delay, dict):
|
||||||
|
if "Отставание / опережение" in delay:
|
||||||
|
row["Отставание/опережение"] = delay["Отставание / опережение"]
|
||||||
|
if "Динамика за прошедшую неделю" in delay:
|
||||||
|
row["Динамика за неделю"] = delay["Динамика за прошедшую неделю"]
|
||||||
|
|
||||||
|
# Добавляем договоры
|
||||||
|
if "Заключение договоров на СМР" in item:
|
||||||
|
contracts = item["Заключение договоров на СМР"]
|
||||||
|
if isinstance(contracts, dict) and "Договор" in contracts:
|
||||||
|
contract = contracts["Договор"]
|
||||||
|
if isinstance(contract, dict) and "%" in contract:
|
||||||
|
row["Договоры (%)"] = contract["%"]
|
||||||
|
|
||||||
|
# Добавляем поставки МТР
|
||||||
|
if "Поставка МТР" in item:
|
||||||
|
supply = item["Поставка МТР"]
|
||||||
|
if isinstance(supply, dict) and "На складе, позиций" in supply:
|
||||||
|
warehouse = supply["На складе, позиций"]
|
||||||
|
if isinstance(warehouse, dict) and "%" in warehouse:
|
||||||
|
row["МТР на складе (%)"] = warehouse["%"]
|
||||||
|
|
||||||
|
df_data.append(row)
|
||||||
|
|
||||||
|
if df_data:
|
||||||
|
df = pd.DataFrame(df_data)
|
||||||
|
st.dataframe(df, use_container_width=True)
|
||||||
|
else:
|
||||||
|
st.info("📋 Нет данных для отображения")
|
||||||
|
else:
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.info("📋 Нет данных для отображения")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
# Вкладка 6: Мониторинг ТЭР
|
||||||
|
with tab6:
|
||||||
|
st.header("⚡ Мониторинг ТЭР (Топливно-энергетических ресурсов)")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
uploaded_file = st.file_uploader(
|
||||||
|
"Выберите ZIP архив с файлами мониторинга ТЭР",
|
||||||
|
type=['zip'],
|
||||||
|
key="monitoring_tar_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_file is not None:
|
||||||
|
if st.button("📤 Загрузить файл", key="monitoring_tar_upload_btn"):
|
||||||
|
with st.spinner("Загружаем файл..."):
|
||||||
|
file_data = uploaded_file.read()
|
||||||
|
result, status_code = upload_file_to_api("/monitoring_tar/upload", file_data, uploaded_file.name)
|
||||||
|
|
||||||
|
if status_code == 200:
|
||||||
|
st.success("✅ Файл успешно загружен!")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка загрузки: {result}")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("📊 Получение данных")
|
||||||
|
|
||||||
|
# Выбор формата отображения
|
||||||
|
display_format = st.radio(
|
||||||
|
"Формат отображения:",
|
||||||
|
["JSON", "Таблица"],
|
||||||
|
key="monitoring_tar_display_format",
|
||||||
|
horizontal=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Выбор режима данных
|
||||||
|
mode = st.selectbox(
|
||||||
|
"Выберите режим данных:",
|
||||||
|
["all", "total", "last_day"],
|
||||||
|
help="total - строки 'Всего' (агрегированные данные), last_day - последние строки данных, all - все данные",
|
||||||
|
key="monitoring_tar_mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("📊 Получить данные", key="monitoring_tar_get_data_btn"):
|
||||||
|
with st.spinner("Получаем данные..."):
|
||||||
|
# Выбираем эндпоинт в зависимости от режима
|
||||||
|
if mode == "all":
|
||||||
|
# Используем полный эндпоинт
|
||||||
|
result, status_code = make_api_request("/monitoring_tar/get_full_data", {})
|
||||||
|
else:
|
||||||
|
# Используем фильтрованный эндпоинт
|
||||||
|
request_data = {"mode": mode}
|
||||||
|
result, status_code = make_api_request("/monitoring_tar/get_data", request_data)
|
||||||
|
|
||||||
|
if status_code == 200 and result.get("success"):
|
||||||
|
st.success("✅ Данные успешно получены!")
|
||||||
|
|
||||||
|
# Показываем данные
|
||||||
|
data = result.get("data", {}).get("value", {})
|
||||||
|
if data:
|
||||||
|
st.subheader("📋 Результат:")
|
||||||
|
|
||||||
|
# # Отладочная информация
|
||||||
|
# st.write(f"🔍 Тип данных: {type(data)}")
|
||||||
|
# if isinstance(data, str):
|
||||||
|
# st.write(f"🔍 Длина строки: {len(data)}")
|
||||||
|
# st.write(f"🔍 Первые 200 символов: {data[:200]}...")
|
||||||
|
|
||||||
|
# Парсим данные, если они пришли как строка
|
||||||
|
if isinstance(data, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
data = json.loads(data)
|
||||||
|
st.write("✅ JSON успешно распарсен")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
st.error(f"❌ Ошибка при парсинге JSON данных: {e}")
|
||||||
|
st.write("Сырые данные:", data)
|
||||||
|
return
|
||||||
|
|
||||||
|
if display_format == "JSON":
|
||||||
|
# Отображаем как JSON
|
||||||
|
st.json(data)
|
||||||
|
else:
|
||||||
|
# Отображаем как таблицы
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Показываем данные по установкам
|
||||||
|
for installation_id, installation_data in data.items():
|
||||||
|
with st.expander(f"🏭 {installation_id}"):
|
||||||
|
if isinstance(installation_data, dict):
|
||||||
|
# Показываем структуру данных
|
||||||
|
for data_type, type_data in installation_data.items():
|
||||||
|
st.write(f"**{data_type}:**")
|
||||||
|
if isinstance(type_data, list) and type_data:
|
||||||
|
df = pd.DataFrame(type_data)
|
||||||
|
st.dataframe(df)
|
||||||
|
else:
|
||||||
|
st.write("Нет данных")
|
||||||
|
else:
|
||||||
|
st.write("Нет данных")
|
||||||
|
else:
|
||||||
|
st.json(data)
|
||||||
|
else:
|
||||||
|
st.info("📋 Нет данных для отображения")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
|
# Вкладка 7: Операционные справки технологических позиций
|
||||||
|
with tab7:
|
||||||
|
st.header("🏭 Операционные справки технологических позиций")
|
||||||
|
|
||||||
|
# Секция загрузки файлов
|
||||||
|
st.subheader("📤 Загрузка файлов")
|
||||||
|
|
||||||
|
uploaded_file = st.file_uploader(
|
||||||
|
"Выберите ZIP архив с файлами операционных справок",
|
||||||
|
type=['zip'],
|
||||||
|
key="oper_spravka_tech_pos_upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_file is not None:
|
||||||
|
if st.button("📤 Загрузить файл", key="oper_spravka_tech_pos_upload_btn"):
|
||||||
|
with st.spinner("Загружаем файл..."):
|
||||||
|
file_data = uploaded_file.read()
|
||||||
|
result, status_code = upload_file_to_api("/oper_spravka_tech_pos/upload", file_data, uploaded_file.name)
|
||||||
|
|
||||||
|
if status_code == 200:
|
||||||
|
st.success("✅ Файл успешно загружен!")
|
||||||
|
st.json(result)
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка загрузки: {result}")
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Секция получения данных
|
||||||
|
st.subheader("📊 Получение данных")
|
||||||
|
|
||||||
|
# Выбор формата отображения
|
||||||
|
display_format = st.radio(
|
||||||
|
"Формат отображения:",
|
||||||
|
["JSON", "Таблица"],
|
||||||
|
key="oper_spravka_tech_pos_display_format",
|
||||||
|
horizontal=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем доступные ОГ динамически
|
||||||
|
available_ogs = get_available_ogs("oper_spravka_tech_pos")
|
||||||
|
|
||||||
|
# Выбор ОГ
|
||||||
|
og_id = st.selectbox(
|
||||||
|
"Выберите ОГ:",
|
||||||
|
available_ogs if available_ogs else ["SNPZ", "KNPZ", "ANHK", "BASH", "UNH", "NOV"],
|
||||||
|
key="oper_spravka_tech_pos_og_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("📊 Получить данные", key="oper_spravka_tech_pos_get_data_btn"):
|
||||||
|
with st.spinner("Получаем данные..."):
|
||||||
|
request_data = {"id": og_id}
|
||||||
|
result, status_code = make_api_request("/oper_spravka_tech_pos/get_data", request_data)
|
||||||
|
|
||||||
|
if status_code == 200 and result.get("success"):
|
||||||
|
st.success("✅ Данные успешно получены!")
|
||||||
|
|
||||||
|
# Показываем данные
|
||||||
|
data = result.get("data", [])
|
||||||
|
|
||||||
|
if data and len(data) > 0:
|
||||||
|
st.subheader("📋 Результат:")
|
||||||
|
|
||||||
|
if display_format == "JSON":
|
||||||
|
# Отображаем как JSON
|
||||||
|
st.json(data)
|
||||||
|
else:
|
||||||
|
# Отображаем как таблицу
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
st.dataframe(df, use_container_width=True)
|
||||||
|
else:
|
||||||
|
st.write("Нет данных")
|
||||||
|
else:
|
||||||
|
st.info("📋 Нет данных для отображения")
|
||||||
|
else:
|
||||||
|
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||||
|
|
||||||
# Футер
|
# Футер
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
st.markdown("### 📚 Документация API")
|
st.markdown("### 📚 Документация API")
|
||||||
@@ -385,6 +832,9 @@ def main():
|
|||||||
- 📊 Парсинг сводок ПМ (план и факт)
|
- 📊 Парсинг сводок ПМ (план и факт)
|
||||||
- 🏭 Парсинг сводок СА
|
- 🏭 Парсинг сводок СА
|
||||||
- ⛽ Мониторинг топлива
|
- ⛽ Мониторинг топлива
|
||||||
|
- ⚡ Мониторинг ТЭР (Топливно-энергетические ресурсы)
|
||||||
|
- 🔧 Управление ремонтными работами СА
|
||||||
|
- 📋 Мониторинг статусов ремонта СА
|
||||||
|
|
||||||
**Технологии:**
|
**Технологии:**
|
||||||
- FastAPI
|
- FastAPI
|
||||||
|
|||||||
BIN
test_repair_ca.zip
Normal file
BIN
test_repair_ca.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user