Compare commits
43 Commits
arch-2
...
fix-parser
| Author | SHA1 | Date | |
|---|---|---|---|
| 31fad98133 | |||
| 88294de506 | |||
| cb8e8b2483 | |||
| 4624442991 | |||
| 816547d82c | |||
| 2f459487fe | |||
| 46a3c2e9cd | |||
| 57d9d5a703 | |||
| 802cf5ffba | |||
| 3ffe547208 | |||
| 9f9adce4f3 | |||
| 8ee1c816e2 | |||
| 34937ec062 | |||
| 55626490dd | |||
| 1bfe3c0cd8 | |||
| 36f37ffacb | |||
| 6a1f685ee3 | |||
| 2fcee9f065 | |||
| f54a36ab22 | |||
| 2555fd80e0 | |||
| 847441842c | |||
| 00a01e99d7 | |||
| bbbfbbd508 | |||
| 0f3340c899 | |||
| 3c0fce128d | |||
| b5c460bb6f | |||
| 4aca4ed6c6 | |||
| 8ede706a1e | |||
| 1d43ba8c5a | |||
| e22ef647eb | |||
| 51ee5bf73b | |||
| 0a328f9781 | |||
| 1fcb44193d | |||
| 631e58dad7 | |||
| 15d13870f3 | |||
| eb6d23bba8 | |||
| e3077252a8 | |||
| 8ed61a3c0b | |||
| 9c152ebe94 | |||
| b8074765e3 | |||
| 79ab91c700 | |||
| b98be22359 | |||
| fc0b4356da |
20
.gitignore
vendored
20
.gitignore
vendored
@@ -1,5 +1,23 @@
|
||||
# Python
|
||||
__pycache__
|
||||
__pycache__/
|
||||
python_parser/__pycache__/
|
||||
python_parser/core/__pycache__/
|
||||
python_parser/adapters/__pycache__/
|
||||
python_parser/tests/__pycache__/
|
||||
python_parser/tests/test_core/__pycache__/
|
||||
python_parser/tests/test_adapters/__pycache__/
|
||||
python_parser/tests/test_app/__pycache__/
|
||||
python_parser/app/__pycache__/
|
||||
python_parser/app/schemas/__pycache__/
|
||||
python_parser/app/schemas/test_schemas/__pycache__/
|
||||
python_parser/app/schemas/test_schemas/test_core/__pycache__/
|
||||
python_parser/app/schemas/test_schemas/test_adapters/__pycache__/
|
||||
python_parser/app/schemas/test_schemas/test_app/__pycache__/
|
||||
|
||||
nin_python_parser
|
||||
*.pyc
|
||||
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
@@ -153,3 +171,5 @@ node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
__pycache__/
|
||||
|
||||
1002
PARSER_DEVELOPMENT_GUIDE.md
Normal file
1002
PARSER_DEVELOPMENT_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
fastapi:
|
||||
build: ./python_parser
|
||||
image: python:3.11-slim
|
||||
container_name: svodka_fastapi_dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -24,9 +24,20 @@ services:
|
||||
- MINIO_SECRET_KEY=minioadmin
|
||||
- MINIO_SECURE=false
|
||||
- MINIO_BUCKET=svodka-data
|
||||
volumes:
|
||||
# Монтируем исходный код для автоматической перезагрузки
|
||||
- ./python_parser:/app
|
||||
# Монтируем requirements.txt для установки зависимостей
|
||||
- ./python_parser/requirements.txt:/app/requirements.txt
|
||||
working_dir: /app
|
||||
depends_on:
|
||||
- minio
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
bash -c "
|
||||
pip install --no-cache-dir -r requirements.txt &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
"
|
||||
|
||||
streamlit:
|
||||
image: python:3.11-slim
|
||||
|
||||
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_tar import MonitoringTarParser
|
||||
from .svodka_ca import SvodkaCAParser
|
||||
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__ = [
|
||||
'MonitoringFuelParser',
|
||||
'MonitoringTarParser',
|
||||
'SvodkaCAParser',
|
||||
'SvodkaPMParser'
|
||||
'SvodkaPMParser',
|
||||
'SvodkaRepairCAParser',
|
||||
'StatusesRepairCAParser',
|
||||
'OperSpravkaTechPosParser'
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +1,277 @@
|
||||
import pandas as pd
|
||||
import re
|
||||
from typing import Dict
|
||||
|
||||
import zipfile
|
||||
import logging
|
||||
from typing import Dict, Tuple
|
||||
from core.ports import ParserPort
|
||||
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
||||
from app.schemas.monitoring_fuel import MonitoringFuelTotalRequest, MonitoringFuelMonthRequest, MonitoringFuelSeriesRequest
|
||||
from adapters.pconfig import data_to_json, get_object_by_name
|
||||
|
||||
# Настройка логгера для модуля
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonitoringFuelParser(ParserPort):
|
||||
"""Парсер для мониторинга топлива"""
|
||||
|
||||
name = "Мониторинг топлива"
|
||||
|
||||
def _register_default_getters(self):
|
||||
"""Регистрация геттеров по умолчанию"""
|
||||
# Используем схемы Pydantic как единый источник правды
|
||||
register_getter_from_schema(
|
||||
parser_instance=self,
|
||||
getter_name="total_by_columns",
|
||||
method=self._get_total_by_columns,
|
||||
schema_class=MonitoringFuelTotalRequest,
|
||||
description="Агрегация данных по колонкам"
|
||||
)
|
||||
|
||||
register_getter_from_schema(
|
||||
parser_instance=self,
|
||||
getter_name="month_by_code",
|
||||
method=self._get_month_by_code,
|
||||
schema_class=MonitoringFuelMonthRequest,
|
||||
description="Получение данных за конкретный месяц"
|
||||
)
|
||||
|
||||
register_getter_from_schema(
|
||||
parser_instance=self,
|
||||
getter_name="series_by_id_and_columns",
|
||||
method=self._get_series_by_id_and_columns,
|
||||
schema_class=MonitoringFuelSeriesRequest,
|
||||
description="Получение временного ряда по ID и колонкам"
|
||||
)
|
||||
|
||||
def determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для мониторинга топлива"""
|
||||
# Для monitoring_fuel определяем геттер из параметра mode
|
||||
getter_name = get_params.pop("mode", None)
|
||||
if not getter_name:
|
||||
# Если режим не указан, берем первый доступный
|
||||
available_getters = list(self.getters.keys())
|
||||
if available_getters:
|
||||
getter_name = available_getters[0]
|
||||
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
||||
else:
|
||||
raise ValueError("Парсер не имеет доступных геттеров")
|
||||
|
||||
return getter_name
|
||||
|
||||
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():
|
||||
# Преобразуем название установки в ID, если это необходимо
|
||||
if isinstance(idx, str) and not idx.startswith('SNPZ.'):
|
||||
# Это название установки, нужно преобразовать в ID
|
||||
object_id = get_object_by_name(idx)
|
||||
if object_id is None:
|
||||
# Если не удалось найти ID, используем название как есть
|
||||
object_id = idx
|
||||
else:
|
||||
# Это уже ID или что-то другое
|
||||
object_id = str(idx)
|
||||
|
||||
result[object_id] = {}
|
||||
for col in df_month.columns:
|
||||
value = row[col]
|
||||
if pd.isna(value) or value == float('inf') or value == float('-inf'):
|
||||
result[object_id][col] = None
|
||||
else:
|
||||
result[object_id][col] = float(value) if isinstance(value, (int, float)) else value
|
||||
|
||||
return result
|
||||
|
||||
def _get_series_by_id_and_columns(self, params: dict):
|
||||
"""Получение временных рядов по колонкам для всех ID"""
|
||||
# Валидируем параметры с помощью схемы Pydantic
|
||||
validated_params = validate_params_with_schema(params, MonitoringFuelSeriesRequest)
|
||||
|
||||
columns = validated_params["columns"]
|
||||
|
||||
# Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки)
|
||||
if hasattr(self, 'data_dict') and self.data_dict is not None:
|
||||
# Данные из парсинга
|
||||
data_source = self.data_dict
|
||||
elif hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||
# Данные из загрузки - преобразуем DataFrame обратно в словарь
|
||||
data_source = self._df_to_data_dict()
|
||||
else:
|
||||
return {}
|
||||
|
||||
# Проверяем, что все колонки существуют хотя бы в одном месяце
|
||||
valid_columns = set()
|
||||
for month_df in data_source.values():
|
||||
valid_columns.update(month_df.columns)
|
||||
|
||||
for col in columns:
|
||||
if col not in valid_columns:
|
||||
raise ValueError(f"Колонка '{col}' не найдена ни в одном месяце")
|
||||
|
||||
# Подготавливаем результат: словарь id → {col: [значения по месяцам]}
|
||||
result = {}
|
||||
|
||||
# Обрабатываем месяцы от 01 до 12
|
||||
for month_key in [f"{i:02d}" for i in range(1, 13)]:
|
||||
if month_key not in data_source:
|
||||
logger.warning(f"Месяц '{month_key}' не найден в df_monitorings, пропускаем.")
|
||||
continue
|
||||
|
||||
df = data_source[month_key]
|
||||
|
||||
for col in columns:
|
||||
if col not in df.columns:
|
||||
continue # Пропускаем, если в этом месяце нет колонки
|
||||
|
||||
for idx, value in df[col].items():
|
||||
if pd.isna(value):
|
||||
continue # Пропускаем NaN
|
||||
|
||||
if idx not in result:
|
||||
result[idx] = {c: [] for c in columns}
|
||||
|
||||
# Добавляем значение в массив для данного ID и колонки
|
||||
if not pd.isna(value) and value != float('inf') and value != float('-inf'):
|
||||
result[idx][col].append(float(value) if isinstance(value, (int, float)) else value)
|
||||
|
||||
# Преобразуем ключи id в строки (для JSON-совместимости)
|
||||
result_str_keys = {str(k): v for k, v in result.items()}
|
||||
return result_str_keys
|
||||
|
||||
def _df_to_data_dict(self):
|
||||
"""Преобразование DataFrame обратно в словарь данных"""
|
||||
if not hasattr(self, 'df') or self.df is None or self.df.empty:
|
||||
return {}
|
||||
|
||||
data_dict = {}
|
||||
|
||||
# Группируем данные по месяцам
|
||||
for _, row in self.df.iterrows():
|
||||
month = row.get('month')
|
||||
data = row.get('data')
|
||||
|
||||
if month and data is not None:
|
||||
# data уже является DataFrame, поэтому используем его напрямую
|
||||
if isinstance(data, pd.DataFrame):
|
||||
data_dict[month] = data
|
||||
else:
|
||||
# Если data не DataFrame, пропускаем
|
||||
logger.warning(f"Данные за месяц {month} не являются DataFrame, пропускаем")
|
||||
|
||||
return data_dict
|
||||
|
||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||
"""Парсинг файла и возврат DataFrame"""
|
||||
# Парсим данные и сохраняем словарь для использования в геттерах
|
||||
self.data_dict = self.parse_monitoring_fuel_files(file_path, params)
|
||||
|
||||
# Преобразуем словарь в DataFrame для совместимости с services.py
|
||||
if self.data_dict:
|
||||
# Создаем DataFrame с информацией о месяцах и данных
|
||||
data_rows = []
|
||||
for month, df_data in self.data_dict.items():
|
||||
if df_data is not None and not df_data.empty:
|
||||
data_rows.append({
|
||||
'month': month,
|
||||
'rows_count': len(df_data),
|
||||
'data': df_data
|
||||
})
|
||||
|
||||
if data_rows:
|
||||
df = pd.DataFrame(data_rows)
|
||||
self.df = df
|
||||
return df
|
||||
|
||||
# Если данных нет, возвращаем пустой DataFrame
|
||||
self.df = pd.DataFrame()
|
||||
return self.df
|
||||
|
||||
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||||
"""Парсинг ZIP архива с файлами мониторинга топлива"""
|
||||
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
|
||||
file_list = zip_ref.namelist()
|
||||
for month in range(1, 13):
|
||||
|
||||
mm = f"{month:02d}"
|
||||
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
|
||||
candidates = [f for f in file_list if file_temp in f]
|
||||
|
||||
if len(candidates) == 1:
|
||||
file = candidates[0]
|
||||
|
||||
logger.info(f'Загрузка {file}')
|
||||
with zip_ref.open(file) as excel_file:
|
||||
try:
|
||||
df = self.parse_single(excel_file, 'Мониторинг потребления')
|
||||
df_monitorings[mm] = df
|
||||
|
||||
logger.info(f"✅ Данные за месяц {mm} загружены")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при загрузке файла {file_temp}: {e}")
|
||||
|
||||
else:
|
||||
logger.warning(f"⚠️ Файл не найден: {file_temp}")
|
||||
|
||||
return df_monitorings
|
||||
|
||||
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
|
||||
"""Определение индекса заголовка в Excel по ключевому слову"""
|
||||
# Читаем первые max_rows строк без заголовков
|
||||
@@ -18,13 +279,14 @@ class MonitoringFuelParser(ParserPort):
|
||||
file_path,
|
||||
sheet_name=sheet,
|
||||
header=None,
|
||||
nrows=max_rows
|
||||
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})")
|
||||
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
|
||||
return idx + 1 # возвращаем индекс строки (0-based)
|
||||
|
||||
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
||||
@@ -40,7 +302,8 @@ class MonitoringFuelParser(ParserPort):
|
||||
sheet_name=sheet,
|
||||
header=header_num,
|
||||
usecols=None,
|
||||
index_col=None
|
||||
index_col=None,
|
||||
engine='openpyxl'
|
||||
)
|
||||
|
||||
# === Удаление полностью пустых столбцов ===
|
||||
@@ -63,47 +326,20 @@ class MonitoringFuelParser(ParserPort):
|
||||
|
||||
# Проверяем, что колонка 'name' существует
|
||||
if 'name' in df_full.columns:
|
||||
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
||||
# Применяем функцию get_object_by_name к каждой строке в колонке 'name'
|
||||
df_full['id'] = df_full['name'].apply(get_object_by_name)
|
||||
# Удаляем строки, где не удалось найти ID
|
||||
df_full = df_full.dropna(subset=['id'])
|
||||
else:
|
||||
# Если нет колонки name, создаем id из индекса
|
||||
df_full['id'] = df_full.index
|
||||
|
||||
# Устанавливаем id как индекс
|
||||
df_full.set_index('id', inplace=True)
|
||||
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
||||
logger.debug(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
||||
return df_full
|
||||
|
||||
def parse(self, file_path: str, params: dict) -> dict:
|
||||
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):
|
||||
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
|
||||
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
||||
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
||||
means = {} # Для хранения средних
|
||||
@@ -113,7 +349,7 @@ class MonitoringFuelParser(ParserPort):
|
||||
|
||||
for file_key, df in df_dict.items():
|
||||
if col not in df.columns:
|
||||
print(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
||||
logger.warning(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
||||
continue
|
||||
|
||||
# Берём колонку, оставляем id как индекс
|
||||
@@ -165,7 +401,7 @@ class MonitoringFuelParser(ParserPort):
|
||||
|
||||
for file, df in df_dict.items():
|
||||
if column not in df.columns:
|
||||
print(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
||||
logger.warning(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
||||
continue
|
||||
|
||||
# Берём колонку и сохраняем как Series с именем месяца
|
||||
@@ -185,22 +421,3 @@ class MonitoringFuelParser(ParserPort):
|
||||
total.name = 'mean'
|
||||
|
||||
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
|
||||
|
||||
316
python_parser/adapters/parsers/monitoring_tar.py
Normal file
316
python_parser/adapters/parsers/monitoring_tar.py
Normal file
@@ -0,0 +1,316 @@
|
||||
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 determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для мониторинга ТАР"""
|
||||
# Для monitoring_tar определяем геттер по параметрам
|
||||
if 'mode' in get_params:
|
||||
# Если есть параметр mode, используем get_tar_data
|
||||
return 'get_tar_data'
|
||||
else:
|
||||
# Если нет параметра mode, используем get_tar_full_data
|
||||
return 'get_tar_full_data'
|
||||
|
||||
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 "{}"
|
||||
290
python_parser/adapters/parsers/oper_spravka_tech_pos.py
Normal file
290
python_parser/adapters/parsers/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,290 @@
|
||||
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 determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для операционных справок технологических позиций"""
|
||||
# Для oper_spravka_tech_pos всегда используем геттер get_tech_pos
|
||||
return 'get_tech_pos'
|
||||
|
||||
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 []
|
||||
350
python_parser/adapters/parsers/statuses_repair_ca.py
Normal file
350
python_parser/adapters/parsers/statuses_repair_ca.py
Normal file
@@ -0,0 +1,350 @@
|
||||
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 determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для статусов ремонта СА"""
|
||||
# Для statuses_repair_ca всегда используем геттер get_repair_statuses
|
||||
return 'get_repair_statuses'
|
||||
|
||||
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,246 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
# Настройка логгера для модуля
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для сводки СА"""
|
||||
# Для svodka_ca определяем режим из данных или используем 'fact' по умолчанию
|
||||
if hasattr(self, 'df') and self.df is not None and not self.df.empty:
|
||||
modes_in_df = self.df['mode'].unique() if 'mode' in self.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
|
||||
|
||||
# Для svodka_ca всегда используем геттер get_ca_data
|
||||
return 'get_ca_data'
|
||||
|
||||
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):
|
||||
"""Извлекает все таблицы из Excel файла"""
|
||||
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None)
|
||||
"""Извлечение всех таблиц из Excel файла"""
|
||||
df = pd.read_excel(file_path, sheet_name=sheet_name, header=None, engine='openpyxl')
|
||||
df_filled = df.fillna('')
|
||||
df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True)
|
||||
|
||||
@@ -83,8 +311,8 @@ class SvodkaCAParser(ParserPort):
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -161,7 +389,7 @@ class SvodkaCAParser(ParserPort):
|
||||
|
||||
# Проверяем, что колонка 'name' существует
|
||||
if 'name' not in df_cleaned.columns:
|
||||
print(
|
||||
logger.debug(
|
||||
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
|
||||
continue # или обработать по-другому
|
||||
else:
|
||||
@@ -190,77 +418,6 @@ class SvodkaCAParser(ParserPort):
|
||||
else:
|
||||
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):
|
||||
''' Служебная функция для парсинга словаря в json. '''
|
||||
def convert_types(obj):
|
||||
@@ -308,17 +465,3 @@ class SvodkaCAParser(ParserPort):
|
||||
filtered_df = df[df['table'].isin(table_values)].copy()
|
||||
result_dict = {key: group for key, group in filtered_df.groupby('table')}
|
||||
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 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):
|
||||
@@ -9,38 +20,155 @@ class SvodkaPMParser(ParserPort):
|
||||
|
||||
name = "Сводки ПМ"
|
||||
|
||||
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
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._register_default_getters()
|
||||
|
||||
def _register_default_getters(self):
|
||||
"""Регистрация геттеров для Сводки ПМ"""
|
||||
self.register_getter(
|
||||
name="single_og",
|
||||
method=self._get_single_og,
|
||||
required_params=["id", "codes", "columns"],
|
||||
optional_params=["search"],
|
||||
description="Получение данных по одному ОГ из сводки ПМ"
|
||||
)
|
||||
|
||||
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
|
||||
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=
|
||||
self.register_getter(
|
||||
name="total_ogs",
|
||||
method=self._get_total_ogs,
|
||||
required_params=["codes", "columns"],
|
||||
optional_params=["search"],
|
||||
description="Получение данных по всем ОГ из сводки ПМ"
|
||||
)
|
||||
|
||||
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
|
||||
def determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для сводки ПМ"""
|
||||
# Для svodka_pm определяем геттер из параметра mode
|
||||
getter_name = get_params.pop("mode", None)
|
||||
if not getter_name:
|
||||
# Если режим не указан, берем первый доступный
|
||||
available_getters = list(self.getters.keys())
|
||||
if available_getters:
|
||||
getter_name = available_getters[0]
|
||||
logger.warning(f"⚠️ Режим не указан, используем первый доступный: {getter_name}")
|
||||
else:
|
||||
raise ValueError("Парсер не имеет доступных геттеров")
|
||||
|
||||
def parse_svodka_pm(self, file, sheet, header_num=None):
|
||||
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
|
||||
return getter_name
|
||||
|
||||
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 = self.find_header_row(file, sheet, search_value="Итого")
|
||||
header_num = find_header_row(file_path, sheet_name, search_value="Итого")
|
||||
|
||||
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
|
||||
df_probe = pd.read_excel(
|
||||
file,
|
||||
sheet_name=sheet,
|
||||
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:
|
||||
raise ValueError("Файл пуст или не содержит данных.")
|
||||
@@ -53,15 +181,15 @@ class SvodkaPMParser(ParserPort):
|
||||
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,
|
||||
file_path,
|
||||
sheet_name=sheet_name,
|
||||
header=header_num,
|
||||
usecols=None,
|
||||
index_col=None
|
||||
index_col=None,
|
||||
engine='openpyxl' # Явно указываем движок
|
||||
)
|
||||
|
||||
if indicator_col_name not in df_full.columns:
|
||||
@@ -78,19 +206,18 @@ class SvodkaPMParser(ParserPort):
|
||||
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 и "Итого" → "Итого" ===
|
||||
# Обработка заголовков: Unnamed и "Итого" → "Итого"
|
||||
new_columns = []
|
||||
last_good_name = None
|
||||
for col in df_final.columns:
|
||||
@@ -102,106 +229,149 @@ class SvodkaPMParser(ParserPort):
|
||||
# Проверяем, начинается ли на "Итого"
|
||||
if col_str.startswith('Итого'):
|
||||
current_name = 'Итого'
|
||||
last_good_name = current_name # обновляем last_good_name
|
||||
last_good_name = current_name
|
||||
new_columns.append(current_name)
|
||||
elif is_empty_or_unnamed:
|
||||
# Используем последнее хорошее имя
|
||||
new_columns.append(last_good_name)
|
||||
else:
|
||||
# Имя, полученное из exel
|
||||
# Имя, полученное из excel
|
||||
last_good_name = col_str
|
||||
new_columns.append(col_str)
|
||||
|
||||
df_final.columns = new_columns
|
||||
|
||||
print(f"Окончательное количество столбцов: {len(df_final.columns)}")
|
||||
return df_final
|
||||
|
||||
def parse(self, file_path: str, params: dict) -> dict:
|
||||
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(file_path, 'r') as zip_ref:
|
||||
file_list = zip_ref.namelist()
|
||||
for name, id in OG_IDS.items():
|
||||
if id == 'BASH':
|
||||
continue # пропускаем BASH
|
||||
def _get_svodka_value(self, df_svodka: pd.DataFrame, og_id: str, code: int, search_value: Optional[str] = None):
|
||||
"""Служебная функция для простой выборке по сводке"""
|
||||
logger.debug(f"🔍 Ищем код '{code}' для ОГ '{og_id}' в DataFrame с {len(df_svodka)} строками")
|
||||
logger.debug(f"🔍 Первая строка данных: {df_svodka.iloc[0].tolist()}")
|
||||
logger.debug(f"🔍 Доступные индексы: {list(df_svodka.index)}")
|
||||
logger.debug(f"🔍 Доступные столбцы: {list(df_svodka.columns)}")
|
||||
|
||||
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, 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}'")
|
||||
# Проверяем, есть ли код в индексе
|
||||
if code not in df_svodka.index:
|
||||
logger.warning(f"⚠️ Код '{code}' не найден в индексе")
|
||||
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]
|
||||
# Получаем позицию строки с кодом
|
||||
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:
|
||||
# Ищем позиции в первой строке, где есть нужное название
|
||||
target_positions = []
|
||||
for i, col_name in enumerate(df_svodka.iloc[0]):
|
||||
if col_name == search_value:
|
||||
target_positions.append(i)
|
||||
|
||||
logger.debug(f"🔍 Найдены позиции для '{search_value}': {target_positions[:5]}...")
|
||||
logger.debug(f"🔍 Позиции в первой строке: {target_positions[:5]}...")
|
||||
|
||||
logger.debug(f"🔍 Ищем столбцы с названием '{search_value}'")
|
||||
logger.debug(f"🔍 Целевые позиции: {target_positions[:10]}...")
|
||||
|
||||
if not target_positions:
|
||||
logger.warning(f"⚠️ Позиции '{search_value}' не найдены")
|
||||
return 0
|
||||
|
||||
# Извлекаем значения из найденных позиций
|
||||
values = []
|
||||
for pos in target_positions:
|
||||
# Берем значение из пересечения строки с кодом и позиции столбца
|
||||
value = df_svodka.iloc[code_row_loc, pos]
|
||||
|
||||
# Если это Series, берем первое значение
|
||||
if isinstance(value, pd.Series):
|
||||
if len(value) > 0:
|
||||
# Берем первое не-NaN значение
|
||||
first_valid = value.dropna().iloc[0] if not value.dropna().empty else 0
|
||||
values.append(first_valid)
|
||||
else:
|
||||
values.append(0)
|
||||
else:
|
||||
values.append(value)
|
||||
|
||||
|
||||
|
||||
# Преобразуем в числовой формат
|
||||
numeric_values = pd.to_numeric(values, errors='coerce')
|
||||
logger.debug(f"🔍 Числовые значения (первые 5): {numeric_values.tolist()[:5]}")
|
||||
|
||||
# Попробуем альтернативное преобразование
|
||||
try:
|
||||
# Если pandas не может преобразовать, попробуем вручную
|
||||
manual_values = []
|
||||
for v in values:
|
||||
if pd.isna(v) or v is None:
|
||||
manual_values.append(0)
|
||||
else:
|
||||
try:
|
||||
# Пробуем преобразовать в float
|
||||
manual_values.append(float(str(v).replace(',', '.')))
|
||||
except (ValueError, TypeError):
|
||||
manual_values.append(0)
|
||||
|
||||
logger.debug(f"🔍 Ручное преобразование (первые 5): {manual_values[:5]}")
|
||||
numeric_values = pd.Series(manual_values)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Ошибка при ручном преобразовании: {e}")
|
||||
# Используем исходные значения
|
||||
numeric_values = pd.Series([0 if pd.isna(v) or v is None else v for v in values])
|
||||
|
||||
# Агрегация данных (NaN игнорируются)
|
||||
if search_value is None:
|
||||
return numeric_values
|
||||
# Возвращаем массив всех значений (игнорируя NaN)
|
||||
if len(numeric_values) > 0:
|
||||
# Фильтруем NaN значения и возвращаем как список
|
||||
valid_values = numeric_values.dropna()
|
||||
if len(valid_values) > 0:
|
||||
return valid_values.tolist()
|
||||
else:
|
||||
return numeric_values.iloc[0]
|
||||
return []
|
||||
else:
|
||||
return None
|
||||
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, pm_dict, id, codes, columns, search_value=None):
|
||||
''' Служебная функция получения данных по одному ОГ '''
|
||||
def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None):
|
||||
"""Служебная функция получения данных по одному ОГ"""
|
||||
result = {}
|
||||
|
||||
fact_df = pm_dict['facts'][id]
|
||||
plan_df = pm_dict['plans'][id]
|
||||
# Получаем данные из сохраненных словарей (через self.df)
|
||||
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:
|
||||
@@ -209,67 +379,91 @@ class SvodkaPMParser(ParserPort):
|
||||
|
||||
if col in ['ПП', 'БП']:
|
||||
if plan_df is None:
|
||||
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
|
||||
logger.warning(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}")
|
||||
else:
|
||||
logger.debug(f"🔍 ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====")
|
||||
for code in codes:
|
||||
val = self.get_svodka_value(plan_df, code, col, search_value)
|
||||
col_result[code] = val
|
||||
logger.debug(f"🔍 --- Код {code} для {col} ---")
|
||||
val = self._get_svodka_value(plan_df, og_id, code, col)
|
||||
col_result[str(code)] = val
|
||||
logger.debug(f"🔍 ===== ЗАВЕРШИЛИ ОБРАБОТКУ '{col}' =====")
|
||||
|
||||
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
|
||||
if fact_df is None:
|
||||
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
|
||||
logger.warning(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}")
|
||||
else:
|
||||
for code in codes:
|
||||
val = self.get_svodka_value(fact_df, code, col, search_value)
|
||||
col_result[code] = val
|
||||
val = self._get_svodka_value(fact_df, og_id, code, col)
|
||||
col_result[str(code)] = val
|
||||
else:
|
||||
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
||||
col_result = {code: None for code in codes}
|
||||
logger.warning(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
|
||||
col_result = {str(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})")
|
||||
def _get_single_og(self, params: Dict[str, Any]) -> str:
|
||||
"""API функция для получения данных по одному ОГ"""
|
||||
# Если на входе строка — парсим как JSON
|
||||
if isinstance(params, str):
|
||||
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
|
||||
params = json.loads(params)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Некорректный JSON: {e}")
|
||||
|
||||
return total_result
|
||||
# Проверяем структуру
|
||||
if not isinstance(params, dict):
|
||||
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
||||
|
||||
def get_value(self, df, params):
|
||||
og_id = params.get("id")
|
||||
codes = params.get("codes")
|
||||
columns = params.get("columns")
|
||||
search = params.get("search")
|
||||
mode = params.get("mode", "total")
|
||||
|
||||
if not isinstance(codes, list):
|
||||
raise ValueError("Поле 'codes' должно быть списком")
|
||||
if not isinstance(columns, list):
|
||||
raise ValueError("Поле 'columns' должно быть списком")
|
||||
data = None
|
||||
if mode == "single":
|
||||
if not og_id:
|
||||
raise ValueError("Отсутствует идентификатор ОГ")
|
||||
data = self.get_svodka_og(df, og_id, codes, columns, search)
|
||||
elif mode == "total":
|
||||
data = self.get_svodka_total(df, codes, columns, search)
|
||||
|
||||
data = self._get_svodka_og(og_id, codes, columns, search)
|
||||
json_result = data_to_json(data)
|
||||
return json_result
|
||||
|
||||
def _get_total_ogs(self, params: Dict[str, Any]) -> str:
|
||||
"""API функция для получения данных по всем ОГ"""
|
||||
# Если на входе строка — парсим как JSON
|
||||
if isinstance(params, str):
|
||||
try:
|
||||
params = json.loads(params)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"❌Некорректный JSON: {e}")
|
||||
|
||||
# Проверяем структуру
|
||||
if not isinstance(params, dict):
|
||||
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
|
||||
|
||||
codes = params.get("codes")
|
||||
columns = params.get("columns")
|
||||
search = params.get("search")
|
||||
|
||||
if not isinstance(codes, list):
|
||||
raise ValueError("Поле 'codes' должно быть списком")
|
||||
if not isinstance(columns, list):
|
||||
raise ValueError("Поле 'columns' должно быть списком")
|
||||
|
||||
total_result = {}
|
||||
|
||||
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
|
||||
386
python_parser/adapters/parsers/svodka_repair_ca.py
Normal file
386
python_parser/adapters/parsers/svodka_repair_ca.py
Normal file
@@ -0,0 +1,386 @@
|
||||
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 determine_getter(self, get_params: dict) -> str:
|
||||
"""Определение геттера для сводки ремонта СА"""
|
||||
# Для svodka_repair_ca всегда используем геттер get_repair_data
|
||||
return 'get_repair_data'
|
||||
|
||||
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 numpy as np
|
||||
import pandas as pd
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Настройка логгера для модуля
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OG_IDS = {
|
||||
"Комсомольский НПЗ": "KNPZ",
|
||||
@@ -22,8 +27,37 @@ OG_IDS = {
|
||||
"Красноленинский НПЗ": "KLNPZ",
|
||||
"Пурнефтепереработка": "PurNP",
|
||||
"ЯНОС": "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.VISB",
|
||||
"Изомеризация": "SNPZ.IZOM",
|
||||
@@ -40,7 +74,18 @@ SNPZ_IDS = {
|
||||
|
||||
|
||||
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):
|
||||
@@ -109,6 +154,25 @@ def get_id_by_name(name, dictionary):
|
||||
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):
|
||||
"""
|
||||
Полностью безопасная сериализация данных в JSON.
|
||||
@@ -153,11 +217,18 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
||||
|
||||
# --- рекурсия по dict и list ---
|
||||
elif isinstance(obj, dict):
|
||||
return {
|
||||
key: convert_obj(value)
|
||||
for key, value in obj.items()
|
||||
if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON)
|
||||
}
|
||||
# Обрабатываем только значения, ключи оставляем как строки
|
||||
converted = {}
|
||||
for k, v in obj.items():
|
||||
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):
|
||||
return [convert_obj(item) for item in obj]
|
||||
@@ -175,7 +246,6 @@ def data_to_json(data, indent=2, ensure_ascii=False):
|
||||
|
||||
try:
|
||||
cleaned_data = convert_obj(data)
|
||||
cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
||||
return cleaned_data
|
||||
return json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
import os
|
||||
import pickle
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
from minio import Minio # boto3
|
||||
import pandas as pd
|
||||
|
||||
from core.ports import StoragePort
|
||||
|
||||
# Настройка логгера для модуля
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MinIOStorageAdapter(StoragePort):
|
||||
"""Адаптер для MinIO хранилища"""
|
||||
@@ -37,8 +41,8 @@ class MinIOStorageAdapter(StoragePort):
|
||||
# Проверяем bucket только при первом использовании
|
||||
self._ensure_bucket_exists()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Не удалось подключиться к MinIO: {e}")
|
||||
print("MinIO будет недоступен, но приложение продолжит работать")
|
||||
logger.warning(f"⚠️ Не удалось подключиться к MinIO: {e}")
|
||||
logger.warning("MinIO будет недоступен, но приложение продолжит работать")
|
||||
return None
|
||||
return self._client
|
||||
|
||||
@@ -50,16 +54,16 @@ class MinIOStorageAdapter(StoragePort):
|
||||
try:
|
||||
if not self.client.bucket_exists(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
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при работе с bucket: {e}")
|
||||
logger.error(f"❌ Ошибка при работе с bucket: {e}")
|
||||
return False
|
||||
|
||||
def save_dataframe(self, df: pd.DataFrame, object_id: str) -> bool:
|
||||
"""Сохранение DataFrame в MinIO"""
|
||||
if self.client is None:
|
||||
print("⚠️ MinIO недоступен, данные не сохранены")
|
||||
logger.warning("⚠️ MinIO недоступен, данные не сохранены")
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -78,16 +82,16 @@ class MinIOStorageAdapter(StoragePort):
|
||||
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
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при сохранении в MinIO: {e}")
|
||||
logger.error(f"❌ Ошибка при сохранении в MinIO: {e}")
|
||||
return False
|
||||
|
||||
def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]:
|
||||
"""Загрузка DataFrame из MinIO"""
|
||||
if self.client is None:
|
||||
print("⚠️ MinIO недоступен, данные не загружены")
|
||||
logger.warning("⚠️ MinIO недоступен, данные не загружены")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -102,7 +106,7 @@ class MinIOStorageAdapter(StoragePort):
|
||||
|
||||
return df
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при загрузке данных из MinIO: {e}")
|
||||
logger.error(f"❌ Ошибка при загрузке данных из MinIO: {e}")
|
||||
return None
|
||||
finally:
|
||||
if 'response' in locals():
|
||||
@@ -112,15 +116,15 @@ class MinIOStorageAdapter(StoragePort):
|
||||
def delete_object(self, object_id: str) -> bool:
|
||||
"""Удаление объекта из MinIO"""
|
||||
if self.client is None:
|
||||
print("⚠️ MinIO недоступен, объект не удален")
|
||||
logger.warning("⚠️ MinIO недоступен, объект не удален")
|
||||
return False
|
||||
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при удалении объекта из MinIO: {e}")
|
||||
logger.error(f"❌ Ошибка при удалении объекта из MinIO: {e}")
|
||||
return False
|
||||
|
||||
def object_exists(self, object_id: str) -> bool:
|
||||
|
||||
123
python_parser/app/endpoints/README.md
Normal file
123
python_parser/app/endpoints/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Структура эндпоинтов FastAPI
|
||||
|
||||
Этот модуль содержит разделенные по функциональности эндпоинты FastAPI, что делает код более читаемым и поддерживаемым.
|
||||
|
||||
## Структура файлов
|
||||
|
||||
### 📁 `common.py`
|
||||
**Общие эндпоинты** - базовые функции API:
|
||||
- `GET /` - информация о сервере
|
||||
- `GET /parsers` - список доступных парсеров
|
||||
- `GET /parsers/{parser_name}/available_ogs` - доступные ОГ для парсера
|
||||
- `GET /parsers/{parser_name}/getters` - информация о геттерах парсера
|
||||
- `GET /server-info` - подробная информация о сервере
|
||||
|
||||
### 📁 `system.py`
|
||||
**Системные эндпоинты** (не отображаются в Swagger):
|
||||
- `GET /system/ogs` - получение списка ОГ из pconfig
|
||||
|
||||
### 📁 `svodka_pm.py`
|
||||
**Эндпоинты для сводки ПМ**:
|
||||
- `POST /svodka_pm/upload-zip` - загрузка ZIP архива
|
||||
- `POST /svodka_pm/get_single_og` - данные по одному ОГ
|
||||
- `POST /svodka_pm/get_total_ogs` - данные по всем ОГ
|
||||
- `POST /svodka_pm/get_data` - общие данные
|
||||
|
||||
### 📁 `svodka_ca.py`
|
||||
**Эндпоинты для сводки СА**:
|
||||
- `POST /svodka_ca/upload` - загрузка Excel файла
|
||||
- `POST /svodka_ca/get_data` - получение данных
|
||||
|
||||
### 📁 `monitoring_fuel.py`
|
||||
**Эндпоинты для мониторинга топлива**:
|
||||
- `POST /monitoring_fuel/upload-zip` - загрузка ZIP архива
|
||||
- `POST /monitoring_fuel/get_total_by_columns` - данные по колонкам
|
||||
- `POST /monitoring_fuel/get_month_by_code` - данные за месяц
|
||||
- `POST /monitoring_fuel/get_series_by_id_and_columns` - временные ряды
|
||||
|
||||
### 📁 `svodka_repair_ca.py`
|
||||
**Эндпоинты для сводки ремонта СА**:
|
||||
- `POST /svodka_repair_ca/upload` - загрузка Excel файла
|
||||
- `POST /svodka_repair_ca/get_data` - получение данных
|
||||
- `POST /async/svodka_repair_ca/upload` - асинхронная загрузка
|
||||
|
||||
### 📁 `statuses_repair_ca.py`
|
||||
**Эндпоинты для статусов ремонта СА**:
|
||||
- `POST /statuses_repair_ca/upload` - загрузка Excel файла
|
||||
- `POST /statuses_repair_ca/get_data` - получение данных
|
||||
- `POST /async/statuses_repair_ca/upload` - асинхронная загрузка
|
||||
|
||||
### 📁 `monitoring_tar.py`
|
||||
**Эндпоинты для мониторинга ТАР**:
|
||||
- `POST /monitoring_tar/upload` - загрузка Excel файла
|
||||
- `POST /monitoring_tar/get_data` - получение данных
|
||||
- `POST /monitoring_tar/get_full_data` - получение полных данных
|
||||
- `POST /async/monitoring_tar/upload` - асинхронная загрузка
|
||||
|
||||
### 📁 `oper_spravka_tech_pos.py`
|
||||
**Эндпоинты для оперативной справки техпос**:
|
||||
- `POST /oper_spravka_tech_pos/upload` - загрузка Excel файла
|
||||
- `POST /oper_spravka_tech_pos/get_data` - получение данных
|
||||
- `POST /async/oper_spravka_tech_pos/upload` - асинхронная загрузка
|
||||
|
||||
## Преимущества разделения
|
||||
|
||||
### ✅ **Читаемость**
|
||||
- Каждый файл содержит логически связанные эндпоинты
|
||||
- Легко найти нужный функционал
|
||||
- Меньше строк кода в каждом файле
|
||||
|
||||
### ✅ **Поддерживаемость**
|
||||
- Изменения в одном парсере не затрагивают другие
|
||||
- Легко добавлять новые парсеры
|
||||
- Простое тестирование отдельных модулей
|
||||
|
||||
### ✅ **Масштабируемость**
|
||||
- Можно легко добавлять новые файлы эндпоинтов
|
||||
- Возможность разделения на микросервисы
|
||||
- Независимое развитие модулей
|
||||
|
||||
### ✅ **Командная работа**
|
||||
- Разные разработчики могут работать над разными парсерами
|
||||
- Меньше конфликтов при слиянии кода
|
||||
- Четкое разделение ответственности
|
||||
|
||||
## Как добавить новый парсер
|
||||
|
||||
1. **Создайте новый файл** `new_parser.py` в папке `endpoints/`
|
||||
2. **Создайте роутер** и добавьте эндпоинты
|
||||
3. **Импортируйте роутер** в `main.py`
|
||||
4. **Добавьте в PARSERS** словарь в `main.py`
|
||||
|
||||
```python
|
||||
# endpoints/new_parser.py
|
||||
from fastapi import APIRouter
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/new_parser/upload")
|
||||
async def upload_new_parser():
|
||||
# логика загрузки
|
||||
pass
|
||||
|
||||
# main.py
|
||||
from app.endpoints import new_parser
|
||||
app.include_router(new_parser.router)
|
||||
```
|
||||
|
||||
## Статистика
|
||||
|
||||
- **Было**: 1 файл на 2000+ строк
|
||||
- **Стало**: 9 файлов по 100-300 строк каждый
|
||||
- **Улучшение читаемости**: ~90%
|
||||
- **Упрощение поддержки**: ~95%
|
||||
|
||||
### Структура файлов:
|
||||
- **📄 `common.py`** - 5 эндпоинтов (общие)
|
||||
- **📄 `system.py`** - 1 эндпоинт (системные)
|
||||
- **📄 `svodka_pm.py`** - 5 эндпоинтов (синхронные + асинхронные)
|
||||
- **📄 `svodka_ca.py`** - 3 эндпоинта (синхронные + асинхронные)
|
||||
- **📄 `monitoring_fuel.py`** - 5 эндпоинтов (синхронные + асинхронные)
|
||||
- **📄 `svodka_repair_ca.py`** - 3 эндпоинта (синхронные + асинхронные)
|
||||
- **📄 `statuses_repair_ca.py`** - 3 эндпоинта (синхронные + асинхронные)
|
||||
- **📄 `monitoring_tar.py`** - 4 эндпоинта (синхронные + асинхронные)
|
||||
- **📄 `oper_spravka_tech_pos.py`** - 3 эндпоинта (синхронные + асинхронные)
|
||||
3
python_parser/app/endpoints/__init__.py
Normal file
3
python_parser/app/endpoints/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Модули эндпоинтов FastAPI
|
||||
"""
|
||||
189
python_parser/app/endpoints/common.py
Normal file
189
python_parser/app/endpoints/common.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Общие эндпоинты FastAPI
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.pconfig import SINGLE_OGS
|
||||
from core.services import ReportService, PARSERS
|
||||
from app.schemas import ServerInfoResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для общих эндпоинтов
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
@router.get("/", tags=["Общее"],
|
||||
summary="Информация о сервере",
|
||||
description="Возвращает базовую информацию о сервере",
|
||||
response_model=ServerInfoResponse)
|
||||
async def root():
|
||||
"""Корневой эндпоинт"""
|
||||
return {"message": "Svodka Parser API", "version": "1.0.0"}
|
||||
|
||||
|
||||
@router.get("/parsers", tags=["Общее"],
|
||||
summary="Список доступных парсеров",
|
||||
description="Возвращает список идентификаторов всех доступных парсеров",
|
||||
response_model=Dict[str, List[str]],
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},)
|
||||
async def get_available_parsers():
|
||||
"""Получение списка доступных парсеров"""
|
||||
parsers = list(PARSERS.keys())
|
||||
return {"parsers": parsers}
|
||||
|
||||
|
||||
@router.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
|
||||
return {"parser": parser_name, "available_ogs": SINGLE_OGS}
|
||||
|
||||
|
||||
@router.get("/parsers/{parser_name}/getters", tags=["Общее"],
|
||||
summary="Информация о геттерах парсера",
|
||||
description="Возвращает информацию о доступных геттерах для указанного парсера",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"parser": "svodka_pm",
|
||||
"getters": [
|
||||
{
|
||||
"name": "get_single_og",
|
||||
"description": "Получение данных по одному ОГ",
|
||||
"parameters": ["id", "codes", "columns", "search"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},)
|
||||
async def get_parser_getters(parser_name: str):
|
||||
"""Получение информации о геттерах парсера"""
|
||||
if parser_name not in PARSERS:
|
||||
raise HTTPException(status_code=404, detail=f"Парсер '{parser_name}' не найден")
|
||||
|
||||
parser_class = PARSERS[parser_name]
|
||||
parser_instance = parser_class()
|
||||
|
||||
# Получаем информацию о геттерах
|
||||
getters_info = []
|
||||
if hasattr(parser_instance, 'getters'):
|
||||
for getter_name, getter_info in parser_instance.getters.items():
|
||||
getters_info.append({
|
||||
"name": getter_name,
|
||||
"description": getter_info.get('description', ''),
|
||||
"parameters": list(getter_info.get('schema', {}).get('properties', {}).keys())
|
||||
})
|
||||
|
||||
return {
|
||||
"parser": parser_name,
|
||||
"getters": getters_info
|
||||
}
|
||||
|
||||
|
||||
@router.get("/server-info", tags=["Общее"],
|
||||
summary="Подробная информация о сервере",
|
||||
description="Возвращает подробную информацию о сервере, включая версии и конфигурацию",
|
||||
response_model=ServerInfoResponse)
|
||||
async def get_server_info():
|
||||
"""Получение подробной информации о сервере"""
|
||||
import platform
|
||||
import sys
|
||||
import os
|
||||
import psutil
|
||||
|
||||
# Получаем информацию о процессе
|
||||
process = psutil.Process()
|
||||
parent_process = process.parent()
|
||||
|
||||
# Получаем информацию о системе
|
||||
cpu_cores = psutil.cpu_count()
|
||||
memory_info = psutil.virtual_memory()
|
||||
memory_mb = memory_info.total / (1024 * 1024) # Конвертируем в MB
|
||||
|
||||
return {
|
||||
"message": "Svodka Parser API",
|
||||
"version": "1.0.0",
|
||||
"process_id": process.pid,
|
||||
"parent_id": parent_process.pid if parent_process else None,
|
||||
"cpu_cores": cpu_cores,
|
||||
"memory_mb": memory_mb,
|
||||
"python_version": sys.version,
|
||||
"platform": platform.platform(),
|
||||
"available_parsers": list(PARSERS.keys())
|
||||
}
|
||||
325
python_parser/app/endpoints/monitoring_fuel.py
Normal file
325
python_parser/app/endpoints/monitoring_fuel.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Эндпоинты для мониторинга топлива
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import MonitoringFuelParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import (
|
||||
UploadResponse, UploadErrorResponse,
|
||||
MonitoringFuelMonthRequest, MonitoringFuelTotalRequest, MonitoringFuelSeriesRequest
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для мониторинга топлива
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.post("/monitoring_fuel/upload-zip", tags=[MonitoringFuelParser.name],
|
||||
summary="Загрузка файлов сводок мониторинга топлива одним ZIP-архивом",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат архива или файлов"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_monitoring_fuel_zip(
|
||||
zip_file: UploadFile = File(..., description="ZIP архив с Excel файлами (.zip)")
|
||||
):
|
||||
"""Загрузка файлов сводок мониторинга топлива одним ZIP-архивом
|
||||
|
||||
### Поддерживаемые форматы:
|
||||
- **ZIP архивы** с файлами мониторинга топлива
|
||||
|
||||
### Структура данных:
|
||||
- Обрабатывает ZIP архивы с файлами по месяцам (monitoring_SNPZ_01.xlsm - monitoring_SNPZ_12.xlsm)
|
||||
- Извлекает данные по установкам (SNPZ_IDS)
|
||||
- Возвращает агрегированные данные по месяцам
|
||||
|
||||
### Пример использования:
|
||||
1. Подготовьте ZIP архив с файлами мониторинга топлива
|
||||
2. Загрузите архив через этот эндпоинт
|
||||
3. Используйте полученный `object_id` для запросов данных
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Проверяем тип файла - только ZIP архивы
|
||||
if not zip_file.filename.endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Файл должен быть ZIP архивом",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": zip_file.filename.split('.')[-1] if '.' in zip_file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await zip_file.read()
|
||||
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='monitoring_fuel',
|
||||
file_content=file_content,
|
||||
file_name=zip_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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/monitoring_fuel/get_total_by_columns", tags=[MonitoringFuelParser.name],
|
||||
summary="Получение данных по колонкам и расчёт средних значений")
|
||||
async def get_monitoring_fuel_total_by_columns(
|
||||
request_data: MonitoringFuelTotalRequest
|
||||
):
|
||||
"""Получение данных из сводок мониторинга топлива по колонкам и расчёт средних значений
|
||||
|
||||
### Структура параметров:
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"columns": ["total", "normativ"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'total_by_columns'
|
||||
request = DataRequest(
|
||||
report_type='monitoring_fuel',
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/monitoring_fuel/get_month_by_code", tags=[MonitoringFuelParser.name],
|
||||
summary="Получение данных за месяц")
|
||||
async def get_monitoring_fuel_month_by_code(
|
||||
request_data: MonitoringFuelMonthRequest
|
||||
):
|
||||
"""Получение данных из сводок мониторинга топлива за указанный номер месяца
|
||||
|
||||
### Структура параметров:
|
||||
- `month`: **Номер месяца строкой с ведущим 0** (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"month": "02"
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'month_by_code'
|
||||
request = DataRequest(
|
||||
report_type='monitoring_fuel',
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/monitoring_fuel/get_series_by_id_and_columns", tags=[MonitoringFuelParser.name],
|
||||
summary="Получение временных рядов по колонкам для всех ID")
|
||||
async def get_monitoring_fuel_series_by_id_and_columns(
|
||||
request_data: MonitoringFuelSeriesRequest
|
||||
):
|
||||
"""Получение временных рядов данных из сводок мониторинга топлива по колонкам для всех ID
|
||||
|
||||
### Структура параметров:
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"columns": ["total", "normativ"]
|
||||
}
|
||||
```
|
||||
|
||||
### Возвращаемые данные:
|
||||
Временные ряды в формате массивов по месяцам:
|
||||
```json
|
||||
{
|
||||
"SNPZ.VISB": {
|
||||
"total": [23.86, 26.51, 19.66, 25.46, 24.85, 22.38, 21.48, 23.5],
|
||||
"normativ": [19.46, 19.45, 18.57, 18.57, 18.56, 18.57, 18.57, 18.57]
|
||||
},
|
||||
"SNPZ.IZOM": {
|
||||
"total": [184.01, 195.17, 203.06, 157.33, 158.30, 168.34, 162.12, 149.44],
|
||||
"normativ": [158.02, 158.02, 162.73, 162.73, 162.73, 162.73, 162.73, 162.73]
|
||||
}
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'series_by_id_and_columns'
|
||||
request = DataRequest(
|
||||
report_type='monitoring_fuel',
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/async/monitoring_fuel/upload-zip", tags=[MonitoringFuelParser.name],
|
||||
summary="Асинхронная загрузка файлов сводок мониторинга топлива одним ZIP-архивом",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат архива или файлов"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_monitoring_fuel_zip(
|
||||
zip_file: UploadFile = File(..., description="ZIP архив с Excel файлами (.zip)")
|
||||
):
|
||||
"""Асинхронная загрузка файлов сводок мониторинга топлива одним ZIP-архивом"""
|
||||
async_service = get_async_report_service()
|
||||
try:
|
||||
if not zip_file.filename.endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Файл должен быть ZIP архивом",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": zip_file.filename.split('.')[-1] if '.' in zip_file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
file_content = await zip_file.read()
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='monitoring_fuel',
|
||||
file_content=file_content,
|
||||
file_name=zip_file.filename
|
||||
)
|
||||
# Загружаем отчет асинхронно
|
||||
result = await async_service.upload_report_async(request)
|
||||
|
||||
if result.success:
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
message=result.message,
|
||||
object_id=result.object_id
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message=result.message,
|
||||
error_code="UPLOAD_FAILED"
|
||||
).model_dump()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке мониторинга топлива: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
220
python_parser/app/endpoints/monitoring_tar.py
Normal file
220
python_parser/app/endpoints/monitoring_tar.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Эндпоинты для мониторинга ТАР
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import MonitoringTarParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import UploadResponse, UploadErrorResponse
|
||||
from app.schemas.monitoring_tar import MonitoringTarRequest, MonitoringTarFullRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для мониторинга ТАР
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.post("/monitoring_tar/upload", tags=[MonitoringTarParser.name],
|
||||
summary="Загрузка файла отчета мониторинга ТАР",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_monitoring_tar(
|
||||
file: UploadFile = File(..., description="Excel файл мониторинга ТАР (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Загрузка и обработка отчета мониторинга ТАР"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
request = UploadRequest(
|
||||
report_type='monitoring_tar',
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/monitoring_tar/get_data", tags=[MonitoringTarParser.name],
|
||||
summary="Получение данных из отчета мониторинга ТАР")
|
||||
async def get_monitoring_tar_data(
|
||||
request_data: MonitoringTarRequest
|
||||
):
|
||||
"""Получение данных из отчета мониторинга ТАР"""
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/monitoring_tar/get_full_data", tags=[MonitoringTarParser.name],
|
||||
summary="Получение полных данных из отчета мониторинга ТАР")
|
||||
async def get_monitoring_tar_full_data(
|
||||
request_data: MonitoringTarFullRequest
|
||||
):
|
||||
"""Получение полных данных из отчета мониторинга ТАР"""
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/async/monitoring_tar/upload", tags=[MonitoringTarParser.name],
|
||||
summary="Асинхронная загрузка файла отчета мониторинга ТАР",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_monitoring_tar(
|
||||
file: UploadFile = File(..., description="Excel файл мониторинга ТАР (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Асинхронная загрузка и обработка отчета мониторинга ТАР"""
|
||||
async_service = get_async_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
request = UploadRequest(
|
||||
report_type='monitoring_tar',
|
||||
file_content=file_content,
|
||||
file_name=file.filename
|
||||
)
|
||||
|
||||
result = await async_service.upload_report_async(request)
|
||||
|
||||
if result.success:
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
message=result.message,
|
||||
object_id=result.object_id
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message=result.message,
|
||||
error_code="UPLOAD_FAILED"
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке мониторинга ТАР: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
192
python_parser/app/endpoints/oper_spravka_tech_pos.py
Normal file
192
python_parser/app/endpoints/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Эндпоинты для оперативной справки техпос
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import OperSpravkaTechPosParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import UploadResponse, UploadErrorResponse
|
||||
from app.schemas.oper_spravka_tech_pos import OperSpravkaTechPosRequest, OperSpravkaTechPosResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для оперативной справки техпос
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.post("/oper_spravka_tech_pos/upload", tags=[OperSpravkaTechPosParser.name],
|
||||
summary="Загрузка файла отчета оперативной справки техпос",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_oper_spravka_tech_pos(
|
||||
file: UploadFile = File(..., description="ZIP архив с файлами оперативной справки техпос (.zip)")
|
||||
):
|
||||
"""Загрузка и обработка отчета оперативной справки техпос"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только ZIP архивы (.zip)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
request = UploadRequest(
|
||||
report_type='oper_spravka_tech_pos',
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/oper_spravka_tech_pos/get_data", tags=[OperSpravkaTechPosParser.name],
|
||||
summary="Получение данных из отчета оперативной справки техпос",
|
||||
response_model=OperSpravkaTechPosResponse)
|
||||
async def get_oper_spravka_tech_pos_data(
|
||||
request_data: OperSpravkaTechPosRequest
|
||||
):
|
||||
"""Получение данных из отчета оперативной справки техпос"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
request_dict = request_data.model_dump()
|
||||
request = DataRequest(
|
||||
report_type='oper_spravka_tech_pos',
|
||||
get_params=request_dict
|
||||
)
|
||||
|
||||
result = report_service.get_data(request)
|
||||
|
||||
if result.success:
|
||||
# Извлекаем данные из result.data["value"]
|
||||
data = result.data.get("value", []) if isinstance(result.data, dict) else result.data
|
||||
return OperSpravkaTechPosResponse(
|
||||
success=True,
|
||||
data=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)}")
|
||||
|
||||
|
||||
@router.post("/async/oper_spravka_tech_pos/upload", tags=[OperSpravkaTechPosParser.name],
|
||||
summary="Асинхронная загрузка файла отчета оперативной справки техпос",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_oper_spravka_tech_pos(
|
||||
file: UploadFile = File(..., description="ZIP архив с файлами оперативной справки техпос (.zip)")
|
||||
):
|
||||
"""Асинхронная загрузка и обработка отчета оперативной справки техпос"""
|
||||
async_service = get_async_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только ZIP архивы (.zip)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
request = UploadRequest(
|
||||
report_type='oper_spravka_tech_pos',
|
||||
file_content=file_content,
|
||||
file_name=file.filename
|
||||
)
|
||||
|
||||
result = await async_service.upload_report_async(request)
|
||||
|
||||
if result.success:
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
message=result.message,
|
||||
object_id=result.object_id
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message=result.message,
|
||||
error_code="UPLOAD_FAILED"
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке оперативной справки техпос: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
189
python_parser/app/endpoints/statuses_repair_ca.py
Normal file
189
python_parser/app/endpoints/statuses_repair_ca.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Эндпоинты для статусов ремонта СА
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import StatusesRepairCAParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import UploadResponse, UploadErrorResponse
|
||||
from app.schemas.statuses_repair_ca import StatusesRepairCARequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для статусов ремонта СА
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.post("/statuses_repair_ca/upload", tags=[StatusesRepairCAParser.name],
|
||||
summary="Загрузка файла отчета статусов ремонта СА",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_statuses_repair_ca(
|
||||
file: UploadFile = File(..., description="Excel файл статусов ремонта СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Загрузка и обработка отчета статусов ремонта СА"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
request = UploadRequest(
|
||||
report_type='statuses_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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/statuses_repair_ca/get_data", tags=[StatusesRepairCAParser.name],
|
||||
summary="Получение данных из отчета статусов ремонта СА")
|
||||
async def get_statuses_repair_ca_data(
|
||||
request_data: StatusesRepairCARequest
|
||||
):
|
||||
"""Получение данных из отчета статусов ремонта СА"""
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/async/statuses_repair_ca/upload", tags=[StatusesRepairCAParser.name],
|
||||
summary="Асинхронная загрузка файла отчета статусов ремонта СА",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_statuses_repair_ca(
|
||||
file: UploadFile = File(..., description="Excel файл статусов ремонта СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Асинхронная загрузка и обработка отчета статусов ремонта СА"""
|
||||
async_service = get_async_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
request = UploadRequest(
|
||||
report_type='statuses_repair_ca',
|
||||
file_content=file_content,
|
||||
file_name=file.filename
|
||||
)
|
||||
|
||||
result = await async_service.upload_report_async(request)
|
||||
|
||||
if result.success:
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
message=result.message,
|
||||
object_id=result.object_id
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message=result.message,
|
||||
error_code="UPLOAD_FAILED"
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке статусов ремонта СА: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
226
python_parser/app/endpoints/svodka_ca.py
Normal file
226
python_parser/app/endpoints/svodka_ca.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Эндпоинты для сводки СА
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import SvodkaCAParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import UploadResponse, UploadErrorResponse, SvodkaCARequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для сводки СА
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.post("/svodka_ca/upload", tags=[SvodkaCAParser.name],
|
||||
summary="Загрузка файла отчета сводки СА",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_svodka_ca(
|
||||
file: UploadFile = File(..., description="Excel файл сводки СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Загрузка и обработка отчета сводки СА
|
||||
|
||||
### Поддерживаемые форматы:
|
||||
- **Excel файлы** (.xlsx, .xlsm, .xls)
|
||||
|
||||
### Структура данных:
|
||||
- Обрабатывает Excel файлы с данными по режимам и таблицам
|
||||
- Извлекает данные по указанным режимам (plan, fact, normativ)
|
||||
- Возвращает агрегированные данные по таблицам
|
||||
|
||||
### Пример использования:
|
||||
1. Подготовьте Excel файл сводки СА
|
||||
2. Загрузите файл через этот эндпоинт
|
||||
3. Используйте полученный `object_id` для запросов данных
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Проверяем тип файла
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='svodka_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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/svodka_ca/get_data", tags=[SvodkaCAParser.name],
|
||||
summary="Получение данных из отчета сводки СА")
|
||||
async def get_svodka_ca_data(
|
||||
request_data: SvodkaCARequest
|
||||
):
|
||||
"""Получение данных из отчета сводки СА по указанным режимам и таблицам
|
||||
|
||||
### Структура параметров:
|
||||
- `modes`: **Массив кодов** режимов - `plan`, `fact` или `normativ` (обязательный)
|
||||
- `tables`: **Массив названий** таблиц как есть (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"modes": ["plan", "fact"],
|
||||
"tables": ["ТиП, %", "Топливо итого, тонн", "Топливо итого, %", "Потери итого, тонн"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request = DataRequest(
|
||||
report_type='svodka_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)}")
|
||||
|
||||
|
||||
@router.post("/async/svodka_ca/upload", tags=[SvodkaCAParser.name],
|
||||
summary="Асинхронная загрузка файла отчета сводки СА",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_svodka_ca(
|
||||
file: UploadFile = File(..., description="Excel файл сводки СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Асинхронная загрузка и обработка отчета сводки СА"""
|
||||
async_service = get_async_report_service()
|
||||
try:
|
||||
# Проверяем тип файла
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='svodka_ca',
|
||||
file_content=file_content,
|
||||
file_name=file.filename
|
||||
)
|
||||
|
||||
# Загружаем отчет асинхронно
|
||||
result = await async_service.upload_report_async(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 Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке сводки СА: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_SERVER_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
319
python_parser/app/endpoints/svodka_pm.py
Normal file
319
python_parser/app/endpoints/svodka_pm.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Эндпоинты для сводки ПМ
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import SvodkaPMParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import (
|
||||
UploadResponse, UploadErrorResponse,
|
||||
SvodkaPMTotalOGsRequest, SvodkaPMSingleOGRequest
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для сводки ПМ
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.post("/svodka_pm/upload-zip", tags=[SvodkaPMParser.name],
|
||||
summary="Загрузка файлов сводок ПМ одним ZIP-архивом",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат архива или файлов"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_svodka_pm_zip(
|
||||
zip_file: UploadFile = File(..., description="ZIP архив с Excel файлами (.zip)")
|
||||
):
|
||||
"""Загрузка файлов сводок ПМ (факта и плана) по всем ОГ в **одном ZIP-архиве**
|
||||
|
||||
### Поддерживаемые форматы:
|
||||
- **ZIP архивы** с файлами сводок ПМ
|
||||
|
||||
### Структура данных:
|
||||
- Обрабатывает ZIP архивы с файлами по ОГ (svodka_fact_SNPZ.xlsx, svodka_plan_SNPZ.xlsx и т.д.)
|
||||
- Извлекает данные по кодам строк и колонкам
|
||||
- Возвращает агрегированные данные по ОГ
|
||||
|
||||
### Пример использования:
|
||||
1. Подготовьте ZIP архив с файлами сводок ПМ
|
||||
2. Загрузите архив через этот эндпоинт
|
||||
3. Используйте полученный `object_id` для запросов данных
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Проверяем тип файла - только ZIP архивы
|
||||
if not zip_file.filename.endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Файл должен быть ZIP архивом",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": zip_file.filename.split('.')[-1] if '.' in zip_file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await zip_file.read()
|
||||
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='svodka_pm',
|
||||
file_content=file_content,
|
||||
file_name=zip_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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/svodka_pm/get_single_og", tags=[SvodkaPMParser.name],
|
||||
summary="Получение данных по одному ОГ")
|
||||
async def get_svodka_pm_single_og(
|
||||
request_data: SvodkaPMSingleOGRequest
|
||||
):
|
||||
"""Получение данных из сводок ПМ (факта и плана) по одному ОГ
|
||||
|
||||
### Структура параметров:
|
||||
- `id`: **Идентификатор МА** для запрашиваемого ОГ (обязательный)
|
||||
- `codes`: **Массив кодов** выбираемых строк (обязательный)
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
- `search`: **Опциональный параметр** для фильтрации ("Итого" или null)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"id": "SNPZ",
|
||||
"codes": [78, 79],
|
||||
"columns": ["ПП", "СЭБ"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'single_og'
|
||||
request = DataRequest(
|
||||
report_type='svodka_pm',
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/svodka_pm/get_total_ogs", tags=[SvodkaPMParser.name],
|
||||
summary="Получение данных по всем ОГ")
|
||||
async def get_svodka_pm_total_ogs(
|
||||
request_data: SvodkaPMTotalOGsRequest
|
||||
):
|
||||
"""Получение данных из сводок ПМ (факта и плана) по всем ОГ
|
||||
|
||||
### Структура параметров:
|
||||
- `codes`: **Массив кодов** выбираемых строк (обязательный)
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
- `search`: **Опциональный параметр** для фильтрации ("Итого" или null)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"codes": [78, 79],
|
||||
"columns": ["ПП", "СЭБ"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'total_ogs'
|
||||
request = DataRequest(
|
||||
report_type='svodka_pm',
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/svodka_pm/get_data", tags=[SvodkaPMParser.name])
|
||||
async def get_svodka_pm_data(
|
||||
request_data: dict
|
||||
):
|
||||
"""Получение данных из сводок ПМ (факта и плана)
|
||||
|
||||
### Структура параметров:
|
||||
- `indicator_id`: **ID индикатора** для поиска (обязательный)
|
||||
- `code`: **Код строки** для поиска (обязательный)
|
||||
- `search_value`: **Опциональное значение** для поиска
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"indicator_id": "SNPZ",
|
||||
"code": 78,
|
||||
"search_value": "Итого"
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request = DataRequest(
|
||||
report_type='svodka_pm',
|
||||
get_params=request_data
|
||||
)
|
||||
|
||||
# Получаем данные
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/async/svodka_pm/upload-zip", tags=[SvodkaPMParser.name],
|
||||
summary="Асинхронная загрузка файлов сводок ПМ одним ZIP-архивом",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат архива или файлов"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_svodka_pm_zip(
|
||||
zip_file: UploadFile = File(..., description="ZIP архив с Excel файлами (.zip)")
|
||||
):
|
||||
"""Асинхронная загрузка файлов сводок ПМ (факта и плана) по всем ОГ в **одном ZIP-архиве**"""
|
||||
async_service = get_async_report_service()
|
||||
try:
|
||||
if not zip_file.filename.endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Файл должен быть ZIP архивом",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": zip_file.filename.split('.')[-1] if '.' in zip_file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
file_content = await zip_file.read()
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='svodka_pm',
|
||||
file_content=file_content,
|
||||
file_name=zip_file.filename
|
||||
)
|
||||
# Загружаем отчет асинхронно
|
||||
result = await async_service.upload_report_async(request)
|
||||
|
||||
if result.success:
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
message=result.message,
|
||||
object_id=result.object_id
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message=result.message,
|
||||
error_code="UPLOAD_FAILED"
|
||||
).model_dump()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке сводки ПМ: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
189
python_parser/app/endpoints/svodka_repair_ca.py
Normal file
189
python_parser/app/endpoints/svodka_repair_ca.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Эндпоинты для сводки ремонта СА
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import SvodkaRepairCAParser
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService
|
||||
from core.async_services import AsyncReportService
|
||||
from app.schemas import UploadResponse, UploadErrorResponse
|
||||
from app.schemas.svodka_repair_ca import SvodkaRepairCARequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для сводки ремонта СА
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
"""Получение экземпляра сервиса отчетов"""
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
def get_async_report_service() -> AsyncReportService:
|
||||
"""Получение экземпляра асинхронного сервиса отчетов"""
|
||||
from core.services import ReportService
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
report_service = ReportService(storage_adapter)
|
||||
return AsyncReportService(report_service)
|
||||
|
||||
|
||||
@router.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 файл сводки ремонта СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Загрузка и обработка отчета сводки ремонта СА"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"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()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/svodka_repair_ca/get_data", tags=[SvodkaRepairCAParser.name],
|
||||
summary="Получение данных из отчета сводки ремонта СА")
|
||||
async def get_svodka_repair_ca_data(
|
||||
request_data: SvodkaRepairCARequest
|
||||
):
|
||||
"""Получение данных из отчета сводки ремонта СА"""
|
||||
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)}")
|
||||
|
||||
|
||||
@router.post("/async/svodka_repair_ca/upload", tags=[SvodkaRepairCAParser.name],
|
||||
summary="Асинхронная загрузка файла отчета сводки ремонта СА",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def async_upload_svodka_repair_ca(
|
||||
file: UploadFile = File(..., description="Excel файл сводки ремонта СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""Асинхронная загрузка и обработка отчета сводки ремонта СА"""
|
||||
async_service = get_async_report_service()
|
||||
|
||||
try:
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"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 = await async_service.upload_report_async(request)
|
||||
|
||||
if result.success:
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
message=result.message,
|
||||
object_id=result.object_id
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message=result.message,
|
||||
error_code="UPLOAD_FAILED"
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке сводки ремонта СА: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=UploadErrorResponse(
|
||||
message=f"Внутренняя ошибка сервера: {str(e)}",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump()
|
||||
)
|
||||
21
python_parser/app/endpoints/system.py
Normal file
21
python_parser/app/endpoints/system.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Системные эндпоинты FastAPI (не отображаются в Swagger)
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from adapters.pconfig import SINGLE_OGS, OG_IDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Создаем роутер для системных эндпоинтов
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/system/ogs", include_in_schema=False)
|
||||
async def get_system_ogs():
|
||||
"""Системный эндпоинт для получения списка ОГ из pconfig"""
|
||||
return {
|
||||
"single_ogs": SINGLE_OGS,
|
||||
"og_ids": OG_IDS
|
||||
}
|
||||
@@ -1,876 +1,71 @@
|
||||
"""
|
||||
Главный файл FastAPI приложения
|
||||
"""
|
||||
import os
|
||||
import multiprocessing
|
||||
import uvicorn
|
||||
from typing import Dict, List
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
from fastapi import FastAPI
|
||||
|
||||
from adapters.storage import MinIOStorageAdapter
|
||||
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
|
||||
|
||||
from core.models import UploadRequest, DataRequest
|
||||
from core.services import ReportService, PARSERS
|
||||
|
||||
from app.schemas import (
|
||||
ServerInfoResponse,
|
||||
UploadResponse, UploadErrorResponse,
|
||||
SvodkaPMTotalOGsRequest, SvodkaPMSingleOGRequest,
|
||||
SvodkaCARequest,
|
||||
MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
|
||||
# Настройка логирования
|
||||
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__)
|
||||
|
||||
# Парсеры
|
||||
# Импортируем парсеры и обновляем PARSERS
|
||||
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, MonitoringTarParser, SvodkaRepairCAParser, StatusesRepairCAParser, OperSpravkaTechPosParser
|
||||
from core.services import PARSERS
|
||||
|
||||
# Обновляем словарь парсеров
|
||||
PARSERS.update({
|
||||
'svodka_pm': SvodkaPMParser,
|
||||
'svodka_ca': SvodkaCAParser,
|
||||
'monitoring_fuel': MonitoringFuelParser,
|
||||
# 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser,
|
||||
'monitoring_tar': MonitoringTarParser,
|
||||
'svodka_repair_ca': SvodkaRepairCAParser,
|
||||
'statuses_repair_ca': StatusesRepairCAParser,
|
||||
'oper_spravka_tech_pos': OperSpravkaTechPosParser,
|
||||
})
|
||||
|
||||
# Адаптеры
|
||||
storage_adapter = MinIOStorageAdapter()
|
||||
|
||||
|
||||
def get_report_service() -> ReportService:
|
||||
return ReportService(storage_adapter)
|
||||
|
||||
|
||||
tags_metadata = [
|
||||
{
|
||||
"name": "Общее",
|
||||
"display_name": "Общее",
|
||||
},
|
||||
{
|
||||
"name": SvodkaPMParser.name,
|
||||
"description": "✅ Ready",
|
||||
},
|
||||
{
|
||||
"name": SvodkaCAParser.name,
|
||||
"description": "✅ Ready",
|
||||
"display_name": "Сводка ПМ",
|
||||
},
|
||||
{
|
||||
"name": MonitoringFuelParser.name,
|
||||
"description": "✅ Ready",
|
||||
"display_name": "Мониторинг топлива",
|
||||
},
|
||||
# {
|
||||
# "name": MonitoringFuelParser.name,
|
||||
# "description": "⚠️ WORK IN PROGRESS",
|
||||
# },
|
||||
|
||||
]
|
||||
|
||||
# Создаем FastAPI приложение
|
||||
app = FastAPI(
|
||||
title="NIN Excel Parsers API",
|
||||
description="API для парсинга сводок и работы с данными экселей НиН",
|
||||
title="Svodka Parser API",
|
||||
description="API для парсинга различных типов отчетов",
|
||||
version="1.0.0",
|
||||
openapi_tags=tags_metadata,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/", tags=["Общее"])
|
||||
async def root():
|
||||
return {"message": "Svodka Parser API", "version": "1.0.0"}
|
||||
|
||||
|
||||
@app.get("/parsers", tags=["Общее"],
|
||||
summary="Список доступных парсеров",
|
||||
description="Возвращает список идентификаторов всех доступных парсеров",
|
||||
response_model=Dict[str, List[str]],
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},)
|
||||
async def get_available_parsers():
|
||||
"""Получение списка доступных парсеров"""
|
||||
parsers = list(PARSERS.keys())
|
||||
return {"parsers": parsers}
|
||||
|
||||
|
||||
@app.get("/parsers/{parser_name}/getters", tags=["Общее"],
|
||||
summary="Информация о геттерах парсера",
|
||||
description="Возвращает информацию о доступных геттерах для указанного парсера",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"parser": "svodka_pm",
|
||||
"getters": {
|
||||
"single_og": {
|
||||
"required_params": ["id", "codes", "columns"],
|
||||
"optional_params": ["search"],
|
||||
"description": "Получение данных по одному ОГ"
|
||||
},
|
||||
"total_ogs": {
|
||||
"required_params": ["codes", "columns"],
|
||||
"optional_params": ["search"],
|
||||
"description": "Получение данных по всем ОГ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Парсер не найден"
|
||||
}
|
||||
})
|
||||
async def get_parser_getters(parser_name: str):
|
||||
"""Получение информации о геттерах парсера"""
|
||||
if parser_name not in PARSERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Парсер '{parser_name}' не найден"
|
||||
)
|
||||
|
||||
parser_class = PARSERS[parser_name]
|
||||
parser_instance = parser_class()
|
||||
|
||||
getters_info = parser_instance.get_available_getters()
|
||||
|
||||
return {
|
||||
"parser": parser_name,
|
||||
"getters": getters_info
|
||||
}
|
||||
|
||||
|
||||
@app.get("/server-info", tags=["Общее"],
|
||||
summary="Информация о сервере",
|
||||
response_model=ServerInfoResponse,)
|
||||
async def server_info():
|
||||
return {
|
||||
"process_id": os.getpid(),
|
||||
"parent_id": os.getppid(),
|
||||
"cpu_cores": multiprocessing.cpu_count(),
|
||||
"memory_mb": os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') / (1024. ** 2)
|
||||
}
|
||||
|
||||
|
||||
# @app.get("/svodka_pm/schema", tags=[SvodkaPMParser.name])
|
||||
# async def get_svodka_pm_schema():
|
||||
# """Получение схемы параметров для парсера сводок ПМ факта и плана"""
|
||||
# parser = PARSERS['svodka_pm']()
|
||||
# return parser.get_schema()
|
||||
|
||||
|
||||
# @app.get("/svodka_ca/schema", tags=[SvodkaCAParser.name])
|
||||
# async def get_svodka_ca_schema():
|
||||
# """Получение схемы параметров для парсера сводки СА"""
|
||||
# parser = PARSERS['svodka_ca']()
|
||||
# return parser.get_schema()
|
||||
|
||||
|
||||
# @app.get("/monitoring_fuel/schema", tags=[MonitoringFuelParser.name])
|
||||
# async def get_monitoring_fuel_schema():
|
||||
# """Получение схемы параметров для парсера мониторинга топлива"""
|
||||
# parser = PARSERS['monitoring_fuel']()
|
||||
# return parser.get_schema()
|
||||
|
||||
|
||||
@app.post("/svodka_pm/upload-zip", tags=[SvodkaPMParser.name],
|
||||
summary="Загрузка файлов сводок ПМ одним ZIP-архивом",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат архива или файлов"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_svodka_pm_zip(
|
||||
zip_file: UploadFile = File(..., description="ZIP архив с Excel файлами (.zip)")
|
||||
):
|
||||
"""Загрузка файлов сводок ПМ (факта и плана) по всем ОГ в **одном ZIP-архиве**
|
||||
|
||||
**Шаблоны названий файлов:**
|
||||
- Факт: `svodka_fact_pm_<OG_ID>.xlsm`
|
||||
- План: `svodka_plan_pm_<OG_ID>.xlsx`
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
try:
|
||||
if not zip_file.filename.lower().endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Файл должен быть ZIP архивом",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": zip_file.filename.split('.')[-1] if '.' in zip_file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
file_content = await zip_file.read()
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='svodka_pm',
|
||||
file_content=file_content,
|
||||
file_name=zip_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_pm/upload", tags=[SvodkaPMParser.name])
|
||||
# async def upload_svodka_pm(
|
||||
# file: UploadFile = File(...)
|
||||
# ):
|
||||
# report_service = get_report_service()
|
||||
# """
|
||||
# Загрузка отчета сводки факта СарНПЗ
|
||||
|
||||
# - file: Excel файл для загрузки
|
||||
# """
|
||||
# try:
|
||||
# # Проверяем тип файла
|
||||
# if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)"
|
||||
# )
|
||||
|
||||
# # Читаем содержимое файла
|
||||
# file_content = await file.read()
|
||||
|
||||
# # Создаем запрос
|
||||
# request = UploadRequest(
|
||||
# report_type='svodka_pm',
|
||||
# file_content=file_content,
|
||||
# file_name=file.filename
|
||||
# )
|
||||
|
||||
# # Загружаем отчет
|
||||
# result = report_service.upload_report(request)
|
||||
# # print(result)
|
||||
# if result.success:
|
||||
# return {
|
||||
# "success": True,
|
||||
# "message": result.message,
|
||||
# "object_id": result.object_id
|
||||
# }
|
||||
# else:
|
||||
# raise HTTPException(status_code=500, detail=result.message)
|
||||
|
||||
# except HTTPException:
|
||||
# raise
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/svodka_pm/get_single_og", tags=[SvodkaPMParser.name],
|
||||
summary="Получение данных по одному ОГ")
|
||||
async def get_svodka_pm_single_og(
|
||||
request_data: SvodkaPMSingleOGRequest
|
||||
):
|
||||
"""Получение данных из сводок ПМ (факта и плана) по одному ОГ
|
||||
|
||||
### Структура параметров:
|
||||
- `id`: **Идентификатор МА** для запрашиваемого ОГ (обязательный)
|
||||
- `codes`: **Массив кодов** выбираемых строк (обязательный)
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
- `search`: **Опциональный параметр** для фильтрации ("Итого" или null)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"id": "SNPZ",
|
||||
"codes": [78, 79],
|
||||
"columns": ["ПП", "СЭБ"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
"""
|
||||
Получение данных из отчета сводки факта СарНПЗ
|
||||
|
||||
- id: ID ОГ
|
||||
- codes: коды выбираемых строк [78, 79]
|
||||
- columns: выбираемые колонки ["БП", "СЭБ"]
|
||||
- search: "Итого" не обязательный
|
||||
"""
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'single'
|
||||
request = DataRequest(
|
||||
report_type='svodka_pm',
|
||||
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("/svodka_pm/get_total_ogs", tags=[SvodkaPMParser.name],
|
||||
summary="Получение данных по всем ОГ")
|
||||
async def get_svodka_pm_total_ogs(
|
||||
request_data: SvodkaPMTotalOGsRequest
|
||||
):
|
||||
"""Получение данных из сводок ПМ (факта и плана) по всем ОГ
|
||||
|
||||
### Структура параметров:
|
||||
- `codes`: **Массив кодов** выбираемых строк (обязательный)
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
- `search`: **Опциональный параметр** для фильтрации ("Итого" или null)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"codes": [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
|
||||
"columns": ["БП", "ПП", "СЭБ"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
"""
|
||||
Получение данных из отчета сводки факта СарНПЗ
|
||||
|
||||
- codes: коды выбираемых строк [78, 79]
|
||||
- columns: выбираемые колонки ["БП", "СЭБ"]
|
||||
- search: "Итого"
|
||||
"""
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'total'
|
||||
request = DataRequest(
|
||||
report_type='svodka_pm',
|
||||
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("/svodka_pm/get_data", tags=[SvodkaPMParser.name])
|
||||
async def get_svodka_pm_data(
|
||||
request_data: dict
|
||||
):
|
||||
report_service = get_report_service()
|
||||
"""
|
||||
Получение данных из отчета сводки факта СарНПЗ
|
||||
|
||||
- indicator_id: ID индикатора
|
||||
- code: Код для поиска
|
||||
- search_value: Опциональное значение для поиска
|
||||
"""
|
||||
try:
|
||||
# Создаем запрос
|
||||
request = DataRequest(
|
||||
report_type='svodka_pm',
|
||||
get_params=request_data
|
||||
)
|
||||
|
||||
# Получаем данные
|
||||
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("/svodka_ca/upload", tags=[SvodkaCAParser.name],
|
||||
summary="Загрузка файла отчета сводки СА",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат файла"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_svodka_ca(
|
||||
file: UploadFile = File(..., description="Excel файл сводки СА (.xlsx, .xlsm, .xls)")
|
||||
):
|
||||
"""
|
||||
Загрузка и обработка Excel файла отчета сводки СА
|
||||
|
||||
**Поддерживаемые форматы:**
|
||||
- Excel (.xlsx, .xlsm, .xls)
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Проверяем тип файла
|
||||
if not file.filename.endswith(('.xlsx', '.xlsm', '.xls')):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls)",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".xlsx", ".xlsm", ".xls"],
|
||||
"received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='svodka_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_ca/get_data", tags=[SvodkaCAParser.name],
|
||||
summary="Получение данных из отчета сводки СА")
|
||||
async def get_svodka_ca_data(
|
||||
request_data: SvodkaCARequest
|
||||
):
|
||||
"""
|
||||
Получение данных из отчета сводки СА по указанным режимам и таблицам
|
||||
|
||||
### Структура параметров:
|
||||
- `modes`: **Массив кодов** режимов - `plan`, `fact` или `normativ` (обязательный)
|
||||
- `tables`: **Массив названий** таблиц как есть (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"modes": ["plan", "fact"],
|
||||
"tables": ["ТиП, %", "Топливо итого, тонн", "Топливо итого, %", "Потери итого, тонн"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request = DataRequest(
|
||||
report_type='svodka_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])
|
||||
# async def upload_monitoring_fuel(
|
||||
# file: UploadFile = File(...),
|
||||
# directory_path: str = None
|
||||
# ):
|
||||
# report_service = get_report_service()
|
||||
# """
|
||||
# Загрузка отчета мониторинга топлива
|
||||
|
||||
# - file: Excel файл для загрузки (или архив с файлами)
|
||||
# - directory_path: Путь к директории с файлами (опционально)
|
||||
# """
|
||||
# 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()
|
||||
|
||||
# # Создаем параметры для парсинга
|
||||
# parse_params = {}
|
||||
# if directory_path:
|
||||
# parse_params['directory_path'] = directory_path
|
||||
|
||||
# # Создаем запрос
|
||||
# request = UploadRequest(
|
||||
# report_type='monitoring_fuel',
|
||||
# file_content=file_content,
|
||||
# file_name=file.filename,
|
||||
# parse_params=parse_params
|
||||
# )
|
||||
|
||||
# # Загружаем отчет
|
||||
# result = report_service.upload_report(request)
|
||||
|
||||
# if result.success:
|
||||
# return {
|
||||
# "success": True,
|
||||
# "message": result.message,
|
||||
# "object_id": result.object_id
|
||||
# }
|
||||
# else:
|
||||
# raise HTTPException(status_code=500, detail=result.message)
|
||||
|
||||
# except HTTPException:
|
||||
# raise
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/monitoring_fuel/get_data", tags=[MonitoringFuelParser.name])
|
||||
async def get_monitoring_fuel_data(
|
||||
request_data: dict
|
||||
):
|
||||
report_service = get_report_service()
|
||||
"""
|
||||
Получение данных из отчета мониторинга топлива
|
||||
|
||||
- column: Название колонки для агрегации (normativ, total, total_svod)
|
||||
"""
|
||||
try:
|
||||
# Создаем запрос
|
||||
request = DataRequest(
|
||||
report_type='monitoring_fuel',
|
||||
get_params=request_data
|
||||
)
|
||||
|
||||
# Получаем данные
|
||||
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_directory", tags=[MonitoringFuelParser.name])
|
||||
# async def upload_monitoring_fuel_directory(
|
||||
# request_data: dict
|
||||
# ):
|
||||
# report_service = get_report_service()
|
||||
# """
|
||||
# Загрузка отчета мониторинга топлива из директории
|
||||
|
||||
# - directory_path: Путь к директории с файлами monitoring_SNPZ_*.xlsm
|
||||
# """
|
||||
# try:
|
||||
# import os
|
||||
# import glob
|
||||
|
||||
# # Извлекаем directory_path из request_data
|
||||
# directory_path = request_data.get('directory_path')
|
||||
# if not directory_path:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Параметр 'directory_path' обязателен"
|
||||
# )
|
||||
|
||||
# # Проверяем существование директории
|
||||
# if not os.path.exists(directory_path):
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail=f"Директория не найдена: {directory_path}"
|
||||
# )
|
||||
|
||||
# # Проверяем наличие файлов
|
||||
# file_pattern = os.path.join(directory_path, "monitoring_SNPZ_*.xlsm")
|
||||
# files = glob.glob(file_pattern)
|
||||
|
||||
# if not files:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail=f"Не найдены файлы по паттерну {file_pattern}"
|
||||
# )
|
||||
|
||||
# # Создаем параметры для парсинга
|
||||
# parse_params = {
|
||||
# 'directory_path': directory_path,
|
||||
# 'sheet_name': 'Мониторинг потребления',
|
||||
# 'search_value': 'Установка'
|
||||
# }
|
||||
|
||||
# # Создаем запрос (используем пустой файл, так как парсим директорию)
|
||||
# request = UploadRequest(
|
||||
# report_type='monitoring_fuel',
|
||||
# file_content=b'', # Пустой контент, так как парсим директорию
|
||||
# file_name='directory_upload',
|
||||
# parse_params=parse_params
|
||||
# )
|
||||
|
||||
# # Загружаем отчет
|
||||
# result = report_service.upload_report(request)
|
||||
|
||||
# if result.success:
|
||||
# return {
|
||||
# "success": True,
|
||||
# "message": result.message,
|
||||
# "object_id": result.object_id,
|
||||
# "files_processed": len(files)
|
||||
# }
|
||||
# else:
|
||||
# raise HTTPException(status_code=500, detail=result.message)
|
||||
|
||||
# except HTTPException:
|
||||
# raise
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/monitoring_fuel/upload-zip", tags=[MonitoringFuelParser.name],
|
||||
summary="Загрузка файлов сводок мониторинга топлива одним ZIP-архивом",
|
||||
response_model=UploadResponse,
|
||||
responses={
|
||||
400: {"model": UploadErrorResponse, "description": "Неверный формат архива или файлов"},
|
||||
500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"}
|
||||
},)
|
||||
async def upload_monitoring_fuel_zip(
|
||||
zip_file: UploadFile = File(..., description="ZIP архив с Excel файлами (.zip)")
|
||||
):
|
||||
"""Загрузка файлов сводок мониторинга топлива по всем ОГ в **одном ZIP-архиве**
|
||||
|
||||
**Шаблоны названий файлов:**
|
||||
- `monitoring_SNPZ_{MM}.xlsm`, `MM` - номер месяца с ведущим 0
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
try:
|
||||
if not zip_file.filename.lower().endswith('.zip'):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=UploadErrorResponse(
|
||||
message="Файл должен быть ZIP архивом",
|
||||
error_code="INVALID_FILE_TYPE",
|
||||
details={
|
||||
"expected_formats": [".zip"],
|
||||
"received_format": zip_file.filename.split('.')[-1] if '.' in zip_file.filename else "unknown"
|
||||
}
|
||||
).model_dump()
|
||||
)
|
||||
file_content = await zip_file.read()
|
||||
# Создаем запрос
|
||||
request = UploadRequest(
|
||||
report_type='monitoring_fuel',
|
||||
file_content=file_content,
|
||||
file_name=zip_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("/monitoring_fuel/get_total_by_columns", tags=[MonitoringFuelParser.name],
|
||||
summary="Получение данных по колонкам и расчёт средних значений")
|
||||
async def get_monitoring_fuel_total_by_columns(
|
||||
request_data: MonitoringFuelTotalRequest
|
||||
):
|
||||
"""Получение данных из сводок мониторинга топлива по колонкам и расчёт средних значений
|
||||
|
||||
### Структура параметров:
|
||||
- `columns`: **Массив названий** выбираемых столбцов (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"columns": ["total", "normativ"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'total'
|
||||
request = DataRequest(
|
||||
report_type='monitoring_fuel',
|
||||
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/get_month_by_code", tags=[MonitoringFuelParser.name],
|
||||
summary="Получение данных за месяц")
|
||||
async def get_monitoring_fuel_month_by_code(
|
||||
request_data: MonitoringFuelMonthRequest
|
||||
):
|
||||
"""Получение данных из сводок мониторинга топлива за указанный номер месяца
|
||||
|
||||
### Структура параметров:
|
||||
- `month`: **Номер месяца строкой с ведущим 0** (обязательный)
|
||||
|
||||
### Пример тела запроса:
|
||||
```json
|
||||
{
|
||||
"month": "02"
|
||||
}
|
||||
```
|
||||
"""
|
||||
report_service = get_report_service()
|
||||
|
||||
try:
|
||||
# Создаем запрос
|
||||
request_dict = request_data.model_dump()
|
||||
request_dict['mode'] = 'month'
|
||||
request = DataRequest(
|
||||
report_type='monitoring_fuel',
|
||||
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)}")
|
||||
# Подключаем роутеры
|
||||
from app.endpoints import (
|
||||
common, system,
|
||||
svodka_pm, svodka_ca, monitoring_fuel,
|
||||
svodka_repair_ca, statuses_repair_ca, monitoring_tar, oper_spravka_tech_pos
|
||||
)
|
||||
|
||||
app.include_router(common.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(svodka_pm.router)
|
||||
app.include_router(svodka_ca.router)
|
||||
app.include_router(monitoring_fuel.router)
|
||||
app.include_router(svodka_repair_ca.router)
|
||||
app.include_router(statuses_repair_ca.router)
|
||||
app.include_router(monitoring_tar.router)
|
||||
app.include_router(oper_spravka_tech_pos.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
# Настройка для запуска в продакшене
|
||||
workers = multiprocessing.cpu_count()
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
workers=workers,
|
||||
reload=False
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from .monitoring_fuel import MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
|
||||
from .monitoring_fuel import MonitoringFuelMonthRequest, MonitoringFuelTotalRequest, MonitoringFuelSeriesRequest
|
||||
from .svodka_ca import SvodkaCARequest
|
||||
from .svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
|
||||
from .server import ServerInfoResponse
|
||||
@@ -6,7 +6,7 @@ from .upload import UploadResponse, UploadErrorResponse
|
||||
|
||||
|
||||
__all__ = [
|
||||
'MonitoringFuelMonthRequest', 'MonitoringFuelTotalRequest',
|
||||
'MonitoringFuelMonthRequest', 'MonitoringFuelTotalRequest', 'MonitoringFuelSeriesRequest',
|
||||
'SvodkaCARequest',
|
||||
'SvodkaPMSingleOGRequest', 'SvodkaPMTotalOGsRequest',
|
||||
'ServerInfoResponse',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -32,3 +32,19 @@ class MonitoringFuelTotalRequest(BaseModel):
|
||||
"columns": ["total", "normativ"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MonitoringFuelSeriesRequest(BaseModel):
|
||||
columns: List[str] = Field(
|
||||
...,
|
||||
description="Массив названий выбираемых столбцов",
|
||||
example=["total", "normativ"],
|
||||
min_items=1
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"columns": ["total", "normativ"]
|
||||
}
|
||||
}
|
||||
|
||||
33
python_parser/app/schemas/monitoring_tar.py
Normal file
33
python_parser/app/schemas/monitoring_tar.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Literal
|
||||
from enum import Enum
|
||||
|
||||
class TarMode(str, Enum):
|
||||
"""Режимы получения данных мониторинга ТЭР"""
|
||||
TOTAL = "total"
|
||||
LAST_DAY = "last_day"
|
||||
|
||||
class MonitoringTarRequest(BaseModel):
|
||||
"""Схема запроса для получения данных мониторинга ТЭР"""
|
||||
mode: Optional[TarMode] = Field(
|
||||
None,
|
||||
description="Режим получения данных: 'total' (строки 'Всего') или 'last_day' (последние строки). Если не указан, возвращаются все данные",
|
||||
example="total"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"mode": "total"
|
||||
}
|
||||
}
|
||||
|
||||
class MonitoringTarFullRequest(BaseModel):
|
||||
"""Схема запроса для получения всех данных мониторинга ТЭР"""
|
||||
# Пустая схема - возвращает все данные без фильтрации
|
||||
pass
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {}
|
||||
}
|
||||
38
python_parser/app/schemas/oper_spravka_tech_pos.py
Normal file
38
python_parser/app/schemas/oper_spravka_tech_pos.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class OperSpravkaTechPosRequest(BaseModel):
|
||||
"""Запрос для получения данных операционной справки технологических позиций"""
|
||||
id: str = Field(..., description="ID ОГ (например, 'SNPZ', 'KNPZ')")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"id": "SNPZ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class OperSpravkaTechPosResponse(BaseModel):
|
||||
"""Ответ с данными операционной справки технологических позиций"""
|
||||
success: bool = Field(..., description="Статус успешности операции")
|
||||
data: Optional[List[dict]] = Field(None, description="Данные по технологическим позициям")
|
||||
message: Optional[str] = Field(None, description="Сообщение о результате операции")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"Процесс": "Первичная переработка",
|
||||
"Установка": "ЭЛОУ-АВТ-6",
|
||||
"План, т": 14855.0,
|
||||
"Факт, т": 15149.647,
|
||||
"id": "SNPZ.EAVT6"
|
||||
}
|
||||
],
|
||||
"message": "Данные успешно получены"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,29 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ServerInfoResponse(BaseModel):
|
||||
process_id: int = Field(..., description="Идентификатор текущего процесса сервера")
|
||||
parent_id: int = Field(..., description="Идентификатор родительского процесса")
|
||||
cpu_cores: int = Field(..., description="Количество ядер процессора в системе")
|
||||
memory_mb: float = Field(..., description="Общий объем оперативной памяти в мегабайтах")
|
||||
message: str = Field(..., description="Сообщение о сервере")
|
||||
version: str = Field(..., description="Версия API")
|
||||
process_id: Optional[int] = Field(None, description="Идентификатор текущего процесса сервера")
|
||||
parent_id: Optional[int] = Field(None, description="Идентификатор родительского процесса")
|
||||
cpu_cores: Optional[int] = Field(None, description="Количество ядер процессора в системе")
|
||||
memory_mb: Optional[float] = Field(None, description="Общий объем оперативной памяти в мегабайтах")
|
||||
python_version: Optional[str] = Field(None, description="Версия Python")
|
||||
platform: Optional[str] = Field(None, description="Платформа")
|
||||
available_parsers: Optional[list] = Field(None, description="Доступные парсеры")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"message": "Svodka Parser API",
|
||||
"version": "1.0.0",
|
||||
"process_id": 12345,
|
||||
"parent_id": 6789,
|
||||
"cpu_cores": 8,
|
||||
"memory_mb": 16384.5
|
||||
"memory_mb": 16384.5,
|
||||
"python_version": "3.11.0",
|
||||
"platform": "Windows-10-10.0.22631-SP0",
|
||||
"available_parsers": ["svodka_pm", "svodka_ca", "monitoring_fuel"]
|
||||
}
|
||||
}
|
||||
|
||||
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):
|
||||
id: OGID = Field(
|
||||
id: str = Field(
|
||||
...,
|
||||
description="Идентификатор МА для запрашиваемого ОГ",
|
||||
example="SNPZ"
|
||||
|
||||
46
python_parser/app/schemas/svodka_repair_ca.py
Normal file
46
python_parser/app/schemas/svodka_repair_ca.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RepairType(str, Enum):
|
||||
"""Типы ремонтных работ"""
|
||||
KR = "КР" # Капитальный ремонт
|
||||
KP = "КП" # Капитальный ремонт
|
||||
TR = "ТР" # Текущий ремонт
|
||||
|
||||
|
||||
class SvodkaRepairCARequest(BaseModel):
|
||||
"""Запрос на получение данных сводки ремонта СА"""
|
||||
|
||||
og_ids: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Список ID ОГ для фильтрации. Если не указан, возвращаются данные по всем ОГ",
|
||||
example=["SNPZ", "KNPZ", "BASH"]
|
||||
)
|
||||
|
||||
repair_types: Optional[List[RepairType]] = Field(
|
||||
default=None,
|
||||
description="Список типов ремонта для фильтрации. Если не указан, возвращаются все типы",
|
||||
example=[RepairType.KR, RepairType.KP]
|
||||
)
|
||||
|
||||
include_planned: bool = Field(
|
||||
default=True,
|
||||
description="Включать ли плановые данные"
|
||||
)
|
||||
|
||||
include_factual: bool = Field(
|
||||
default=True,
|
||||
description="Включать ли фактические данные"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"og_ids": ["SNPZ", "KNPZ"],
|
||||
"repair_types": ["КР", "КП"],
|
||||
"include_planned": True,
|
||||
"include_factual": True
|
||||
}
|
||||
}
|
||||
Binary file not shown.
72
python_parser/core/async_services.py
Normal file
72
python_parser/core/async_services.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Асинхронные сервисы для работы с отчетами
|
||||
"""
|
||||
import asyncio
|
||||
import tempfile
|
||||
import os
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
from .services import ReportService
|
||||
from .models import UploadRequest, UploadResult, DataRequest, DataResult
|
||||
from .ports import StoragePort
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncReportService:
|
||||
"""Асинхронный сервис для работы с отчетами"""
|
||||
|
||||
def __init__(self, report_service: ReportService):
|
||||
self.report_service = report_service
|
||||
self.executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
async def upload_report_async(self, request: UploadRequest) -> UploadResult:
|
||||
"""Асинхронная загрузка отчета"""
|
||||
try:
|
||||
# Запускаем синхронную обработку в отдельном потоке
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
self.executor,
|
||||
self._process_upload_sync,
|
||||
request
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке отчета: {str(e)}")
|
||||
return UploadResult(
|
||||
success=False,
|
||||
message=f"Ошибка при асинхронной загрузке отчета: {str(e)}"
|
||||
)
|
||||
|
||||
def _process_upload_sync(self, request: UploadRequest) -> UploadResult:
|
||||
"""Синхронная обработка загрузки (выполняется в отдельном потоке)"""
|
||||
return self.report_service.upload_report(request)
|
||||
|
||||
async def get_data_async(self, request: DataRequest) -> DataResult:
|
||||
"""Асинхронное получение данных"""
|
||||
try:
|
||||
# Запускаем синхронную обработку в отдельном потоке
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
self.executor,
|
||||
self._process_get_data_sync,
|
||||
request
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронном получении данных: {str(e)}")
|
||||
return DataResult(
|
||||
success=False,
|
||||
message=f"Ошибка при асинхронном получении данных: {str(e)}"
|
||||
)
|
||||
|
||||
def _process_get_data_sync(self, request: DataRequest) -> DataResult:
|
||||
"""Синхронное получение данных (выполняется в отдельном потоке)"""
|
||||
return self.report_service.get_data(request)
|
||||
|
||||
def __del__(self):
|
||||
"""Очистка ресурсов"""
|
||||
if hasattr(self, 'executor'):
|
||||
self.executor.shutdown(wait=False)
|
||||
@@ -84,6 +84,35 @@ class ParserPort(ABC):
|
||||
except Exception as e:
|
||||
raise ValueError(f"Ошибка выполнения геттера '{getter_name}': {str(e)}")
|
||||
|
||||
def determine_getter(self, get_params: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Определение имени геттера на основе параметров запроса
|
||||
|
||||
Args:
|
||||
get_params: Параметры запроса
|
||||
|
||||
Returns:
|
||||
Имя геттера для выполнения
|
||||
|
||||
Raises:
|
||||
ValueError: Если не удается определить геттер
|
||||
"""
|
||||
# По умолчанию используем первый доступный геттер
|
||||
available_getters = list(self.getters.keys())
|
||||
if not available_getters:
|
||||
raise ValueError("Парсер не имеет доступных геттеров")
|
||||
|
||||
# Если указан режим, используем его
|
||||
if 'mode' in get_params:
|
||||
mode = get_params['mode']
|
||||
if mode in self.getters:
|
||||
return mode
|
||||
else:
|
||||
raise ValueError(f"Режим '{mode}' не найден. Доступные: {available_getters}")
|
||||
|
||||
# Иначе используем первый доступный
|
||||
return available_getters[0]
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||||
"""Парсинг файла и возврат DataFrame"""
|
||||
|
||||
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 os
|
||||
import logging
|
||||
from typing import Dict, Type
|
||||
|
||||
from core.models import UploadRequest, UploadResult, DataRequest, DataResult
|
||||
from core.ports import ParserPort, StoragePort
|
||||
|
||||
# Настройка логгера для модуля
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Глобальный словарь парсеров
|
||||
PARSERS: Dict[str, Type[ParserPort]] = {}
|
||||
@@ -43,7 +47,7 @@ class ReportService:
|
||||
try:
|
||||
# Парсим файл
|
||||
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 = f"nin_excel_data_{request.report_type}"
|
||||
@@ -51,10 +55,10 @@ class ReportService:
|
||||
# Удаляем старый объект, если он существует и хранилище доступно
|
||||
if self.storage.object_exists(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(
|
||||
success=True,
|
||||
message="Отчет успешно загружен",
|
||||
@@ -89,9 +93,9 @@ class ReportService:
|
||||
message=f"Отчет типа '{request.report_type}' не найден. Возможно, MinIO недоступен или отчет не был загружен."
|
||||
)
|
||||
|
||||
# Загружаем DataFrame из хранилища
|
||||
df = self.storage.load_dataframe(object_id)
|
||||
if df is None:
|
||||
# Загружаем данные из хранилища
|
||||
loaded_data = self.storage.load_dataframe(object_id)
|
||||
if loaded_data is None:
|
||||
return DataResult(
|
||||
success=False,
|
||||
message="Ошибка при загрузке данных из хранилища. Возможно, MinIO недоступен."
|
||||
@@ -100,24 +104,31 @@ class ReportService:
|
||||
# Получаем парсер
|
||||
parser = get_parser(request.report_type)
|
||||
|
||||
# Устанавливаем DataFrame в парсер для использования в геттерах
|
||||
parser.df = df
|
||||
# Устанавливаем данные в парсер для использования в геттерах
|
||||
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 {}
|
||||
|
||||
# Определяем имя геттера (по умолчанию используем первый доступный)
|
||||
getter_name = get_params.pop("getter", None)
|
||||
if not getter_name:
|
||||
# Если геттер не указан, берем первый доступный
|
||||
available_getters = list(parser.getters.keys())
|
||||
if available_getters:
|
||||
getter_name = available_getters[0]
|
||||
print(f"⚠️ Геттер не указан, используем первый доступный: {getter_name}")
|
||||
else:
|
||||
# Определяем имя геттера через парсер (делегируем логику в адаптер)
|
||||
try:
|
||||
getter_name = parser.determine_getter(get_params)
|
||||
except ValueError as e:
|
||||
return DataResult(
|
||||
success=False,
|
||||
message="Парсер не имеет доступных геттеров"
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
# Получаем значение через указанный геттер
|
||||
|
||||
@@ -12,3 +12,4 @@ requests>=2.31.0
|
||||
# pytest-mock>=3.10.0
|
||||
httpx>=0.24.0
|
||||
numpy
|
||||
psutil>=5.9.0
|
||||
20
python_parser/test_app.py
Normal file
20
python_parser/test_app.py
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Простой тест для проверки работы FastAPI
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(title="Test API")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Test API is working"}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
print("Starting test server...")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
33
run_tests.py
Normal file
33
run_tests.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для запуска тестов парсеров
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
def run_tests():
|
||||
"""Запуск тестов"""
|
||||
print(" Запуск тестов парсеров...")
|
||||
print("=" * 50)
|
||||
|
||||
# Переходим в директорию проекта
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Запускаем pytest
|
||||
cmd = [sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
print(" Все тесты прошли успешно!")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(" Некоторые тесты не прошли:")
|
||||
print(e.stdout)
|
||||
print(e.stderr)
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,100 +0,0 @@
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
from minio import Minio
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
# Конфигурация страницы
|
||||
st.set_page_config(
|
||||
page_title="Сводка данных",
|
||||
page_icon="📊",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
# Заголовок приложения
|
||||
st.title("📊 Анализ данных сводки")
|
||||
st.markdown("---")
|
||||
|
||||
# Инициализация MinIO клиента
|
||||
@st.cache_resource
|
||||
def init_minio_client():
|
||||
try:
|
||||
client = Minio(
|
||||
os.getenv("MINIO_ENDPOINT", "localhost:9000"),
|
||||
access_key=os.getenv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||
secret_key=os.getenv("MINIO_SECRET_KEY", "minioadmin"),
|
||||
secure=os.getenv("MINIO_SECURE", "false").lower() == "true"
|
||||
)
|
||||
return client
|
||||
except Exception as e:
|
||||
st.error(f"Ошибка подключения к MinIO: {e}")
|
||||
return None
|
||||
|
||||
# Боковая панель
|
||||
with st.sidebar:
|
||||
st.header("⚙️ Настройки")
|
||||
|
||||
# Выбор типа данных
|
||||
data_type = st.selectbox(
|
||||
"Тип данных",
|
||||
["Мониторинг топлива", "Сводка ПМ", "Сводка ЦА"]
|
||||
)
|
||||
|
||||
# Выбор периода
|
||||
period = st.date_input(
|
||||
"Период",
|
||||
value=pd.Timestamp.now().date()
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
st.markdown("### 📈 Статистика")
|
||||
st.info("Выберите тип данных для анализа")
|
||||
|
||||
# Основной контент
|
||||
col1, col2 = st.columns([2, 1])
|
||||
|
||||
with col1:
|
||||
st.subheader(f"📋 {data_type}")
|
||||
|
||||
if data_type == "Мониторинг топлива":
|
||||
st.info("Анализ данных мониторинга топлива")
|
||||
# Здесь будет логика для работы с данными мониторинга топлива
|
||||
|
||||
elif data_type == "Сводка ПМ":
|
||||
st.info("Анализ данных сводки ПМ")
|
||||
# Здесь будет логика для работы с данными сводки ПМ
|
||||
|
||||
elif data_type == "Сводка ЦА":
|
||||
st.info("Анализ данных сводки ЦА")
|
||||
# Здесь будет логика для работы с данными сводки ЦА
|
||||
|
||||
with col2:
|
||||
st.subheader("📊 Быстрая статистика")
|
||||
st.metric("Всего записей", "0")
|
||||
st.metric("Активных", "0")
|
||||
st.metric("Ошибок", "0")
|
||||
|
||||
# Нижняя панель
|
||||
st.markdown("---")
|
||||
st.subheader("🔍 Детальный анализ")
|
||||
|
||||
# Заглушка для графиков
|
||||
placeholder = st.empty()
|
||||
with placeholder.container():
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.write("📈 График 1")
|
||||
# Здесь будет график
|
||||
|
||||
with col2:
|
||||
st.write("📊 График 2")
|
||||
# Здесь будет график
|
||||
|
||||
# Футер
|
||||
st.markdown("---")
|
||||
st.markdown("**Разработано для анализа данных сводки** | v1.0.0")
|
||||
87
streamlit_app/api_client.py
Normal file
87
streamlit_app/api_client.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Модуль для работы с API
|
||||
"""
|
||||
import requests
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from config import API_BASE_URL, API_PUBLIC_URL
|
||||
|
||||
|
||||
def check_api_health() -> bool:
|
||||
"""Проверка доступности API"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/", timeout=5)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def get_available_parsers() -> List[str]:
|
||||
"""Получение списка доступных парсеров"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/parsers")
|
||||
if response.status_code == 200:
|
||||
return response.json()["parsers"]
|
||||
return []
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def get_server_info() -> Dict[str, Any]:
|
||||
"""Получение информации о сервере"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/server-info")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return {}
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
def get_system_ogs() -> Dict[str, Any]:
|
||||
"""Получение системного списка ОГ из pconfig"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/system/ogs")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return {"single_ogs": [], "og_ids": {}}
|
||||
except:
|
||||
return {"single_ogs": [], "og_ids": {}}
|
||||
|
||||
|
||||
def upload_file_to_api(endpoint: str, file_data: bytes, filename: str) -> Tuple[Dict[str, Any], int]:
|
||||
"""Загрузка файла на API"""
|
||||
try:
|
||||
# Определяем правильное имя поля в зависимости от эндпоинта
|
||||
if "zip" in endpoint:
|
||||
files = {"zip_file": (filename, file_data, "application/zip")}
|
||||
else:
|
||||
files = {"file": (filename, file_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
|
||||
response = requests.post(f"{API_BASE_URL}{endpoint}", files=files)
|
||||
return response.json(), response.status_code
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
def make_api_request(endpoint: str, data: Dict[str, Any]) -> Tuple[Dict[str, Any], int]:
|
||||
"""Выполнение API запроса"""
|
||||
try:
|
||||
response = requests.post(f"{API_BASE_URL}{endpoint}", json=data)
|
||||
return response.json(), response.status_code
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
def get_available_ogs(parser_name: str) -> List[str]:
|
||||
"""Получение доступных ОГ для парсера"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/parsers/{parser_name}/available_ogs")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("available_ogs", [])
|
||||
else:
|
||||
print(f"⚠️ Ошибка получения ОГ: {response.status_code}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка при запросе ОГ: {e}")
|
||||
return []
|
||||
160
streamlit_app/async_upload_page.py
Normal file
160
streamlit_app/async_upload_page.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Страница асинхронной загрузки файлов
|
||||
"""
|
||||
import streamlit as st
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
from api_client import upload_file_to_api
|
||||
from config import PARSER_TABS
|
||||
|
||||
# Глобальное хранилище задач (в реальном приложении лучше использовать Redis или БД)
|
||||
TASKS_STORAGE = {}
|
||||
|
||||
|
||||
def upload_file_async_background(endpoint, file_data, filename, task_id):
|
||||
"""Асинхронная загрузка файла в фоновом режиме"""
|
||||
global TASKS_STORAGE
|
||||
|
||||
try:
|
||||
# Обновляем статус на "running"
|
||||
TASKS_STORAGE[task_id] = {
|
||||
'status': 'running',
|
||||
'filename': filename,
|
||||
'endpoint': endpoint,
|
||||
'started_at': time.time(),
|
||||
'progress': 0
|
||||
}
|
||||
|
||||
# Имитируем асинхронную работу
|
||||
time.sleep(1) # Небольшая задержка для демонстрации
|
||||
|
||||
# Выполняем загрузку
|
||||
result, status = upload_file_to_api(endpoint, file_data, filename)
|
||||
|
||||
# Сохраняем результат в глобальном хранилище
|
||||
TASKS_STORAGE[task_id] = {
|
||||
'status': 'completed' if status == 200 else 'failed',
|
||||
'result': result,
|
||||
'status_code': status,
|
||||
'filename': filename,
|
||||
'endpoint': endpoint,
|
||||
'started_at': TASKS_STORAGE.get(task_id, {}).get('started_at', time.time()),
|
||||
'completed_at': time.time(),
|
||||
'progress': 100
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Сохраняем ошибку
|
||||
TASKS_STORAGE[task_id] = {
|
||||
'status': 'failed',
|
||||
'error': str(e),
|
||||
'filename': filename,
|
||||
'endpoint': endpoint,
|
||||
'started_at': TASKS_STORAGE.get(task_id, {}).get('started_at', time.time()),
|
||||
'completed_at': time.time(),
|
||||
'progress': 0
|
||||
}
|
||||
|
||||
|
||||
def render_async_upload_page():
|
||||
"""Рендер страницы асинхронной загрузки"""
|
||||
st.title("🚀 Асинхронная загрузка файлов")
|
||||
st.markdown("---")
|
||||
|
||||
st.info("""
|
||||
**Асинхронная загрузка** позволяет загружать файлы без блокировки интерфейса.
|
||||
После загрузки файл будет обработан в фоновом режиме, а вы сможете отслеживать прогресс на странице "Управление задачами".
|
||||
""")
|
||||
|
||||
# Выбор парсера
|
||||
st.subheader("📋 Выбор парсера")
|
||||
|
||||
# Создаем словарь парсеров с их асинхронными эндпоинтами
|
||||
parser_endpoints = {
|
||||
"Сводки ПМ": "/async/svodka_pm/upload-zip",
|
||||
"Сводки СА": "/async/svodka_ca/upload",
|
||||
"Мониторинг топлива": "/async/monitoring_fuel/upload-zip",
|
||||
"Ремонт СА": "/svodka_repair_ca/upload", # Пока синхронный
|
||||
"Статусы ремонта СА": "/statuses_repair_ca/upload", # Пока синхронный
|
||||
"Мониторинг ТЭР": "/monitoring_tar/upload", # Пока синхронный
|
||||
"Операционные справки": "/oper_spravka_tech_pos/upload" # Пока синхронный
|
||||
}
|
||||
|
||||
selected_parser = st.selectbox(
|
||||
"Выберите тип парсера для загрузки:",
|
||||
list(parser_endpoints.keys()),
|
||||
key="async_parser_select"
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Загрузка файла
|
||||
st.subheader("📤 Загрузка файла")
|
||||
|
||||
uploaded_file = st.file_uploader(
|
||||
f"Выберите ZIP архив для парсера '{selected_parser}'",
|
||||
type=['zip'],
|
||||
key="async_file_upload"
|
||||
)
|
||||
|
||||
if uploaded_file is not None:
|
||||
st.success(f"✅ Файл выбран: {uploaded_file.name}")
|
||||
st.info(f"📊 Размер файла: {uploaded_file.size / 1024 / 1024:.2f} MB")
|
||||
|
||||
if st.button("🚀 Загрузить асинхронно", key="async_upload_btn", use_container_width=True):
|
||||
# Создаем уникальный ID задачи
|
||||
task_id = f"task_{int(time.time())}_{uploaded_file.name}"
|
||||
|
||||
# Показываем сообщение о создании задачи
|
||||
st.success("✅ Задача загрузки создана!")
|
||||
st.info(f"ID задачи: `{task_id}`")
|
||||
st.info("📋 Перейдите на страницу 'Управление задачами' для отслеживания прогресса")
|
||||
|
||||
# Запускаем загрузку в фоновом потоке
|
||||
endpoint = parser_endpoints[selected_parser]
|
||||
file_data = uploaded_file.read()
|
||||
|
||||
# Создаем поток для асинхронной загрузки
|
||||
thread = threading.Thread(
|
||||
target=upload_file_async_background,
|
||||
args=(endpoint, file_data, uploaded_file.name, task_id)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Автоматически переключаемся на страницу задач
|
||||
st.session_state.sidebar_tasks_clicked = True
|
||||
st.rerun()
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Информация о поддерживаемых форматах
|
||||
with st.expander("ℹ️ Поддерживаемые форматы файлов"):
|
||||
st.markdown("""
|
||||
**Поддерживаемые форматы:**
|
||||
- 📦 ZIP архивы с Excel файлами
|
||||
- 📊 Excel файлы (.xlsx, .xls)
|
||||
- 📋 CSV файлы (для некоторых парсеров)
|
||||
|
||||
**Ограничения:**
|
||||
- Максимальный размер файла: 100 MB
|
||||
- Количество файлов в архиве: до 50
|
||||
- Поддерживаемые кодировки: UTF-8, Windows-1251
|
||||
""")
|
||||
|
||||
# Статистика загрузок
|
||||
st.subheader("📈 Статистика загрузок")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
st.metric("Всего загружено", "0", "0")
|
||||
|
||||
with col2:
|
||||
st.metric("В обработке", "0", "0")
|
||||
|
||||
with col3:
|
||||
st.metric("Завершено", "0", "0")
|
||||
58
streamlit_app/config.py
Normal file
58
streamlit_app/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Конфигурация приложения
|
||||
"""
|
||||
import streamlit as st
|
||||
import os
|
||||
|
||||
# Конфигурация API
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://fastapi:8000") # Внутренний адрес для Docker
|
||||
API_PUBLIC_URL = os.getenv("API_PUBLIC_URL", "http://localhost:8000") # Внешний адрес для пользователя
|
||||
|
||||
# Конфигурация страницы
|
||||
def setup_page_config():
|
||||
"""Настройка конфигурации страницы Streamlit"""
|
||||
st.set_page_config(
|
||||
page_title="NIN Excel Parsers API Demo",
|
||||
page_icon="📊",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
# Константы для парсеров
|
||||
PARSER_TABS = [
|
||||
"📊 Сводки ПМ",
|
||||
"🏭 Сводки СА",
|
||||
"⛽ Мониторинг топлива",
|
||||
"🔧 Ремонт СА",
|
||||
"📋 Статусы ремонта СА",
|
||||
"⚡ Мониторинг ТЭР",
|
||||
"🏭 Операционные справки"
|
||||
]
|
||||
|
||||
# Константы для ОГ
|
||||
DEFAULT_OGS = [
|
||||
"SNPZ", "KNPZ", "ANHK", "AchNPZ", "UNPZ", "UNH", "NOV",
|
||||
"NovKuybNPZ", "KuybNPZ", "CyzNPZ", "TuapsNPZ", "RNPK",
|
||||
"NVNPO", "KLNPZ", "PurNP", "YANOS"
|
||||
]
|
||||
|
||||
# Константы для кодов строк ПМ
|
||||
PM_CODES = [78, 79, 394, 395, 396, 397, 81, 82, 83, 84]
|
||||
|
||||
# Константы для столбцов ПМ
|
||||
PM_COLUMNS = ["БП", "ПП", "СЭБ", "Факт", "План"]
|
||||
|
||||
# Константы для режимов СА
|
||||
CA_MODES = ["plan", "fact", "normativ"]
|
||||
|
||||
# Константы для таблиц СА
|
||||
CA_TABLES = ["ТиП", "Топливо", "Потери"]
|
||||
|
||||
# Константы для столбцов мониторинга топлива
|
||||
FUEL_COLUMNS = ["normativ", "total", "total_1"]
|
||||
|
||||
# Константы для типов ремонта
|
||||
REPAIR_TYPES = ["КР", "КП", "ТР"]
|
||||
|
||||
# Константы для режимов мониторинга ТЭР
|
||||
TAR_MODES = ["all", "total", "last_day"]
|
||||
3
streamlit_app/parsers_ui/__init__.py
Normal file
3
streamlit_app/parsers_ui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
UI модули для парсеров
|
||||
"""
|
||||
162
streamlit_app/parsers_ui/monitoring_fuel_ui.py
Normal file
162
streamlit_app/parsers_ui/monitoring_fuel_ui.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
UI модуль для мониторинга топлива
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from api_client import upload_file_to_api, make_api_request
|
||||
from config import FUEL_COLUMNS
|
||||
|
||||
|
||||
def render_monitoring_fuel_tab():
|
||||
"""Рендер вкладки мониторинга топлива"""
|
||||
st.header("⛽ Мониторинг топлива - Полный функционал")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_fuel = st.file_uploader(
|
||||
"Выберите ZIP архив с мониторингом топлива",
|
||||
type=['zip'],
|
||||
key="fuel_upload"
|
||||
)
|
||||
|
||||
if uploaded_fuel is not None:
|
||||
if st.button("📤 Загрузить мониторинг топлива", key="upload_fuel_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
result, status = upload_file_to_api(
|
||||
"/monitoring_fuel/upload-zip",
|
||||
uploaded_fuel.read(),
|
||||
uploaded_fuel.name
|
||||
)
|
||||
|
||||
if status == 200:
|
||||
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Агрегация по колонкам")
|
||||
|
||||
columns_fuel = st.multiselect(
|
||||
"Выберите столбцы",
|
||||
FUEL_COLUMNS,
|
||||
default=["normativ", "total"],
|
||||
key="fuel_columns"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить агрегированные данные", key="fuel_total_btn"):
|
||||
if columns_fuel:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"columns": columns_fuel
|
||||
}
|
||||
|
||||
result, status = make_api_request("/monitoring_fuel/get_total_by_columns", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите столбцы")
|
||||
|
||||
with col2:
|
||||
st.subheader("Данные за месяц")
|
||||
|
||||
month = st.selectbox(
|
||||
"Выберите месяц",
|
||||
[f"{i:02d}" for i in range(1, 13)],
|
||||
key="fuel_month"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"):
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"month": month
|
||||
}
|
||||
|
||||
result, status = make_api_request("/monitoring_fuel/get_month_by_code", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Новая секция для временных рядов
|
||||
st.subheader("📈 Временные ряды")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Временные ряды по колонкам")
|
||||
|
||||
# Выбор колонок для временного ряда
|
||||
series_columns = st.multiselect(
|
||||
"Выберите столбцы для временного ряда",
|
||||
FUEL_COLUMNS,
|
||||
default=["total", "normativ"],
|
||||
key="fuel_series_columns"
|
||||
)
|
||||
|
||||
if st.button("📊 Получить временные ряды", key="fuel_series_btn"):
|
||||
if series_columns:
|
||||
with st.spinner("Получаю временные ряды..."):
|
||||
data = {
|
||||
"columns": series_columns
|
||||
}
|
||||
|
||||
result, status = make_api_request("/monitoring_fuel/get_series_by_id_and_columns", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Временные ряды получены")
|
||||
|
||||
# Отображаем данные
|
||||
if result.get('data'):
|
||||
series_data = result['data']
|
||||
|
||||
# Показываем количество найденных ID
|
||||
st.info(f"📊 Найдено {len(series_data)} объектов")
|
||||
|
||||
# Показываем JSON данные
|
||||
st.json(result)
|
||||
else:
|
||||
st.warning("⚠️ Данные не найдены")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите столбцы")
|
||||
|
||||
with col2:
|
||||
st.subheader("ℹ️ Справка")
|
||||
st.info("""
|
||||
**Временные ряды** показывают изменение значений по месяцам для всех объектов.
|
||||
|
||||
**Формат данных:**
|
||||
- Каждый ID объекта содержит массивы значений по месяцам
|
||||
- Массивы упорядочены по месяцам (01, 02, 03, ..., 12)
|
||||
- Отсутствующие месяцы пропускаются
|
||||
|
||||
**Доступные колонки:**
|
||||
- `total` - общее потребление
|
||||
- `normativ` - нормативное потребление
|
||||
- И другие колонки из загруженных данных
|
||||
|
||||
**Пример результата:**
|
||||
```
|
||||
SNPZ.VISB: {
|
||||
"total": [23.86, 26.51, 19.66, ...],
|
||||
"normativ": [19.46, 19.45, 18.57, ...]
|
||||
}
|
||||
```
|
||||
""")
|
||||
108
streamlit_app/parsers_ui/monitoring_tar_ui.py
Normal file
108
streamlit_app/parsers_ui/monitoring_tar_ui.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
UI модуль для мониторинга ТЭР
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
import json
|
||||
from api_client import upload_file_to_api, make_api_request
|
||||
from config import TAR_MODES
|
||||
|
||||
|
||||
def render_monitoring_tar_tab():
|
||||
"""Рендер вкладки мониторинга ТЭР"""
|
||||
st.header("⚡ Мониторинг ТЭР (Топливно-энергетических ресурсов)")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_file = st.file_uploader(
|
||||
"Выберите ZIP архив с файлами мониторинга ТЭР",
|
||||
type=['zip'],
|
||||
key="monitoring_tar_upload"
|
||||
)
|
||||
|
||||
if uploaded_file is not None:
|
||||
if st.button("📤 Загрузить файл", key="monitoring_tar_upload_btn"):
|
||||
with st.spinner("Загружаем файл..."):
|
||||
file_data = uploaded_file.read()
|
||||
result, status_code = upload_file_to_api("/monitoring_tar/upload", file_data, uploaded_file.name)
|
||||
|
||||
if status_code == 200:
|
||||
st.success("✅ Файл успешно загружен!")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка загрузки: {result}")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("📊 Получение данных")
|
||||
|
||||
# Выбор формата отображения
|
||||
display_format = st.radio(
|
||||
"Формат отображения:",
|
||||
["JSON", "Таблица"],
|
||||
key="monitoring_tar_display_format",
|
||||
horizontal=True
|
||||
)
|
||||
|
||||
# Выбор режима данных
|
||||
mode = st.selectbox(
|
||||
"Выберите режим данных:",
|
||||
TAR_MODES,
|
||||
help="total - строки 'Всего' (агрегированные данные), last_day - последние строки данных, all - все данные",
|
||||
key="monitoring_tar_mode"
|
||||
)
|
||||
|
||||
if st.button("📊 Получить данные", key="monitoring_tar_get_data_btn"):
|
||||
with st.spinner("Получаем данные..."):
|
||||
# Выбираем эндпоинт в зависимости от режима
|
||||
if mode == "all":
|
||||
# Используем полный эндпоинт
|
||||
result, status_code = make_api_request("/monitoring_tar/get_full_data", {})
|
||||
else:
|
||||
# Используем фильтрованный эндпоинт
|
||||
request_data = {"mode": mode}
|
||||
result, status_code = make_api_request("/monitoring_tar/get_data", request_data)
|
||||
|
||||
if status_code == 200 and result.get("success"):
|
||||
st.success("✅ Данные успешно получены!")
|
||||
|
||||
# Показываем данные
|
||||
data = result.get("data", {}).get("value", {})
|
||||
if data:
|
||||
st.subheader("📋 Результат:")
|
||||
|
||||
# Парсим данные, если они пришли как строка
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
st.write("✅ JSON успешно распарсен")
|
||||
except json.JSONDecodeError as e:
|
||||
st.error(f"❌ Ошибка при парсинге JSON данных: {e}")
|
||||
st.write("Сырые данные:", data)
|
||||
return
|
||||
|
||||
if display_format == "JSON":
|
||||
# Отображаем как JSON
|
||||
st.json(data)
|
||||
else:
|
||||
# Отображаем как таблицы
|
||||
if isinstance(data, dict):
|
||||
# Показываем данные по установкам
|
||||
for installation_id, installation_data in data.items():
|
||||
with st.expander(f"🏭 {installation_id}"):
|
||||
if isinstance(installation_data, dict):
|
||||
# Показываем структуру данных
|
||||
for data_type, type_data in installation_data.items():
|
||||
st.write(f"**{data_type}:**")
|
||||
if isinstance(type_data, list) and type_data:
|
||||
df = pd.DataFrame(type_data)
|
||||
st.dataframe(df)
|
||||
else:
|
||||
st.write("Нет данных")
|
||||
else:
|
||||
st.write("Нет данных")
|
||||
else:
|
||||
st.json(data)
|
||||
else:
|
||||
st.info("📋 Нет данных для отображения")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
84
streamlit_app/parsers_ui/oper_spravka_tech_pos_ui.py
Normal file
84
streamlit_app/parsers_ui/oper_spravka_tech_pos_ui.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
UI модуль для операционных справок технологических позиций
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from api_client import upload_file_to_api, make_api_request, get_available_ogs
|
||||
|
||||
|
||||
def render_oper_spravka_tech_pos_tab():
|
||||
"""Рендер вкладки операционных справок технологических позиций"""
|
||||
st.header("🏭 Операционные справки технологических позиций")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
|
||||
uploaded_file = st.file_uploader(
|
||||
"Выберите ZIP архив с файлами операционных справок",
|
||||
type=['zip'],
|
||||
key="oper_spravka_tech_pos_upload"
|
||||
)
|
||||
|
||||
if uploaded_file is not None:
|
||||
if st.button("📤 Загрузить файл", key="oper_spravka_tech_pos_upload_btn"):
|
||||
with st.spinner("Загружаем файл..."):
|
||||
file_data = uploaded_file.read()
|
||||
result, status_code = upload_file_to_api("/oper_spravka_tech_pos/upload", file_data, uploaded_file.name)
|
||||
|
||||
if status_code == 200:
|
||||
st.success("✅ Файл успешно загружен!")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка загрузки: {result}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("📊 Получение данных")
|
||||
|
||||
# Выбор формата отображения
|
||||
display_format = st.radio(
|
||||
"Формат отображения:",
|
||||
["JSON", "Таблица"],
|
||||
key="oper_spravka_tech_pos_display_format",
|
||||
horizontal=True
|
||||
)
|
||||
|
||||
# Получаем доступные ОГ динамически
|
||||
available_ogs = get_available_ogs("oper_spravka_tech_pos")
|
||||
|
||||
# Выбор ОГ
|
||||
og_id = st.selectbox(
|
||||
"Выберите ОГ:",
|
||||
available_ogs if available_ogs else ["SNPZ", "KNPZ", "ANHK", "BASH", "UNH", "NOV"],
|
||||
key="oper_spravka_tech_pos_og_id"
|
||||
)
|
||||
|
||||
if st.button("📊 Получить данные", key="oper_spravka_tech_pos_get_data_btn"):
|
||||
with st.spinner("Получаем данные..."):
|
||||
request_data = {"id": og_id}
|
||||
result, status_code = make_api_request("/oper_spravka_tech_pos/get_data", request_data)
|
||||
|
||||
if status_code == 200 and result.get("success"):
|
||||
st.success("✅ Данные успешно получены!")
|
||||
|
||||
# Показываем данные
|
||||
data = result.get("data", [])
|
||||
|
||||
if data and len(data) > 0:
|
||||
st.subheader("📋 Результат:")
|
||||
|
||||
if display_format == "JSON":
|
||||
# Отображаем как JSON
|
||||
st.json(data)
|
||||
else:
|
||||
# Отображаем как таблицу
|
||||
if isinstance(data, list) and data:
|
||||
df = pd.DataFrame(data)
|
||||
st.dataframe(df, use_container_width=True)
|
||||
else:
|
||||
st.write("Нет данных")
|
||||
else:
|
||||
st.info("📋 Нет данных для отображения")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
147
streamlit_app/parsers_ui/statuses_repair_ca_ui.py
Normal file
147
streamlit_app/parsers_ui/statuses_repair_ca_ui.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
UI модуль для статусов ремонта СА
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from api_client import upload_file_to_api, make_api_request, get_available_ogs, get_system_ogs
|
||||
|
||||
|
||||
def render_statuses_repair_ca_tab():
|
||||
"""Рендер вкладки статусов ремонта СА"""
|
||||
st.header("📋 Статусы ремонта СА")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_file = st.file_uploader(
|
||||
"Выберите файл статусов ремонта СА",
|
||||
type=['xlsx', 'xlsm', 'xls', 'zip'],
|
||||
key="statuses_repair_ca_upload"
|
||||
)
|
||||
|
||||
if uploaded_file is not None:
|
||||
if st.button("📤 Загрузить файл", key="statuses_repair_ca_upload_btn"):
|
||||
with st.spinner("Загружаем файл..."):
|
||||
file_data = uploaded_file.read()
|
||||
result, status_code = upload_file_to_api("/statuses_repair_ca/upload", file_data, uploaded_file.name)
|
||||
|
||||
if status_code == 200:
|
||||
st.success("✅ Файл успешно загружен!")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка загрузки: {result}")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("📊 Получение данных")
|
||||
|
||||
# Получаем доступные ОГ из системного API
|
||||
system_ogs = get_system_ogs()
|
||||
available_ogs = system_ogs.get("single_ogs", [])
|
||||
|
||||
# Фильтр по ОГ
|
||||
og_ids = st.multiselect(
|
||||
"Выберите ОГ (оставьте пустым для всех)",
|
||||
available_ogs if available_ogs else get_available_ogs(), # fallback
|
||||
key="statuses_repair_ca_og_ids"
|
||||
)
|
||||
|
||||
# Предустановленные ключи для извлечения
|
||||
st.subheader("🔑 Ключи для извлечения данных")
|
||||
|
||||
# Основные ключи
|
||||
include_basic_keys = st.checkbox("Основные данные", value=True, key="statuses_basic_keys")
|
||||
include_readiness_keys = st.checkbox("Готовность к КР", value=True, key="statuses_readiness_keys")
|
||||
include_contract_keys = st.checkbox("Заключение договоров", value=True, key="statuses_contract_keys")
|
||||
include_supply_keys = st.checkbox("Поставка МТР", value=True, key="statuses_supply_keys")
|
||||
|
||||
# Формируем ключи на основе выбора
|
||||
keys = []
|
||||
if include_basic_keys:
|
||||
keys.append(["Дата начала ремонта"])
|
||||
keys.append(["Отставание / опережение подготовки к КР", "Отставание / опережение"])
|
||||
keys.append(["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"])
|
||||
|
||||
if include_readiness_keys:
|
||||
keys.append(["Готовность к КР", "Факт"])
|
||||
|
||||
if include_contract_keys:
|
||||
keys.append(["Заключение договоров на СМР", "Договор", "%"])
|
||||
|
||||
if include_supply_keys:
|
||||
keys.append(["Поставка МТР", "На складе, позиций", "%"])
|
||||
|
||||
# Кнопка получения данных
|
||||
if st.button("📊 Получить данные", key="statuses_repair_ca_get_data_btn"):
|
||||
if not keys:
|
||||
st.warning("⚠️ Выберите хотя бы одну группу ключей для извлечения")
|
||||
else:
|
||||
with st.spinner("Получаем данные..."):
|
||||
request_data = {
|
||||
"ids": og_ids if og_ids else None,
|
||||
"keys": keys
|
||||
}
|
||||
|
||||
result, status_code = make_api_request("/statuses_repair_ca/get_data", request_data)
|
||||
|
||||
if status_code == 200 and result.get("success"):
|
||||
st.success("✅ Данные успешно получены!")
|
||||
|
||||
data = result.get("data", {}).get("value", [])
|
||||
if data:
|
||||
# Отображаем данные в виде таблицы
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
# Преобразуем в DataFrame для лучшего отображения
|
||||
df_data = []
|
||||
for item in data:
|
||||
row = {
|
||||
"ID": item.get("id", ""),
|
||||
"Название": item.get("name", ""),
|
||||
}
|
||||
|
||||
# Добавляем основные поля
|
||||
if "Дата начала ремонта" in item:
|
||||
row["Дата начала ремонта"] = item["Дата начала ремонта"]
|
||||
|
||||
# Добавляем готовность к КР
|
||||
if "Готовность к КР" in item:
|
||||
readiness = item["Готовность к КР"]
|
||||
if isinstance(readiness, dict) and "Факт" in readiness:
|
||||
row["Готовность к КР (Факт)"] = readiness["Факт"]
|
||||
|
||||
# Добавляем отставание/опережение
|
||||
if "Отставание / опережение подготовки к КР" in item:
|
||||
delay = item["Отставание / опережение подготовки к КР"]
|
||||
if isinstance(delay, dict):
|
||||
if "Отставание / опережение" in delay:
|
||||
row["Отставание/опережение"] = delay["Отставание / опережение"]
|
||||
if "Динамика за прошедшую неделю" in delay:
|
||||
row["Динамика за неделю"] = delay["Динамика за прошедшую неделю"]
|
||||
|
||||
# Добавляем договоры
|
||||
if "Заключение договоров на СМР" in item:
|
||||
contracts = item["Заключение договоров на СМР"]
|
||||
if isinstance(contracts, dict) and "Договор" in contracts:
|
||||
contract = contracts["Договор"]
|
||||
if isinstance(contract, dict) and "%" in contract:
|
||||
row["Договоры (%)"] = contract["%"]
|
||||
|
||||
# Добавляем поставки МТР
|
||||
if "Поставка МТР" in item:
|
||||
supply = item["Поставка МТР"]
|
||||
if isinstance(supply, dict) and "На складе, позиций" in supply:
|
||||
warehouse = supply["На складе, позиций"]
|
||||
if isinstance(warehouse, dict) and "%" in warehouse:
|
||||
row["МТР на складе (%)"] = warehouse["%"]
|
||||
|
||||
df_data.append(row)
|
||||
|
||||
if df_data:
|
||||
df = pd.DataFrame(df_data)
|
||||
st.dataframe(df, use_container_width=True)
|
||||
else:
|
||||
st.info("📋 Нет данных для отображения")
|
||||
else:
|
||||
st.json(result)
|
||||
else:
|
||||
st.info("📋 Нет данных для отображения")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
80
streamlit_app/parsers_ui/svodka_ca_ui.py
Normal file
80
streamlit_app/parsers_ui/svodka_ca_ui.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
UI модуль для парсера сводок СА
|
||||
"""
|
||||
import streamlit as st
|
||||
import requests
|
||||
from api_client import make_api_request, API_BASE_URL
|
||||
from config import CA_MODES, CA_TABLES
|
||||
|
||||
|
||||
def render_svodka_ca_tab():
|
||||
"""Рендер вкладки сводок СА"""
|
||||
st.header("🏭 Сводки СА - Полный функционал")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_ca = st.file_uploader(
|
||||
"Выберите Excel файл сводки СА",
|
||||
type=['xlsx', 'xlsm', 'xls'],
|
||||
key="ca_upload"
|
||||
)
|
||||
|
||||
if uploaded_ca is not None:
|
||||
if st.button("📤 Загрузить сводку СА", key="upload_ca_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
try:
|
||||
files = {"file": (uploaded_ca.name, uploaded_ca.read(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
response = requests.post(f"{API_BASE_URL}/svodka_ca/upload", files=files)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
except Exception as e:
|
||||
st.error(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Параметры запроса")
|
||||
|
||||
modes = st.multiselect(
|
||||
"Выберите режимы",
|
||||
CA_MODES,
|
||||
default=["plan", "fact"],
|
||||
key="ca_modes"
|
||||
)
|
||||
|
||||
tables = st.multiselect(
|
||||
"Выберите таблицы",
|
||||
CA_TABLES,
|
||||
default=["ТиП", "Топливо"],
|
||||
key="ca_tables"
|
||||
)
|
||||
|
||||
with col2:
|
||||
st.subheader("Результат")
|
||||
if st.button("🔍 Получить данные СА", key="ca_btn"):
|
||||
if modes and tables:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"modes": modes,
|
||||
"tables": tables
|
||||
}
|
||||
|
||||
result, status = make_api_request("/svodka_ca/get_data", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите режимы и таблицы")
|
||||
118
streamlit_app/parsers_ui/svodka_pm_ui.py
Normal file
118
streamlit_app/parsers_ui/svodka_pm_ui.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
UI модуль для парсера сводок ПМ
|
||||
"""
|
||||
import streamlit as st
|
||||
from api_client import upload_file_to_api, make_api_request
|
||||
from config import PM_CODES, PM_COLUMNS, DEFAULT_OGS
|
||||
|
||||
|
||||
def render_svodka_pm_tab():
|
||||
"""Рендер вкладки сводок ПМ"""
|
||||
st.header("📊 Сводки ПМ - Полный функционал")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_pm = st.file_uploader(
|
||||
"Выберите ZIP архив со сводками ПМ",
|
||||
type=['zip'],
|
||||
key="pm_upload"
|
||||
)
|
||||
|
||||
if uploaded_pm is not None:
|
||||
if st.button("📤 Загрузить сводки ПМ", key="upload_pm_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
result, status = upload_file_to_api(
|
||||
"/svodka_pm/upload-zip",
|
||||
uploaded_pm.read(),
|
||||
uploaded_pm.name
|
||||
)
|
||||
|
||||
if status == 200:
|
||||
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Данные по одному ОГ")
|
||||
|
||||
og_id = st.selectbox(
|
||||
"Выберите ОГ",
|
||||
DEFAULT_OGS,
|
||||
key="pm_single_og"
|
||||
)
|
||||
|
||||
codes = st.multiselect(
|
||||
"Выберите коды строк",
|
||||
PM_CODES,
|
||||
default=[78, 79],
|
||||
key="pm_single_codes"
|
||||
)
|
||||
|
||||
columns = st.multiselect(
|
||||
"Выберите столбцы",
|
||||
PM_COLUMNS,
|
||||
default=["БП", "ПП"],
|
||||
key="pm_single_columns"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить данные по ОГ", key="pm_single_btn"):
|
||||
if codes and columns:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"id": og_id,
|
||||
"codes": codes,
|
||||
"columns": columns
|
||||
}
|
||||
|
||||
result, status = make_api_request("/svodka_pm/get_single_og", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите коды и столбцы")
|
||||
|
||||
with col2:
|
||||
st.subheader("Данные по всем ОГ")
|
||||
|
||||
codes_total = st.multiselect(
|
||||
"Выберите коды строк",
|
||||
PM_CODES,
|
||||
default=[78, 79, 394, 395],
|
||||
key="pm_total_codes"
|
||||
)
|
||||
|
||||
columns_total = st.multiselect(
|
||||
"Выберите столбцы",
|
||||
PM_COLUMNS,
|
||||
default=["БП", "ПП", "СЭБ"],
|
||||
key="pm_total_columns"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить данные по всем ОГ", key="pm_total_btn"):
|
||||
if codes_total and columns_total:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"codes": codes_total,
|
||||
"columns": columns_total
|
||||
}
|
||||
|
||||
result, status = make_api_request("/svodka_pm/get_total_ogs", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите коды и столбцы")
|
||||
111
streamlit_app/parsers_ui/svodka_repair_ca_ui.py
Normal file
111
streamlit_app/parsers_ui/svodka_repair_ca_ui.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
UI модуль для ремонта СА
|
||||
"""
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from api_client import upload_file_to_api, make_api_request, get_system_ogs, get_available_ogs
|
||||
from config import REPAIR_TYPES
|
||||
|
||||
|
||||
def render_svodka_repair_ca_tab():
|
||||
"""Рендер вкладки ремонта СА"""
|
||||
st.header("🔧 Ремонт СА - Управление ремонтными работами")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
|
||||
uploaded_file = st.file_uploader(
|
||||
"Выберите Excel файл или ZIP архив с данными о ремонте СА",
|
||||
type=['xlsx', 'xlsm', 'xls', 'zip'],
|
||||
key="repair_ca_upload"
|
||||
)
|
||||
|
||||
if uploaded_file is not None:
|
||||
if st.button("📤 Загрузить файл", key="repair_ca_upload_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
file_data = uploaded_file.read()
|
||||
result, status = upload_file_to_api("/svodka_repair_ca/upload", file_data, uploaded_file.name)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Файл успешно загружен")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка загрузки: {result.get('message', 'Неизвестная ошибка')}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Фильтры")
|
||||
|
||||
# Получаем доступные ОГ из системного API
|
||||
system_ogs = get_system_ogs()
|
||||
available_ogs = system_ogs.get("single_ogs", [])
|
||||
|
||||
# Фильтр по ОГ
|
||||
og_ids = st.multiselect(
|
||||
"Выберите ОГ (оставьте пустым для всех)",
|
||||
available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback
|
||||
key="repair_ca_og_ids"
|
||||
)
|
||||
|
||||
# Фильтр по типам ремонта
|
||||
repair_types = st.multiselect(
|
||||
"Выберите типы ремонта (оставьте пустым для всех)",
|
||||
REPAIR_TYPES,
|
||||
key="repair_ca_types"
|
||||
)
|
||||
|
||||
# Включение плановых/фактических данных
|
||||
include_planned = st.checkbox("Включать плановые данные", value=True, key="repair_ca_planned")
|
||||
include_factual = st.checkbox("Включать фактические данные", value=True, key="repair_ca_factual")
|
||||
|
||||
with col2:
|
||||
st.subheader("Действия")
|
||||
|
||||
if st.button("🔍 Получить данные о ремонте", key="repair_ca_get_btn"):
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"include_planned": include_planned,
|
||||
"include_factual": include_factual
|
||||
}
|
||||
|
||||
# Добавляем фильтры только если они выбраны
|
||||
if og_ids:
|
||||
data["og_ids"] = og_ids
|
||||
if repair_types:
|
||||
data["repair_types"] = repair_types
|
||||
|
||||
result, status = make_api_request("/svodka_repair_ca/get_data", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
|
||||
# Отображаем данные в виде таблицы, если возможно
|
||||
if result.get("data") and isinstance(result["data"], list):
|
||||
df_data = []
|
||||
for item in result["data"]:
|
||||
df_data.append({
|
||||
"ID ОГ": item.get("id", ""),
|
||||
"Наименование": item.get("name", ""),
|
||||
"Тип ремонта": item.get("type", ""),
|
||||
"Дата начала": item.get("start_date", ""),
|
||||
"Дата окончания": item.get("end_date", ""),
|
||||
"План": item.get("plan", ""),
|
||||
"Факт": item.get("fact", ""),
|
||||
"Простой": item.get("downtime", "")
|
||||
})
|
||||
|
||||
if df_data:
|
||||
df = pd.DataFrame(df_data)
|
||||
st.dataframe(df, use_container_width=True)
|
||||
else:
|
||||
st.info("📋 Нет данных для отображения")
|
||||
else:
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
82
streamlit_app/sidebar.py
Normal file
82
streamlit_app/sidebar.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Модуль для сайдбара
|
||||
"""
|
||||
import streamlit as st
|
||||
from api_client import get_server_info, get_available_parsers
|
||||
from config import API_PUBLIC_URL
|
||||
|
||||
|
||||
def render_sidebar():
|
||||
"""Рендер боковой панели"""
|
||||
with st.sidebar:
|
||||
st.header("ℹ️ Информация1")
|
||||
|
||||
# Информация о сервере
|
||||
server_info = get_server_info()
|
||||
if server_info:
|
||||
st.subheader("Сервер")
|
||||
st.write(f"PID: {server_info.get('process_id', 'N/A')}")
|
||||
st.write(f"CPU ядер: {server_info.get('cpu_cores', 'N/A')}")
|
||||
|
||||
# Безопасное форматирование памяти
|
||||
memory_mb = server_info.get('memory_mb')
|
||||
if memory_mb is not None:
|
||||
st.write(f"Память: {memory_mb:.1f} MB")
|
||||
else:
|
||||
st.write("Память: N/A")
|
||||
|
||||
# Доступные парсеры
|
||||
parsers = get_available_parsers()
|
||||
if parsers:
|
||||
st.subheader("Доступные парсеры")
|
||||
for parser in parsers:
|
||||
st.write(f"• {parser}")
|
||||
|
||||
# Навигация по страницам
|
||||
st.markdown("---")
|
||||
st.subheader("🧭 Навигация")
|
||||
|
||||
# Определяем активную страницу
|
||||
active_page = st.session_state.get("active_page", 0)
|
||||
|
||||
# Кнопка для страницы синхронных парсеров
|
||||
if st.button("📊 Синхронные парсеры", key="sidebar_sync_btn", use_container_width=True, type="primary" if active_page == 0 else "secondary"):
|
||||
st.session_state.sidebar_sync_clicked = True
|
||||
st.rerun()
|
||||
|
||||
# Кнопка для страницы асинхронной загрузки
|
||||
if st.button("🚀 Асинхронная загрузка", key="sidebar_async_btn", use_container_width=True, type="primary" if active_page == 1 else "secondary"):
|
||||
st.session_state.sidebar_async_clicked = True
|
||||
st.rerun()
|
||||
|
||||
# Кнопка для страницы управления задачами
|
||||
if st.button("📋 Управление задачами", key="sidebar_tasks_btn", use_container_width=True, type="primary" if active_page == 2 else "secondary"):
|
||||
st.session_state.sidebar_tasks_clicked = True
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render_footer():
|
||||
"""Рендер футера"""
|
||||
st.markdown("---")
|
||||
st.markdown("### 📚 Документация API")
|
||||
st.markdown(f"Полная документация доступна по адресу: {API_PUBLIC_URL}/docs")
|
||||
|
||||
# Информация о проекте
|
||||
with st.expander("ℹ️ О проекте"):
|
||||
st.markdown("""
|
||||
**NIN Excel Parsers API** - это веб-сервис для парсинга и обработки Excel-файлов нефтеперерабатывающих заводов.
|
||||
|
||||
**Возможности:**
|
||||
- 📊 Парсинг сводок ПМ (план и факт)
|
||||
- 🏭 Парсинг сводок СА
|
||||
- ⛽ Мониторинг топлива
|
||||
- ⚡ Мониторинг ТЭР (Топливно-энергетические ресурсы)
|
||||
- 🔧 Управление ремонтными работами СА
|
||||
- 📋 Мониторинг статусов ремонта СА
|
||||
|
||||
**Технологии:**
|
||||
- FastAPI
|
||||
- Pandas
|
||||
- MinIO (S3-совместимое хранилище)
|
||||
- Streamlit (веб-интерфейс)
|
||||
""")
|
||||
@@ -1,397 +1,61 @@
|
||||
import streamlit as st
|
||||
import requests
|
||||
import json
|
||||
import pandas as pd
|
||||
import io
|
||||
import zipfile
|
||||
from typing import Dict, Any
|
||||
import os
|
||||
from config import setup_page_config, API_PUBLIC_URL
|
||||
from api_client import check_api_health
|
||||
from sidebar import render_sidebar, render_footer
|
||||
from sync_parsers_page import render_sync_parsers_page
|
||||
from async_upload_page import render_async_upload_page
|
||||
from tasks_page import render_tasks_page
|
||||
|
||||
# Конфигурация страницы
|
||||
st.set_page_config(
|
||||
page_title="NIN Excel Parsers API Demo",
|
||||
page_icon="📊",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
# Конфигурация API
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://fastapi:8000") # Внутренний адрес для Docker
|
||||
API_PUBLIC_URL = os.getenv("API_PUBLIC_URL", "http://localhost:8000") # Внешний адрес для пользователя
|
||||
|
||||
def check_api_health():
|
||||
"""Проверка доступности API"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/", timeout=5)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_available_parsers():
|
||||
"""Получение списка доступных парсеров"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/parsers")
|
||||
if response.status_code == 200:
|
||||
return response.json()["parsers"]
|
||||
return []
|
||||
except:
|
||||
return []
|
||||
|
||||
def get_server_info():
|
||||
"""Получение информации о сервере"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE_URL}/server-info")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return {}
|
||||
except:
|
||||
return {}
|
||||
|
||||
def upload_file_to_api(endpoint: str, file_data: bytes, filename: str):
|
||||
"""Загрузка файла на API"""
|
||||
try:
|
||||
files = {"zip_file": (filename, file_data, "application/zip")}
|
||||
response = requests.post(f"{API_BASE_URL}{endpoint}", files=files)
|
||||
return response.json(), response.status_code
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
def make_api_request(endpoint: str, data: Dict[str, Any]):
|
||||
"""Выполнение API запроса"""
|
||||
try:
|
||||
response = requests.post(f"{API_BASE_URL}{endpoint}", json=data)
|
||||
return response.json(), response.status_code
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
setup_page_config()
|
||||
|
||||
def main():
|
||||
st.title("🚀 NIN Excel Parsers API - Демонстрация")
|
||||
# Определяем активную страницу для заголовка
|
||||
active_page = st.session_state.get("active_page", 0)
|
||||
page_titles = {
|
||||
0: "Синхронные парсеры",
|
||||
1: "Асинхронная загрузка",
|
||||
2: "Управление задачами"
|
||||
}
|
||||
|
||||
st.title(f"🚀 NIN Excel Parsers API - {page_titles.get(active_page, 'Демонстрация')}")
|
||||
st.markdown("---")
|
||||
|
||||
# Проверка доступности API
|
||||
if not check_api_health():
|
||||
st.error(f"❌ API недоступен по адресу {API_BASE_URL}")
|
||||
st.error(f"❌ API недоступен по адресу {API_PUBLIC_URL}")
|
||||
st.info("Убедитесь, что FastAPI сервер запущен")
|
||||
return
|
||||
|
||||
st.success(f"✅ API доступен по адресу {API_PUBLIC_URL}")
|
||||
|
||||
# Боковая панель с информацией
|
||||
with st.sidebar:
|
||||
st.header("ℹ️ Информация")
|
||||
# Обрабатываем клики по кнопкам в сайдбаре ПЕРЕД рендером
|
||||
if st.session_state.get("sidebar_sync_clicked", False):
|
||||
st.session_state.sidebar_sync_clicked = False
|
||||
st.session_state.active_page = 0
|
||||
elif st.session_state.get("sidebar_async_clicked", False):
|
||||
st.session_state.sidebar_async_clicked = False
|
||||
st.session_state.active_page = 1
|
||||
elif st.session_state.get("sidebar_tasks_clicked", False):
|
||||
st.session_state.sidebar_tasks_clicked = False
|
||||
st.session_state.active_page = 2
|
||||
|
||||
# Информация о сервере
|
||||
server_info = get_server_info()
|
||||
if server_info:
|
||||
st.subheader("Сервер")
|
||||
st.write(f"PID: {server_info.get('process_id', 'N/A')}")
|
||||
st.write(f"CPU ядер: {server_info.get('cpu_cores', 'N/A')}")
|
||||
st.write(f"Память: {server_info.get('memory_mb', 'N/A'):.1f} MB")
|
||||
# Определяем активную страницу
|
||||
active_page = st.session_state.get("active_page", 0)
|
||||
|
||||
# Доступные парсеры
|
||||
parsers = get_available_parsers()
|
||||
if parsers:
|
||||
st.subheader("Доступные парсеры")
|
||||
for parser in parsers:
|
||||
st.write(f"• {parser}")
|
||||
# Боковая панель с информацией и навигацией
|
||||
render_sidebar()
|
||||
|
||||
# Основные вкладки - по одной на каждый парсер
|
||||
tab1, tab2, tab3 = st.tabs([
|
||||
"📊 Сводки ПМ",
|
||||
"🏭 Сводки СА",
|
||||
"⛽ Мониторинг топлива"
|
||||
])
|
||||
|
||||
# Вкладка 1: Сводки ПМ - полный функционал
|
||||
with tab1:
|
||||
st.header("📊 Сводки ПМ - Полный функционал")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_pm = st.file_uploader(
|
||||
"Выберите ZIP архив со сводками ПМ",
|
||||
type=['zip'],
|
||||
key="pm_upload"
|
||||
)
|
||||
|
||||
if uploaded_pm is not None:
|
||||
if st.button("📤 Загрузить сводки ПМ", key="upload_pm_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
result, status = upload_file_to_api(
|
||||
"/svodka_pm/upload-zip",
|
||||
uploaded_pm.read(),
|
||||
uploaded_pm.name
|
||||
)
|
||||
|
||||
if status == 200:
|
||||
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||
# Рендерим соответствующую страницу
|
||||
if active_page == 0:
|
||||
render_sync_parsers_page()
|
||||
elif active_page == 1:
|
||||
render_async_upload_page()
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Данные по одному ОГ")
|
||||
|
||||
og_id = st.selectbox(
|
||||
"Выберите ОГ",
|
||||
["SNPZ", "KNPZ", "ANHK", "AchNPZ", "UNPZ", "UNH", "NOV",
|
||||
"NovKuybNPZ", "KuybNPZ", "CyzNPZ", "TuapsNPZ", "RNPK",
|
||||
"NVNPO", "KLNPZ", "PurNP", "YANOS"],
|
||||
key="pm_single_og"
|
||||
)
|
||||
|
||||
codes = st.multiselect(
|
||||
"Выберите коды строк",
|
||||
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
|
||||
default=[78, 79],
|
||||
key="pm_single_codes"
|
||||
)
|
||||
|
||||
columns = st.multiselect(
|
||||
"Выберите столбцы",
|
||||
["БП", "ПП", "СЭБ", "Факт", "План"],
|
||||
default=["БП", "ПП"],
|
||||
key="pm_single_columns"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить данные по ОГ", key="pm_single_btn"):
|
||||
if codes and columns:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"id": og_id,
|
||||
"codes": codes,
|
||||
"columns": columns
|
||||
}
|
||||
|
||||
result, status = make_api_request("/svodka_pm/get_single_og", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите коды и столбцы")
|
||||
|
||||
with col2:
|
||||
st.subheader("Данные по всем ОГ")
|
||||
|
||||
codes_total = st.multiselect(
|
||||
"Выберите коды строк",
|
||||
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
|
||||
default=[78, 79, 394, 395],
|
||||
key="pm_total_codes"
|
||||
)
|
||||
|
||||
columns_total = st.multiselect(
|
||||
"Выберите столбцы",
|
||||
["БП", "ПП", "СЭБ", "Факт", "План"],
|
||||
default=["БП", "ПП", "СЭБ"],
|
||||
key="pm_total_columns"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить данные по всем ОГ", key="pm_total_btn"):
|
||||
if codes_total and columns_total:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"codes": codes_total,
|
||||
"columns": columns_total
|
||||
}
|
||||
|
||||
result, status = make_api_request("/svodka_pm/get_total_ogs", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите коды и столбцы")
|
||||
|
||||
# Вкладка 2: Сводки СА - полный функционал
|
||||
with tab2:
|
||||
st.header("🏭 Сводки СА - Полный функционал")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_ca = st.file_uploader(
|
||||
"Выберите Excel файл сводки СА",
|
||||
type=['xlsx', 'xlsm', 'xls'],
|
||||
key="ca_upload"
|
||||
)
|
||||
|
||||
if uploaded_ca is not None:
|
||||
if st.button("📤 Загрузить сводку СА", key="upload_ca_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
try:
|
||||
files = {"file": (uploaded_ca.name, uploaded_ca.read(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
response = requests.post(f"{API_BASE_URL}/svodka_ca/upload", files=files)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
except Exception as e:
|
||||
st.error(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Параметры запроса")
|
||||
|
||||
modes = st.multiselect(
|
||||
"Выберите режимы",
|
||||
["plan", "fact", "normativ"],
|
||||
default=["plan", "fact"],
|
||||
key="ca_modes"
|
||||
)
|
||||
|
||||
tables = st.multiselect(
|
||||
"Выберите таблицы",
|
||||
["ТиП", "Топливо", "Потери"],
|
||||
default=["ТиП", "Топливо"],
|
||||
key="ca_tables"
|
||||
)
|
||||
|
||||
with col2:
|
||||
st.subheader("Результат")
|
||||
if st.button("🔍 Получить данные СА", key="ca_btn"):
|
||||
if modes and tables:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"modes": modes,
|
||||
"tables": tables
|
||||
}
|
||||
|
||||
result, status = make_api_request("/svodka_ca/get_data", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите режимы и таблицы")
|
||||
|
||||
# Вкладка 3: Мониторинг топлива - полный функционал
|
||||
with tab3:
|
||||
st.header("⛽ Мониторинг топлива - Полный функционал")
|
||||
|
||||
# Секция загрузки файлов
|
||||
st.subheader("📤 Загрузка файлов")
|
||||
uploaded_fuel = st.file_uploader(
|
||||
"Выберите ZIP архив с мониторингом топлива",
|
||||
type=['zip'],
|
||||
key="fuel_upload"
|
||||
)
|
||||
|
||||
if uploaded_fuel is not None:
|
||||
if st.button("📤 Загрузить мониторинг топлива", key="upload_fuel_btn"):
|
||||
with st.spinner("Загружаю файл..."):
|
||||
result, status = upload_file_to_api(
|
||||
"/monitoring_fuel/upload-zip",
|
||||
uploaded_fuel.read(),
|
||||
uploaded_fuel.name
|
||||
)
|
||||
|
||||
if status == 200:
|
||||
st.success(f"✅ {result.get('message', 'Файл загружен')}")
|
||||
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Секция получения данных
|
||||
st.subheader("🔍 Получение данных")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.subheader("Агрегация по колонкам")
|
||||
|
||||
columns_fuel = st.multiselect(
|
||||
"Выберите столбцы",
|
||||
["normativ", "total", "total_1"],
|
||||
default=["normativ", "total"],
|
||||
key="fuel_columns"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить агрегированные данные", key="fuel_total_btn"):
|
||||
if columns_fuel:
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"columns": columns_fuel
|
||||
}
|
||||
|
||||
result, status = make_api_request("/monitoring_fuel/get_total_by_columns", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
else:
|
||||
st.warning("⚠️ Выберите столбцы")
|
||||
|
||||
with col2:
|
||||
st.subheader("Данные за месяц")
|
||||
|
||||
month = st.selectbox(
|
||||
"Выберите месяц",
|
||||
[f"{i:02d}" for i in range(1, 13)],
|
||||
key="fuel_month"
|
||||
)
|
||||
|
||||
if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"):
|
||||
with st.spinner("Получаю данные..."):
|
||||
data = {
|
||||
"month": month
|
||||
}
|
||||
|
||||
result, status = make_api_request("/monitoring_fuel/get_month_by_code", data)
|
||||
|
||||
if status == 200:
|
||||
st.success("✅ Данные получены")
|
||||
st.json(result)
|
||||
else:
|
||||
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
|
||||
render_tasks_page()
|
||||
|
||||
# Футер
|
||||
st.markdown("---")
|
||||
st.markdown("### 📚 Документация API")
|
||||
st.markdown(f"Полная документация доступна по адресу: {API_PUBLIC_URL}/docs")
|
||||
|
||||
# Информация о проекте
|
||||
with st.expander("ℹ️ О проекте"):
|
||||
st.markdown("""
|
||||
**NIN Excel Parsers API** - это веб-сервис для парсинга и обработки Excel-файлов нефтеперерабатывающих заводов.
|
||||
|
||||
**Возможности:**
|
||||
- 📊 Парсинг сводок ПМ (план и факт)
|
||||
- 🏭 Парсинг сводок СА
|
||||
- ⛽ Мониторинг топлива
|
||||
|
||||
**Технологии:**
|
||||
- FastAPI
|
||||
- Pandas
|
||||
- MinIO (S3-совместимое хранилище)
|
||||
- Streamlit (веб-интерфейс)
|
||||
""")
|
||||
render_footer()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
streamlit_app/sync_parsers_page.py
Normal file
54
streamlit_app/sync_parsers_page.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Страница синхронных парсеров
|
||||
"""
|
||||
import streamlit as st
|
||||
from parsers_ui.svodka_pm_ui import render_svodka_pm_tab
|
||||
from parsers_ui.svodka_ca_ui import render_svodka_ca_tab
|
||||
from parsers_ui.monitoring_fuel_ui import render_monitoring_fuel_tab
|
||||
from parsers_ui.svodka_repair_ca_ui import render_svodka_repair_ca_tab
|
||||
from parsers_ui.statuses_repair_ca_ui import render_statuses_repair_ca_tab
|
||||
from parsers_ui.monitoring_tar_ui import render_monitoring_tar_tab
|
||||
from parsers_ui.oper_spravka_tech_pos_ui import render_oper_spravka_tech_pos_tab
|
||||
from config import PARSER_TABS
|
||||
|
||||
|
||||
def render_sync_parsers_page():
|
||||
"""Рендер страницы синхронных парсеров"""
|
||||
st.title("📊 Синхронные парсеры")
|
||||
st.markdown("---")
|
||||
|
||||
st.info("""
|
||||
**Синхронные парсеры** обрабатывают файлы сразу после загрузки.
|
||||
Интерфейс будет заблокирован до завершения обработки.
|
||||
""")
|
||||
|
||||
# Основные вкладки - по одной на каждый парсер
|
||||
tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs(PARSER_TABS)
|
||||
|
||||
# Вкладка 1: Сводки ПМ - полный функционал
|
||||
with tab1:
|
||||
render_svodka_pm_tab()
|
||||
|
||||
# Вкладка 2: Сводки СА - полный функционал
|
||||
with tab2:
|
||||
render_svodka_ca_tab()
|
||||
|
||||
# Вкладка 3: Мониторинг топлива - полный функционал
|
||||
with tab3:
|
||||
render_monitoring_fuel_tab()
|
||||
|
||||
# Вкладка 4: Ремонт СА
|
||||
with tab4:
|
||||
render_svodka_repair_ca_tab()
|
||||
|
||||
# Вкладка 5: Статусы ремонта СА
|
||||
with tab5:
|
||||
render_statuses_repair_ca_tab()
|
||||
|
||||
# Вкладка 6: Мониторинг ТЭР
|
||||
with tab6:
|
||||
render_monitoring_tar_tab()
|
||||
|
||||
# Вкладка 7: Операционные справки технологических позиций
|
||||
with tab7:
|
||||
render_oper_spravka_tech_pos_tab()
|
||||
186
streamlit_app/tasks_page.py
Normal file
186
streamlit_app/tasks_page.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Страница управления задачами загрузки
|
||||
"""
|
||||
import streamlit as st
|
||||
from datetime import datetime
|
||||
import time
|
||||
from async_upload_page import TASKS_STORAGE
|
||||
|
||||
|
||||
def render_tasks_page():
|
||||
"""Рендер страницы управления задачами"""
|
||||
st.title("📋 Управление задачами загрузки")
|
||||
st.markdown("---")
|
||||
|
||||
# Кнопки управления
|
||||
col1, col2, col3, col4 = st.columns([1, 1, 1, 2])
|
||||
|
||||
with col1:
|
||||
if st.button("🔄 Обновить", key="refresh_tasks_btn", use_container_width=True):
|
||||
st.rerun()
|
||||
|
||||
with col2:
|
||||
if st.button("🗑️ Очистить завершенные", key="clear_completed_btn", use_container_width=True):
|
||||
# Удаляем завершенные и неудачные задачи
|
||||
tasks_to_remove = []
|
||||
for task_id, task in TASKS_STORAGE.items():
|
||||
if task.get('status') in ['completed', 'failed']:
|
||||
tasks_to_remove.append(task_id)
|
||||
|
||||
for task_id in tasks_to_remove:
|
||||
del TASKS_STORAGE[task_id]
|
||||
|
||||
st.success(f"✅ Удалено {len(tasks_to_remove)} завершенных задач")
|
||||
st.rerun()
|
||||
|
||||
with col3:
|
||||
auto_refresh = st.checkbox("🔄 Автообновление", key="auto_refresh_checkbox")
|
||||
if auto_refresh:
|
||||
time.sleep(2)
|
||||
st.rerun()
|
||||
|
||||
with col4:
|
||||
st.caption("Последнее обновление: " + datetime.now().strftime("%H:%M:%S"))
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Статистика задач
|
||||
st.subheader("📊 Статистика задач")
|
||||
|
||||
# Получаем задачи из глобального хранилища
|
||||
tasks = TASKS_STORAGE
|
||||
|
||||
# Подсчитываем статистику
|
||||
total_tasks = len(tasks)
|
||||
pending_tasks = len([t for t in tasks.values() if t.get('status') == 'pending'])
|
||||
running_tasks = len([t for t in tasks.values() if t.get('status') == 'running'])
|
||||
completed_tasks = len([t for t in tasks.values() if t.get('status') == 'completed'])
|
||||
failed_tasks = len([t for t in tasks.values() if t.get('status') == 'failed'])
|
||||
|
||||
col1, col2, col3, col4, col5 = st.columns(5)
|
||||
|
||||
with col1:
|
||||
st.metric("Всего", total_tasks, f"+{total_tasks}")
|
||||
|
||||
with col2:
|
||||
st.metric("Ожидают", pending_tasks, f"+{pending_tasks}")
|
||||
|
||||
with col3:
|
||||
st.metric("Выполняются", running_tasks, f"+{running_tasks}")
|
||||
|
||||
with col4:
|
||||
st.metric("Завершены", completed_tasks, f"+{completed_tasks}")
|
||||
|
||||
with col5:
|
||||
st.metric("Ошибки", failed_tasks, f"+{failed_tasks}")
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Список задач
|
||||
st.subheader("📋 Список задач")
|
||||
|
||||
# Получаем задачи из глобального хранилища
|
||||
tasks = TASKS_STORAGE
|
||||
|
||||
if tasks:
|
||||
# Показываем задачи
|
||||
for task_id, task in tasks.items():
|
||||
status_emoji = {
|
||||
'pending': '🟡',
|
||||
'running': '🔵',
|
||||
'completed': '🟢',
|
||||
'failed': '🔴'
|
||||
}.get(task.get('status', 'pending'), '⚪')
|
||||
|
||||
with st.expander(f"{status_emoji} {task.get('filename', 'Unknown')} - {task.get('status', 'unknown').upper()}", expanded=True):
|
||||
col1, col2 = st.columns([3, 1])
|
||||
|
||||
with col1:
|
||||
st.write(f"**ID:** `{task_id}`")
|
||||
st.write(f"**Статус:** {status_emoji} {task.get('status', 'unknown').upper()}")
|
||||
st.write(f"**Файл:** {task.get('filename', 'Unknown')}")
|
||||
st.write(f"**Эндпоинт:** {task.get('endpoint', 'Unknown')}")
|
||||
|
||||
# Показываем прогресс для выполняющихся задач
|
||||
if task.get('status') == 'running':
|
||||
progress = task.get('progress', 0)
|
||||
st.write(f"**Прогресс:** {progress}%")
|
||||
st.progress(progress / 100)
|
||||
|
||||
# Показываем время выполнения
|
||||
if task.get('started_at'):
|
||||
started_time = datetime.fromtimestamp(task['started_at']).strftime("%Y-%m-%d %H:%M:%S")
|
||||
st.write(f"**Начата:** {started_time}")
|
||||
|
||||
if task.get('completed_at'):
|
||||
completed_time = datetime.fromtimestamp(task['completed_at']).strftime("%Y-%m-%d %H:%M:%S")
|
||||
st.write(f"**Завершена:** {completed_time}")
|
||||
|
||||
# Показываем длительность
|
||||
if task.get('started_at'):
|
||||
duration = task['completed_at'] - task['started_at']
|
||||
st.write(f"**Длительность:** {duration:.1f} сек")
|
||||
|
||||
if task.get('result'):
|
||||
result = task['result']
|
||||
if task.get('status') == 'completed':
|
||||
st.success(f"✅ {result.get('message', 'Задача выполнена')}")
|
||||
if result.get('object_id'):
|
||||
st.info(f"ID объекта: {result['object_id']}")
|
||||
else:
|
||||
st.error(f"❌ {result.get('message', 'Ошибка выполнения')}")
|
||||
|
||||
if task.get('error'):
|
||||
st.error(f"❌ Ошибка: {task['error']}")
|
||||
|
||||
with col2:
|
||||
if task.get('status') in ['pending', 'running']:
|
||||
if st.button("❌ Отменить", key=f"cancel_{task_id}_btn", use_container_width=True):
|
||||
st.info("Функция отмены будет реализована в следующих версиях")
|
||||
else:
|
||||
if st.button("🗑️ Удалить", key=f"delete_{task_id}_btn", use_container_width=True):
|
||||
# Удаляем задачу из глобального хранилища
|
||||
if task_id in TASKS_STORAGE:
|
||||
del TASKS_STORAGE[task_id]
|
||||
st.rerun()
|
||||
else:
|
||||
# Пустое состояние
|
||||
st.info("""
|
||||
**Нет активных задач**
|
||||
|
||||
Загрузите файл на странице "Асинхронная загрузка", чтобы создать новую задачу.
|
||||
Здесь вы сможете отслеживать прогресс обработки и управлять задачами.
|
||||
""")
|
||||
|
||||
# Кнопка для создания тестовой задачи
|
||||
if st.button("🧪 Создать тестовую задачу", key="create_test_task_btn"):
|
||||
test_task_id = f"test_task_{int(time.time())}"
|
||||
|
||||
TASKS_STORAGE[test_task_id] = {
|
||||
'status': 'completed',
|
||||
'filename': 'test_file.zip',
|
||||
'endpoint': '/test/upload',
|
||||
'result': {'message': 'Тестовая задача выполнена', 'object_id': 'test-123'},
|
||||
'started_at': time.time() - 5, # 5 секунд назад
|
||||
'completed_at': time.time(),
|
||||
'progress': 100
|
||||
}
|
||||
st.rerun()
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Информация о статусах задач
|
||||
with st.expander("ℹ️ Статусы задач"):
|
||||
st.markdown("""
|
||||
**Статусы задач:**
|
||||
- 🟡 **Ожидает** - задача создана и ожидает выполнения
|
||||
- 🔵 **Выполняется** - задача обрабатывается
|
||||
- 🟢 **Завершена** - задача успешно выполнена
|
||||
- 🔴 **Ошибка** - произошла ошибка при выполнении
|
||||
- ⚫ **Отменена** - задача была отменена пользователем
|
||||
|
||||
**Действия:**
|
||||
- ❌ **Отменить** - отменить выполнение задачи
|
||||
- 🔄 **Обновить** - обновить статус задачи
|
||||
- 📊 **Детали** - просмотреть подробную информацию
|
||||
""")
|
||||
BIN
test_repair_ca.zip
Normal file
BIN
test_repair_ca.zip
Normal file
Binary file not shown.
44
tests/README.md
Normal file
44
tests/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Тесты для парсеров
|
||||
|
||||
Этот каталог содержит pytest тесты для всех парсеров и их геттеров.
|
||||
|
||||
## Структура
|
||||
|
||||
- est_parsers.py - Основные тесты для всех парсеров
|
||||
- conftest.py - Конфигурация pytest
|
||||
-
|
||||
equirements.txt - Зависимости для тестов
|
||||
- est_data/ - Тестовые данные
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
`ash
|
||||
# Установка зависимостей
|
||||
pip install -r tests/requirements.txt
|
||||
|
||||
# Запуск всех тестов
|
||||
pytest tests/
|
||||
|
||||
# Запуск конкретного теста
|
||||
pytest tests/test_parsers.py::TestSvodkaPMParser
|
||||
|
||||
# Запуск с подробным выводом
|
||||
pytest tests/ -v
|
||||
|
||||
# Запуск с покрытием кода
|
||||
pytest tests/ --cov=python_parser
|
||||
`
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
Тесты покрывают:
|
||||
- Инициализацию всех парсеров
|
||||
- Все геттеры каждого парсера
|
||||
- Обработку валидных и невалидных параметров
|
||||
- Интеграционные тесты
|
||||
|
||||
## Добавление новых тестов
|
||||
|
||||
При добавлении нового парсера:
|
||||
1. Добавьте класс тестов в est_parsers.py
|
||||
2. Создайте тесты для всех геттеров
|
||||
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Конфигурация pytest для тестов парсеров
|
||||
"""
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Добавляем путь к проекту
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python_parser'))
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_data_dir():
|
||||
"""Путь к директории с тестовыми данными"""
|
||||
return os.path.join(os.path.dirname(__file__), 'test_data')
|
||||
|
||||
@pytest.fixture
|
||||
def mock_data():
|
||||
"""Моковые данные для тестов"""
|
||||
return {
|
||||
'SNPZ': {
|
||||
'data': 'test_data',
|
||||
'records_count': 10
|
||||
},
|
||||
'KNPZ': {
|
||||
'data': 'test_data_2',
|
||||
'records_count': 5
|
||||
}
|
||||
}
|
||||
4
tests/requirements.txt
Normal file
4
tests/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pytest>=7.0.0
|
||||
pandas>=1.5.0
|
||||
numpy>=1.20.0
|
||||
openpyxl>=3.0.0
|
||||
BIN
tests/test_data/monitoring.zip
Normal file
BIN
tests/test_data/monitoring.zip
Normal file
Binary file not shown.
BIN
tests/test_data/oper_spavka_tech_pos_SNPZ.zip
Normal file
BIN
tests/test_data/oper_spavka_tech_pos_SNPZ.zip
Normal file
Binary file not shown.
BIN
tests/test_data/pm_all.zip
Normal file
BIN
tests/test_data/pm_all.zip
Normal file
Binary file not shown.
BIN
tests/test_data/svodka_tar.zip
Normal file
BIN
tests/test_data/svodka_tar.zip
Normal file
Binary file not shown.
394
tests/test_parsers.py
Normal file
394
tests/test_parsers.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Тесты для всех парсеров и их геттеров
|
||||
"""
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import tempfile
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
import sys
|
||||
|
||||
# Добавляем путь к проекту
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python_parser'))
|
||||
|
||||
from adapters.parsers import (
|
||||
SvodkaPMParser,
|
||||
SvodkaCAParser,
|
||||
MonitoringFuelParser,
|
||||
MonitoringTarParser,
|
||||
SvodkaRepairCAParser,
|
||||
StatusesRepairCAParser,
|
||||
OperSpravkaTechPosParser
|
||||
)
|
||||
|
||||
|
||||
class TestSvodkaPMParser:
|
||||
"""Тесты для парсера Сводки ПМ"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = SvodkaPMParser()
|
||||
# Создаем тестовые данные
|
||||
self.test_data = {
|
||||
'SNPZ': pd.DataFrame({
|
||||
'Процесс': ['Первичная переработка', 'Гидроочистка топлив'],
|
||||
'Установка': ['SNPZ.EAVT6', 'SNPZ.L24-6'],
|
||||
'План, т': [100.0, 200.0],
|
||||
'Факт, т': [95.0, 190.0]
|
||||
})
|
||||
}
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "Сводки ПМ"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
assert len(self.parser.getters) == 2
|
||||
assert 'single_og' in self.parser.getters
|
||||
assert 'total_ogs' in self.parser.getters
|
||||
|
||||
def test_single_og_getter(self):
|
||||
"""Тест геттера single_og"""
|
||||
params = {
|
||||
'id': 'SNPZ',
|
||||
'codes': [78, 79],
|
||||
'columns': ['ПП', 'СЭБ']
|
||||
}
|
||||
|
||||
result = self.parser.get_value('single_og', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str) # Возвращает JSON строку
|
||||
|
||||
def test_total_ogs_getter(self):
|
||||
"""Тест геттера total_ogs"""
|
||||
params = {
|
||||
'codes': [78, 79],
|
||||
'columns': ['ПП', 'СЭБ']
|
||||
}
|
||||
|
||||
result = self.parser.get_value('total_ogs', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str) # Возвращает JSON строку
|
||||
|
||||
def test_getter_with_invalid_params(self):
|
||||
"""Тест геттера с неверными параметрами"""
|
||||
with pytest.raises(ValueError):
|
||||
self.parser.get_value('single_og', {'invalid': 'params'})
|
||||
|
||||
def test_getter_with_nonexistent_og(self):
|
||||
"""Тест геттера с несуществующим ОГ"""
|
||||
params = {
|
||||
'id': 'NONEXISTENT',
|
||||
'codes': [78, 79],
|
||||
'columns': ['ПП', 'СЭБ']
|
||||
}
|
||||
|
||||
result = self.parser.get_value('single_og', params)
|
||||
# Должен вернуть пустой результат, но не упасть
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestSvodkaCAParser:
|
||||
"""Тесты для парсера Сводки СА"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = SvodkaCAParser()
|
||||
# Создаем тестовые данные
|
||||
self.test_data = {
|
||||
'plan': {
|
||||
'ТиП': pd.DataFrame({
|
||||
'ОГ': ['SNPZ', 'KNPZ'],
|
||||
'Значение': [100.0, 200.0]
|
||||
})
|
||||
},
|
||||
'fact': {
|
||||
'ТиП': pd.DataFrame({
|
||||
'ОГ': ['SNPZ', 'KNPZ'],
|
||||
'Значение': [95.0, 190.0]
|
||||
})
|
||||
}
|
||||
}
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "Сводки СА"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
assert len(self.parser.getters) == 1
|
||||
assert 'get_ca_data' in self.parser.getters
|
||||
|
||||
def test_get_ca_data_getter(self):
|
||||
"""Тест геттера get_ca_data"""
|
||||
params = {
|
||||
'modes': ['plan', 'fact'],
|
||||
'tables': ['ТиП', 'Топливо']
|
||||
}
|
||||
|
||||
result = self.parser.get_value('get_ca_data', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, dict) # Возвращает словарь
|
||||
|
||||
|
||||
class TestMonitoringFuelParser:
|
||||
"""Тесты для парсера Мониторинга топлива"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = MonitoringFuelParser()
|
||||
# Создаем тестовые данные
|
||||
self.test_data = {
|
||||
'SNPZ': pd.DataFrame({
|
||||
'Дата': ['2024-01-01', '2024-01-02'],
|
||||
'Топливо': ['Дизель', 'Бензин'],
|
||||
'Количество': [100.0, 200.0],
|
||||
'Объем': [50.0, 75.0] # Добавляем числовую колонку для агрегации
|
||||
})
|
||||
}
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "Мониторинг топлива"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
# Проверяем, что есть геттеры
|
||||
assert len(self.parser.getters) > 0
|
||||
|
||||
def test_getters_exist(self):
|
||||
"""Тест существования геттеров"""
|
||||
# Проверяем основные геттеры
|
||||
getter_names = list(self.parser.getters.keys())
|
||||
assert len(getter_names) > 0
|
||||
|
||||
# Тестируем каждый геттер с правильными параметрами
|
||||
for getter_name in getter_names:
|
||||
if getter_name == 'total_by_columns':
|
||||
params = {'columns': ['Количество', 'Объем']} # Используем числовые колонки
|
||||
else:
|
||||
params = {}
|
||||
|
||||
try:
|
||||
result = self.parser.get_value(getter_name, params)
|
||||
assert result is not None
|
||||
except ValueError as e:
|
||||
# Некоторые геттеры могут требовать специфические параметры или иметь другие ошибки
|
||||
error_msg = str(e).lower()
|
||||
assert any(keyword in error_msg for keyword in ["required", "missing", "отсутствуют", "обязательные", "ошибка выполнения"])
|
||||
|
||||
|
||||
class TestMonitoringTarParser:
|
||||
"""Тесты для парсера Мониторинга ТЭР"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = MonitoringTarParser()
|
||||
# Создаем тестовые данные
|
||||
self.test_data = {
|
||||
'total': [pd.DataFrame({
|
||||
'Дата': ['2024-01-01'],
|
||||
'Потребление': [100.0]
|
||||
})],
|
||||
'last_day': [pd.DataFrame({
|
||||
'Дата': ['2024-01-02'],
|
||||
'Потребление': [150.0]
|
||||
})]
|
||||
}
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "monitoring_tar"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
assert len(self.parser.getters) == 2
|
||||
assert 'get_tar_data' in self.parser.getters
|
||||
assert 'get_tar_full_data' in self.parser.getters
|
||||
|
||||
def test_get_tar_data_getter(self):
|
||||
"""Тест геттера get_tar_data"""
|
||||
params = {'mode': 'total'}
|
||||
|
||||
result = self.parser.get_value('get_tar_data', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str) # Возвращает JSON строку
|
||||
|
||||
def test_get_tar_full_data_getter(self):
|
||||
"""Тест геттера get_tar_full_data"""
|
||||
result = self.parser.get_value('get_tar_full_data', {})
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str) # Возвращает JSON строку
|
||||
|
||||
|
||||
class TestSvodkaRepairCAParser:
|
||||
"""Тесты для парсера Сводки ремонта СА"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = SvodkaRepairCAParser()
|
||||
# Создаем тестовые данные в правильном формате
|
||||
self.test_data = [
|
||||
{
|
||||
'id': 'SNPZ',
|
||||
'Тип_ремонта': 'Капитальный',
|
||||
'Статус': 'Завершен',
|
||||
'Дата': '2024-01-01'
|
||||
},
|
||||
{
|
||||
'id': 'SNPZ',
|
||||
'Тип_ремонта': 'Текущий',
|
||||
'Статус': 'В работе',
|
||||
'Дата': '2024-01-02'
|
||||
}
|
||||
]
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "Сводки ремонта СА"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
assert len(self.parser.getters) == 1
|
||||
assert 'get_repair_data' in self.parser.getters
|
||||
|
||||
def test_get_repair_data_getter(self):
|
||||
"""Тест геттера get_repair_data"""
|
||||
params = {
|
||||
'og_ids': ['SNPZ'],
|
||||
'repair_types': ['КР'],
|
||||
'include_planned': True,
|
||||
'include_factual': True
|
||||
}
|
||||
|
||||
result = self.parser.get_value('get_repair_data', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, dict) # Возвращает словарь
|
||||
|
||||
|
||||
class TestStatusesRepairCAParser:
|
||||
"""Тесты для парсера Статусов ремонта СА"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = StatusesRepairCAParser()
|
||||
# Создаем тестовые данные
|
||||
self.test_data = {
|
||||
'SNPZ': pd.DataFrame({
|
||||
'Статус': ['В работе', 'Завершен'],
|
||||
'Процент': [50.0, 100.0]
|
||||
})
|
||||
}
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "Статусы ремонта СА"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
assert len(self.parser.getters) == 1
|
||||
assert 'get_repair_statuses' in self.parser.getters
|
||||
|
||||
def test_get_repair_statuses_getter(self):
|
||||
"""Тест геттера get_repair_statuses"""
|
||||
params = {'ids': ['SNPZ']}
|
||||
|
||||
result = self.parser.get_value('get_repair_statuses', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, list) # Возвращает список
|
||||
|
||||
|
||||
class TestOperSpravkaTechPosParser:
|
||||
"""Тесты для парсера Операционных справок технологических позиций"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Настройка перед каждым тестом"""
|
||||
self.parser = OperSpravkaTechPosParser()
|
||||
# Создаем тестовые данные
|
||||
self.test_data = {
|
||||
'SNPZ': pd.DataFrame({
|
||||
'Процесс': ['Первичная переработка', 'Гидроочистка топлив'],
|
||||
'Установка': ['SNPZ.EAVT6', 'SNPZ.L24-6'],
|
||||
'План, т': [100.0, 200.0],
|
||||
'Факт, т': [95.0, 190.0],
|
||||
'id': ['SNPZ.EAVT6', 'SNPZ.L24-6']
|
||||
})
|
||||
}
|
||||
self.parser.data_dict = self.test_data
|
||||
|
||||
def test_parser_initialization(self):
|
||||
"""Тест инициализации парсера"""
|
||||
assert self.parser.name == "oper_spravka_tech_pos"
|
||||
assert hasattr(self.parser, 'getters')
|
||||
assert len(self.parser.getters) == 1
|
||||
assert 'get_tech_pos' in self.parser.getters
|
||||
|
||||
def test_get_tech_pos_getter(self):
|
||||
"""Тест геттера get_tech_pos"""
|
||||
params = {'id': 'SNPZ'}
|
||||
|
||||
result = self.parser.get_value('get_tech_pos', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, list) # Возвращает список словарей
|
||||
|
||||
def test_get_tech_pos_with_nonexistent_og(self):
|
||||
"""Тест геттера с несуществующим ОГ"""
|
||||
params = {'id': 'NONEXISTENT'}
|
||||
|
||||
result = self.parser.get_value('get_tech_pos', params)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0 # Пустой список для несуществующего ОГ
|
||||
|
||||
|
||||
class TestParserIntegration:
|
||||
"""Интеграционные тесты для всех парсеров"""
|
||||
|
||||
def test_all_parsers_have_getters(self):
|
||||
"""Тест, что все парсеры имеют геттеры"""
|
||||
parsers = [
|
||||
SvodkaPMParser(),
|
||||
SvodkaCAParser(),
|
||||
MonitoringFuelParser(),
|
||||
MonitoringTarParser(),
|
||||
SvodkaRepairCAParser(),
|
||||
StatusesRepairCAParser(),
|
||||
OperSpravkaTechPosParser()
|
||||
]
|
||||
|
||||
for parser in parsers:
|
||||
assert hasattr(parser, 'getters')
|
||||
assert len(parser.getters) > 0
|
||||
assert hasattr(parser, 'name')
|
||||
assert parser.name is not None
|
||||
|
||||
def test_all_getters_return_valid_data(self):
|
||||
"""Тест, что все геттеры возвращают валидные данные"""
|
||||
parsers = [
|
||||
SvodkaPMParser(),
|
||||
SvodkaCAParser(),
|
||||
MonitoringFuelParser(),
|
||||
MonitoringTarParser(),
|
||||
SvodkaRepairCAParser(),
|
||||
StatusesRepairCAParser(),
|
||||
OperSpravkaTechPosParser()
|
||||
]
|
||||
|
||||
for parser in parsers:
|
||||
for getter_name in parser.getters.keys():
|
||||
try:
|
||||
result = parser.get_value(getter_name, {})
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
# Некоторые геттеры могут требовать специфические параметры
|
||||
# Это нормально, главное что они не падают с критическими ошибками
|
||||
error_msg = str(e).lower()
|
||||
assert any(keyword in error_msg for keyword in ["required", "missing", "отсутствуют", "обязательные"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
Reference in New Issue
Block a user