2 Commits

Author SHA1 Message Date
de63f98b50 Эндпоинты не работают 2025-09-02 10:09:22 +03:00
84069e4e41 ch 2025-09-02 07:15:16 +03:00
99 changed files with 3132 additions and 7859 deletions

18
.gitignore vendored
View File

@@ -1,22 +1,8 @@
# 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__/
*.pyc
nin_python_parser
*.pyc
*.py[cod]
*$py.class
@@ -171,5 +157,3 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
__pycache__/

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ services:
restart: unless-stopped
fastapi:
image: python:3.11-slim
build: ./python_parser
container_name: svodka_fastapi_dev
ports:
- "8000:8000"
@@ -24,20 +24,9 @@ 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

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,154 @@
"""
Локальный storage адаптер для тестирования
Сохраняет данные в локальную файловую систему вместо MinIO
"""
import os
import json
import pickle
from pathlib import Path
from typing import Optional, Dict, Any
import pandas as pd
from core.ports import StoragePort
class LocalStorageAdapter(StoragePort):
"""Локальный адаптер для хранения данных в файловой системе"""
def __init__(self, base_path: str = "local_storage"):
"""
Инициализация локального storage
Args:
base_path: Базовый путь для хранения данных
"""
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
# Создаем поддиректории
(self.base_path / "data").mkdir(exist_ok=True)
(self.base_path / "metadata").mkdir(exist_ok=True)
def object_exists(self, object_id: str) -> bool:
"""Проверяет существование объекта"""
data_file = self.base_path / "data" / f"{object_id}.pkl"
return data_file.exists()
def save_dataframe(self, object_id: str, df: pd.DataFrame) -> bool:
"""Сохраняет DataFrame в локальную файловую систему"""
try:
data_file = self.base_path / "data" / f"{object_id}.pkl"
metadata_file = self.base_path / "metadata" / f"{object_id}.json"
# Сохраняем DataFrame
with open(data_file, 'wb') as f:
pickle.dump(df, f)
# Сохраняем метаданные
metadata = {
"object_id": object_id,
"shape": df.shape,
"columns": df.columns.tolist(),
"dtypes": {str(k): str(v) for k, v in df.dtypes.to_dict().items()}
}
with open(metadata_file, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
print(f"Ошибка при сохранении {object_id}: {e}")
return False
def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]:
"""Загружает DataFrame из локальной файловой системы"""
try:
data_file = self.base_path / "data" / f"{object_id}.pkl"
if not data_file.exists():
return None
with open(data_file, 'rb') as f:
df = pickle.load(f)
return df
except Exception as e:
print(f"Ошибка при загрузке {object_id}: {e}")
return None
def delete_object(self, object_id: str) -> bool:
"""Удаляет объект из локального storage"""
try:
data_file = self.base_path / "data" / f"{object_id}.pkl"
metadata_file = self.base_path / "metadata" / f"{object_id}.json"
# Удаляем файлы если они существуют
if data_file.exists():
data_file.unlink()
if metadata_file.exists():
metadata_file.unlink()
return True
except Exception as e:
print(f"Ошибка при удалении {object_id}: {e}")
return False
def list_objects(self) -> list:
"""Возвращает список всех объектов в storage"""
try:
data_dir = self.base_path / "data"
if not data_dir.exists():
return []
objects = []
for file_path in data_dir.glob("*.pkl"):
object_id = file_path.stem
objects.append(object_id)
return objects
except Exception as e:
print(f"Ошибка при получении списка объектов: {e}")
return []
def get_object_metadata(self, object_id: str) -> Optional[Dict[str, Any]]:
"""Возвращает метаданные объекта"""
try:
metadata_file = self.base_path / "metadata" / f"{object_id}.json"
if not metadata_file.exists():
return None
with open(metadata_file, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return metadata
except Exception as e:
print(f"Ошибка при получении метаданных {object_id}: {e}")
return None
def clear_all(self) -> bool:
"""Очищает весь storage"""
try:
data_dir = self.base_path / "data"
metadata_dir = self.base_path / "metadata"
# Удаляем все файлы
for file_path in data_dir.glob("*"):
if file_path.is_file():
file_path.unlink()
for file_path in metadata_dir.glob("*"):
if file_path.is_file():
file_path.unlink()
return True
except Exception as e:
print(f"Ошибка при очистке storage: {e}")
return False

View File

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

View File

@@ -1,17 +1,9 @@
from .monitoring_fuel import MonitoringFuelParser
from .monitoring_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',
'SvodkaRepairCAParser',
'StatusesRepairCAParser',
'OperSpravkaTechPosParser'
'SvodkaPMParser'
]

View File

@@ -1,15 +1,11 @@
import pandas as pd
import re
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__)
from adapters.pconfig import data_to_json, find_header_row
class MonitoringFuelParser(ParserPort):
@@ -41,7 +37,7 @@ class MonitoringFuelParser(ParserPort):
getter_name="series_by_id_and_columns",
method=self._get_series_by_id_and_columns,
schema_class=MonitoringFuelSeriesRequest,
description="Получение временного ряда по ID и колонкам"
description="Получение временных рядов по ID и колонкам"
)
def _get_total_by_columns(self, params: dict):
@@ -51,31 +47,9 @@ class MonitoringFuelParser(ParserPort):
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
# TODO: Переделать под новую архитектуру
df_means, _ = self.aggregate_by_columns(self.df, columns)
return df_means.to_dict(orient='index')
def _get_month_by_code(self, params: dict):
"""Получение данных за конкретный месяц"""
@@ -84,145 +58,14 @@ class MonitoringFuelParser(ParserPort):
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
# TODO: Переделать под новую архитектуру
df_month = self.get_month(self.df, month)
return df_month.to_dict(orient='index')
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()
# Сохраняем DataFrame для использования в геттерах
self.df = self.parse_monitoring_fuel_files(file_path, params)
return self.df
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
@@ -241,46 +84,29 @@ class MonitoringFuelParser(ParserPort):
if len(candidates) == 1:
file = candidates[0]
logger.info(f'Загрузка {file}')
print(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} загружены")
print(f"✅ Данные за месяц {mm} загружены")
except Exception as e:
logger.error(f"Ошибка при загрузке файла {file_temp}: {e}")
print(f"Ошибка при загрузке файла {file_temp}: {e}")
else:
logger.warning(f"⚠️ Файл не найден: {file_temp}")
print(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 строк без заголовков
df_temp = pd.read_excel(
file_path,
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():
logger.debug(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx + 1 # возвращаем индекс строки (0-based)
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def parse_single(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 = find_header_row(file, sheet, search_value="Установка")
# Читаем весь лист, начиная с найденной строки как заголовок
df_full = pd.read_excel(
file,
@@ -311,17 +137,13 @@ class MonitoringFuelParser(ParserPort):
# Проверяем, что колонка 'name' существует
if 'name' in df_full.columns:
# Применяем функцию 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
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
# df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
pass # Placeholder for new_code
# Устанавливаем id как индекс
df_full.set_index('id', inplace=True)
logger.debug(f"Окончательное количество столбцов: {len(df_full.columns)}")
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
return df_full
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
@@ -334,7 +156,7 @@ class MonitoringFuelParser(ParserPort):
for file_key, df in df_dict.items():
if col not in df.columns:
logger.warning(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
print(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
continue
# Берём колонку, оставляем id как индекс
@@ -386,7 +208,7 @@ class MonitoringFuelParser(ParserPort):
for file, df in df_dict.items():
if column not in df.columns:
logger.warning(f"Колонка '{column}' не найдена в {file}, пропускаем.")
print(f"Колонка '{column}' не найдена в {file}, пропускаем.")
continue
# Берём колонку и сохраняем как Series с именем месяца
@@ -406,3 +228,47 @@ class MonitoringFuelParser(ParserPort):
total.name = 'mean'
return total, df_combined
def _get_series_by_id_and_columns(self, params: dict):
"""Получение временных рядов по ID и колонкам"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, MonitoringFuelSeriesRequest)
columns = validated_params["columns"]
# Проверяем, что все колонки существуют хотя бы в одном месяце
valid_columns = set()
for month in self.df.values():
valid_columns.update(month.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 self.df:
print(f"Месяц '{month_key}' не найден в df_monitorings, пропускаем.")
continue
df = self.df[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, или оставить как null
if idx not in result:
result[idx] = {c: [] for c in columns}
result[idx][col].append(value)
# Преобразуем ключи id в строки (для JSON-совместимости)
result_str_keys = {str(k): v for k, v in result.items()}
return result_str_keys

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,11 @@
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):
"""Парсер для сводок СА"""
@@ -29,197 +25,134 @@ class SvodkaCAParser(ParserPort):
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 {}
# TODO: Переделать под новую архитектуру
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
for mode in modes:
data_dict[mode] = self.get_data(self.df, mode, tables)
return self.data_dict_to_json(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")
# Сохраняем DataFrame для использования в геттерах
self.df = self.parse_svodka_ca(file_path, params)
return self.df
def parse_svodka_ca(self, file_path: str, params: dict) -> dict:
"""Парсинг сводки СА - работает с тремя листами: План, Факт, Норматив"""
logger.debug(f"🔍 Начинаем парсинг сводки СА из файла: {file_path}")
"""Парсинг сводки СА"""
# Получаем параметры из params
sheet_name = params.get('sheet_name', 0) # По умолчанию первый лист
inclusion_list = params.get('inclusion_list', {'ТиП', 'Топливо', 'Потери'})
# === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив ===
# Выгружаем План
inclusion_list_plan = {
"ТиП, %",
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
"в т.ч. Идентифицированные безвозвратные потери, %**",
"в т.ч. Неидентифицированные потери, тонн**",
"в т.ч. Неидентифицированные потери, %**"
}
# === Извлечение и фильтрация ===
tables = self.extract_all_tables(file_path, sheet_name)
df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan)
logger.debug(f"🔍 Объединённый и отсортированный План: {df_ca_plan.shape if df_ca_plan is not None else 'None'}")
# Фильтруем таблицы: оставляем только те, где первая строка содержит нужные заголовки
filtered_tables = []
for table in tables:
if table.empty:
continue
first_row_values = table.iloc[0].astype(str).str.strip().tolist()
if any(val in inclusion_list for val in first_row_values):
filtered_tables.append(table)
# Выгружаем Факт
inclusion_list_fact = {
"ТиП, %",
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн",
"в т.ч. Идентифицированные безвозвратные потери, %",
"в т.ч. Неидентифицированные потери, тонн",
"в т.ч. Неидентифицированные потери, %"
}
tables = filtered_tables
df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact)
logger.debug(f"🔍 Объединённый и отсортированный Факт: {df_ca_fact.shape if df_ca_fact is not None else 'None'}")
# === Итоговый список таблиц датафреймов ===
result_list = []
# Выгружаем Норматив
inclusion_list_normativ = {
"Топливо итого, тонн",
"Топливо итого, %",
"Топливо на технологию, тонн",
"Топливо на технологию, %",
"Топливо на энергетику, тонн",
"Топливо на энергетику, %",
"Потери итого, тонн",
"Потери итого, %",
"в т.ч. Идентифицированные безвозвратные потери, тонн**",
"в т.ч. Идентифицированные безвозвратные потери, %**",
"в т.ч. Неидентифицированные потери, тонн**",
"в т.ч. Неидентифицированные потери, %**"
}
for table in tables:
if table.empty:
continue
df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ)
logger.debug(f"🔍 Объединённый и отсортированный Норматив: {df_ca_normativ.shape if df_ca_normativ is not None else 'None'}")
# Получаем первую строку (до удаления)
first_row_values = table.iloc[0].astype(str).str.strip().tolist()
# Преобразуем DataFrame в словарь по режимам и таблицам
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
# Находим, какой элемент из inclusion_list присутствует
matched_key = None
for val in first_row_values:
if val in inclusion_list:
matched_key = val
break # берём первый совпадающий заголовок
if matched_key is None:
continue # на всякий случай (хотя уже отфильтровано)
# Удаляем первую строку (заголовок) и сбрасываем индекс
df_cleaned = table.iloc[1:].copy().reset_index(drop=True)
# Пропускаем, если таблица пустая
if df_cleaned.empty:
continue
# Первая строка становится заголовком
new_header = df_cleaned.iloc[0] # извлекаем первую строку как потенциальные названия столбцов
# Преобразуем заголовок: только первый столбец может быть заменён на "name"
cleaned_header = []
# Обрабатываем первый столбец отдельно
first_item = new_header.iloc[0] if isinstance(new_header, pd.Series) else new_header[0]
first_item_str = str(first_item).strip() if pd.notna(first_item) else ""
if first_item_str == "" or first_item_str == "nan":
cleaned_header.append("name")
else:
cleaned_header.append(first_item_str)
# Остальные столбцы добавляем без изменений (или с минимальной очисткой)
for item in new_header[1:]:
# Опционально: приводим к строке и убираем лишние пробелы, но не заменяем на "name"
item_str = str(item).strip() if pd.notna(item) else ""
cleaned_header.append(item_str)
# Применяем очищенные названия столбцов
df_cleaned = df_cleaned[1:] # удаляем строку с заголовком
df_cleaned.columns = cleaned_header
df_cleaned = df_cleaned.reset_index(drop=True)
if matched_key.endswith('**'):
cleaned_key = matched_key[:-2] # удаляем последние **
else:
cleaned_key = matched_key
# Добавляем новую колонку с именем параметра
df_cleaned["table"] = cleaned_key
# Проверяем, что колонка 'name' существует
if 'name' not in df_cleaned.columns:
print(
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
continue # или обработать по-другому
else:
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
df_cleaned['id'] = df_cleaned['name'].apply(get_og_by_name)
# Удаляем строки, где id — None, NaN или пустой
df_cleaned = df_cleaned.dropna(subset=['id']) # dropna удаляет NaN
# Дополнительно: удаляем None (если не поймал dropna)
df_cleaned = df_cleaned[df_cleaned['id'].notna() & (df_cleaned['id'].astype(str) != 'None')]
# Добавляем в словарь
result_list.append(df_cleaned)
# === Объединение и сортировка по id (индекс) и table ===
if result_list:
combined_df = pd.concat(result_list, axis=0)
# Сортируем по индексу (id) и по столбцу 'table'
combined_df = combined_df.sort_values(by=['id', 'table'], axis=0)
# Устанавливаем id как индекс
# combined_df.set_index('id', inplace=True)
return combined_df
else:
return None
def extract_all_tables(self, file_path, sheet_name=0):
"""Извлечение всех таблиц из Excel файла"""
@@ -372,7 +305,7 @@ class SvodkaCAParser(ParserPort):
# Проверяем, что колонка 'name' существует
if 'name' not in df_cleaned.columns:
logger.debug(
print(
f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.")
continue # или обработать по-другому
else:

View File

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

View File

@@ -1,18 +1,9 @@
import pandas as pd
import os
import json
import zipfile
import tempfile
import shutil
import logging
from typing import Dict, Any, List, Optional
from core.ports import ParserPort
from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
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 SINGLE_OGS, replace_id_in_path, data_to_json, find_header_row
class SvodkaPMParser(ParserPort):
@@ -20,140 +11,74 @@ class SvodkaPMParser(ParserPort):
name = "Сводки ПМ"
def __init__(self):
super().__init__()
self._register_default_getters()
def _register_default_getters(self):
"""Регистрация геттеров для Сводки ПМ"""
self.register_getter(
name="single_og",
"""Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды
register_getter_from_schema(
parser_instance=self,
getter_name="single_og",
method=self._get_single_og,
required_params=["id", "codes", "columns"],
optional_params=["search"],
description="Получение данных по одному ОГ из сводки ПМ"
schema_class=SvodkaPMSingleOGRequest,
description="Получение данных по одному ОГ"
)
self.register_getter(
name="total_ogs",
register_getter_from_schema(
parser_instance=self,
getter_name="total_ogs",
method=self._get_total_ogs,
required_params=["codes", "columns"],
optional_params=["search"],
description="Получение данных по всем ОГ из сводки ПМ"
schema_class=SvodkaPMTotalOGsRequest,
description="Получение данных по всем ОГ"
)
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}")
def _get_single_og(self, params: dict):
"""Получение данных по одному ОГ"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
# Создаем временную директорию для разархивирования
temp_dir = tempfile.mkdtemp()
og_id = validated_params["id"]
codes = validated_params["codes"]
columns = validated_params["columns"]
search = validated_params.get("search")
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
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику
# TODO: Переделать под новую архитектуру
return self.get_svodka_og(self.df, og_id, codes, columns, search)
# Возвращаем словарь с данными (как в оригинале)
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 _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_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame:
"""Парсинг отчетов одного ОГ для БП, ПП и факта"""
try:
# Автоопределение header_num, если не передан
if header_num is None:
header_num = find_header_row(file_path, sheet_name, search_value="Итого")
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
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
df_probe = pd.read_excel(
file_path,
sheet_name=sheet_name,
header=header_num,
usecols=None,
nrows=2,
engine='openpyxl' # Явно указываем движок
)
except Exception as e:
raise ValueError(f"Ошибка при чтении файла {file_path}: {str(e)}")
def parse_svodka_pm(self, file, sheet, header_num=None):
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
# Автоопределение header_num, если не передан
if header_num is None:
header_num = 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("Файл пуст или не содержит данных.")
@@ -166,15 +91,16 @@ 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_path,
sheet_name=sheet_name,
file,
sheet_name=sheet,
header=header_num,
usecols=None,
index_col=None,
engine='openpyxl' # Явно указываем движок
engine='openpyxl'
)
if indicator_col_name not in df_full.columns:
@@ -191,18 +117,19 @@ 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:
@@ -211,152 +138,109 @@ class SvodkaPMParser(ParserPort):
# Проверяем, является ли колонка пустой/некорректной
is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan'
# Проверяем, начинается ли на "Итого"
if col_str.startswith('Итого'):
current_name = 'Итого'
last_good_name = current_name
new_columns.append(current_name)
elif is_empty_or_unnamed:
# Используем последнее хорошее имя
new_columns.append(last_good_name)
if is_empty_or_unnamed:
# Если это пустая колонка, используем последнее хорошее имя
if last_good_name:
new_columns.append(last_good_name)
else:
# Если нет хорошего имени, пропускаем
continue
else:
# Имя, полученное из excel
# Это хорошая колонка
last_good_name = col_str
new_columns.append(col_str)
# Применяем новые заголовки
df_final.columns = new_columns
return df_final
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)}")
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 id in SINGLE_OGS:
if id == 'BASH':
continue # пропускаем BASH
# Проверяем, есть ли код в индексе
if code not in df_svodka.index:
logger.warning(f"⚠️ Код '{code}' не найден в индексе")
return 0
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
# Получаем позицию строки с кодом
code_row_loc = df_svodka.index.get_loc(code)
logger.debug(f"🔍 Код '{code}' в позиции {code_row_loc}")
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:
# Ищем все позиции кроме "Итого" и 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)
mask_name = df_svodka.columns != 'Итого'
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]}...")
mask_name = df_svodka.columns == search_value
logger.debug(f"🔍 Ищем столбцы с названием '{search_value}'")
logger.debug(f"🔍 Целевые позиции: {target_positions[:10]}...")
# Убедимся, что маски совпадают по длине
if len(mask_value) != len(mask_name):
raise ValueError(
f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}"
)
if not target_positions:
logger.warning(f"⚠️ Позиции '{search_value}' не найдены")
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
# Извлекаем значения из найденных позиций
values = []
for pos in target_positions:
# Берем значение из пересечения строки с кодом и позиции столбца
value = df_svodka.iloc[code_row_loc, pos]
# Если это Series, берем первое значение
if isinstance(value, pd.Series):
if len(value) > 0:
# Берем первое не-NaN значение
first_valid = value.dropna().iloc[0] if not value.dropna().empty else 0
values.append(first_valid)
else:
values.append(0)
else:
values.append(value)
# Преобразуем в числовой формат
numeric_values = pd.to_numeric(values, errors='coerce')
logger.debug(f"🔍 Числовые значения (первые 5): {numeric_values.tolist()[:5]}")
# Попробуем альтернативное преобразование
try:
# Если pandas не может преобразовать, попробуем вручную
manual_values = []
for v in values:
if pd.isna(v) or v is None:
manual_values.append(0)
else:
try:
# Пробуем преобразовать в float
manual_values.append(float(str(v).replace(',', '.')))
except (ValueError, TypeError):
manual_values.append(0)
logger.debug(f"🔍 Ручное преобразование (первые 5): {manual_values[:5]}")
numeric_values = pd.Series(manual_values)
except Exception as e:
logger.warning(f"⚠️ Ошибка при ручном преобразовании: {e}")
# Используем исходные значения
numeric_values = pd.Series([0 if pd.isna(v) or v is None else v for v in values])
# Агрегация данных (NaN игнорируются)
if search_value is None:
# Возвращаем массив всех значений (игнорируя NaN)
if len(numeric_values) > 0:
# Фильтруем NaN значения и возвращаем как список
valid_values = numeric_values.dropna()
if len(valid_values) > 0:
return valid_values.tolist()
else:
return []
else:
return []
else:
# Возвращаем массив всех значений (игнорируя 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 []
if row_index in df_svodka.index:
# Получаем позицию строки
row_loc = df_svodka.index.get_loc(row_index)
def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None):
"""Служебная функция получения данных по одному ОГ"""
# Извлекаем значения по позициям столбцов
values = df_svodka.iloc[row_loc, col_positions]
# Преобразуем в числовой формат
numeric_values = pd.to_numeric(values, errors='coerce')
# Агрегация данных (NaN игнорируются)
if search_value is None:
return numeric_values
else:
return numeric_values.iloc[0]
else:
return None
def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None):
''' Служебная функция получения данных по одному ОГ '''
result = {}
# Получаем данные из сохраненных словарей (через 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 ''}")
fact_df = pm_dict['facts'][id]
plan_df = pm_dict['plans'][id]
# Определяем, какие столбцы из какого датафрейма брать
for col in columns:
@@ -364,91 +248,49 @@ class SvodkaPMParser(ParserPort):
if col in ['ПП', 'БП']:
if plan_df is None:
logger.warning(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}")
print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}")
else:
logger.debug(f"🔍 ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====")
for code in codes:
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}' =====")
val = self.get_svodka_value(plan_df, code, col, search_value)
col_result[code] = val
elif col in ['ТБ', 'СЭБ', 'НЭБ']:
if fact_df is None:
logger.warning(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}")
print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}")
else:
for code in codes:
val = self._get_svodka_value(fact_df, og_id, code, col)
col_result[str(code)] = val
val = self.get_svodka_value(fact_df, code, col, search_value)
col_result[code] = val
else:
logger.warning(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
col_result = {str(code): None for code in codes}
print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.")
col_result = {code: None for code in codes}
result[col] = col_result
return result
def _get_single_og(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-строкой")
og_id = params.get("id")
codes = params.get("codes")
columns = params.get("columns")
search = params.get("search")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
data = self._get_svodka_og(og_id, codes, columns, search)
json_result = data_to_json(data)
return json_result
def _get_total_ogs(self, params: Dict[str, Any]) -> str:
"""API функция для получения данных по всем ОГ"""
# Если на входе строка — парсим как JSON
if isinstance(params, str):
try:
params = json.loads(params)
except json.JSONDecodeError as e:
raise ValueError(f"❌Некорректный JSON: {e}")
# Проверяем структуру
if not isinstance(params, dict):
raise TypeError("Конфиг должен быть словарём или JSON-строкой")
codes = params.get("codes")
columns = params.get("columns")
search = params.get("search")
if not isinstance(codes, list):
raise ValueError("Поле 'codes' должно быть списком")
if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
def get_svodka_total(self, pm_dict, codes, columns, search_value=None):
''' Служебная функция агрегации данные по всем ОГ '''
total_result = {}
for og_id in SINGLE_OGS:
if og_id == 'BASH':
continue
# print(f"📊 Обработка: {og_id}")
try:
data = self._get_svodka_og(og_id, codes, columns, search)
data = self.get_svodka_og(
pm_dict,
og_id,
codes,
columns,
search_value
)
total_result[og_id] = data
except Exception as e:
logger.error(f"❌ Ошибка при обработке {og_id}: {e}")
print(f"❌ Ошибка при обработке {og_id}: {e}")
total_result[og_id] = None
json_result = data_to_json(total_result)
return json_result
return total_result
# Убираем старый метод get_value, так как он теперь в базовом классе

View File

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

View File

@@ -4,10 +4,6 @@ import json
import numpy as np
import pandas as pd
import os
import logging
# Настройка логгера для модуля
logger = logging.getLogger(__name__)
OG_IDS = {
"Комсомольский НПЗ": "KNPZ",
@@ -167,7 +163,7 @@ def find_header_row(file, sheet, search_value="Итого", max_rows=50):
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
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})")
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx # 0-based index — то, что нужно для header=
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
@@ -217,18 +213,11 @@ def data_to_json(data, indent=2, ensure_ascii=False):
# --- рекурсия по dict и list ---
elif isinstance(obj, dict):
# Обрабатываем только значения, ключи оставляем как строки
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
return {
key: convert_obj(value)
for key, value in obj.items()
if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON)
}
elif isinstance(obj, list):
return [convert_obj(item) for item in obj]

View File

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

View File

@@ -1,123 +0,0 @@
# Структура эндпоинтов 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 эндпоинта (синхронные + асинхронные)

View File

@@ -1,3 +0,0 @@
"""
Модули эндпоинтов FastAPI
"""

View File

@@ -1,174 +0,0 @@
"""
Общие эндпоинты 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
return {
"message": "Svodka Parser API",
"version": "1.0.0",
"python_version": sys.version,
"platform": platform.platform(),
"available_parsers": list(PARSERS.keys())
}

View File

@@ -1,325 +0,0 @@
"""
Эндпоинты для мониторинга топлива
"""
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()
)

View File

@@ -1,220 +0,0 @@
"""
Эндпоинты для мониторинга ТАР
"""
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()
)

View File

@@ -1,190 +0,0 @@
"""
Эндпоинты для оперативной справки техпос
"""
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="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='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:
return OperSpravkaTechPosResponse(
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/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="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='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()
)

View File

@@ -1,189 +0,0 @@
"""
Эндпоинты для статусов ремонта СА
"""
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()
)

View File

@@ -1,226 +0,0 @@
"""
Эндпоинты для сводки СА
"""
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()
)

View File

@@ -1,319 +0,0 @@
"""
Эндпоинты для сводки ПМ
"""
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()
)

View File

@@ -1,189 +0,0 @@
"""
Эндпоинты для сводки ремонта СА
"""
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()
)

View File

@@ -1,21 +0,0 @@
"""
Системные эндпоинты 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
}

View File

@@ -1,71 +1,859 @@
"""
Главный файл FastAPI приложения
"""
import os
import multiprocessing
import uvicorn
import logging
from fastapi import FastAPI
from typing import Dict, List
from fastapi import FastAPI, File, UploadFile, HTTPException, status
from fastapi.responses import JSONResponse
# Настройка логирования
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(name)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
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, MonitoringFuelSeriesRequest
)
# Настройка логгера для модуля
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,
'monitoring_tar': MonitoringTarParser,
'svodka_repair_ca': SvodkaRepairCAParser,
'statuses_repair_ca': StatusesRepairCAParser,
'oper_spravka_tech_pos': OperSpravkaTechPosParser,
# 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser,
})
# Создаем FastAPI приложение
# Адаптеры
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",
# },
]
app = FastAPI(
title="Svodka Parser API",
description="API для парсинга различных типов отчетов",
title="NIN Excel Parsers API",
description="API для парсинга сводок и работы с данными экселей НиН",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
openapi_tags=tags_metadata,
)
# Подключаем роутеры
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)
@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_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)}")
@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_ca_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_dict['mode'] = 'get_ca_data'
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/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_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)}")
@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_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)}")
@app.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"]
}
```
### Возвращает:
Словарь где ключ - ID объекта, значение - словарь с колонками,
в которых хранятся списки значений по месяцам.
"""
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)}")
if __name__ == "__main__":
# Настройка для запуска в продакшене
workers = multiprocessing.cpu_count()
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
workers=workers,
reload=False
)
uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@@ -6,7 +6,7 @@ from .upload import UploadResponse, UploadErrorResponse
__all__ = [
'MonitoringFuelMonthRequest', 'MonitoringFuelTotalRequest', 'MonitoringFuelSeriesRequest',
'MonitoringFuelMonthRequest', 'MonitoringFuelTotalRequest',
'SvodkaCARequest',
'SvodkaPMSingleOGRequest', 'SvodkaPMTotalOGsRequest',
'ServerInfoResponse',

View File

@@ -37,7 +37,7 @@ class MonitoringFuelTotalRequest(BaseModel):
class MonitoringFuelSeriesRequest(BaseModel):
columns: List[str] = Field(
...,
description="Массив названий выбираемых столбцов",
description="Массив названий выбираемых столбцов для получения временных рядов",
example=["total", "normativ"],
min_items=1
)

View File

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

View File

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

View File

@@ -1,29 +1,18 @@
from pydantic import BaseModel, Field
from typing import Optional
class ServerInfoResponse(BaseModel):
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="Доступные парсеры")
process_id: int = Field(..., description="Идентификатор текущего процесса сервера")
parent_id: int = Field(..., description="Идентификатор родительского процесса")
cpu_cores: int = Field(..., description="Количество ядер процессора в системе")
memory_mb: float = Field(..., 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,
"python_version": "3.11.0",
"platform": "Windows-10-10.0.22631-SP0",
"available_parsers": ["svodka_pm", "svodka_ca", "monitoring_fuel"]
"memory_mb": 16384.5
}
}

View File

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

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
"""
Асинхронные сервисы для работы с отчетами
"""
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)

View File

@@ -135,6 +135,10 @@ def validate_params_with_schema(params: Dict[str, Any], schema_class: Type[BaseM
try:
# Создаем экземпляр схемы для валидации
validated_data = schema_class(**params)
return validated_data.dict()
# Используем model_dump() для Pydantic v2 или dict() для v1
if hasattr(validated_data, 'model_dump'):
return validated_data.model_dump()
else:
return validated_data.dict()
except Exception as e:
raise ValueError(f"Ошибка валидации параметров: {str(e)}")

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,87 +0,0 @@
"""
Модуль для работы с 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 []

View File

@@ -1,160 +0,0 @@
"""
Страница асинхронной загрузки файлов
"""
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")

View File

@@ -1,58 +0,0 @@
"""
Конфигурация приложения
"""
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"]

View File

@@ -1,3 +0,0 @@
"""
UI модули для парсеров
"""

View File

@@ -1,162 +0,0 @@
"""
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, ...]
}
```
""")

View File

@@ -1,108 +0,0 @@
"""
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', 'Неизвестная ошибка')}")

View File

@@ -1,84 +0,0 @@
"""
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', 'Неизвестная ошибка')}")

View File

@@ -1,147 +0,0 @@
"""
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', 'Неизвестная ошибка')}")

View File

@@ -1,80 +0,0 @@
"""
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("⚠️ Выберите режимы и таблицы")

View File

@@ -1,118 +0,0 @@
"""
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("⚠️ Выберите коды и столбцы")

View File

@@ -1,111 +0,0 @@
"""
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', 'Неизвестная ошибка')}")

View File

@@ -1,82 +0,0 @@
"""
Модуль для сайдбара
"""
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 (веб-интерфейс)
""")

View File

@@ -1,61 +1,425 @@
import streamlit as st
from config import setup_page_config, API_PUBLIC_URL
from api_client import check_api_health
from sidebar import render_sidebar, render_footer
from sync_parsers_page import render_sync_parsers_page
from async_upload_page import render_async_upload_page
from tasks_page import render_tasks_page
import requests
import json
import pandas as pd
import io
import zipfile
from typing import Dict, Any
import os
# Конфигурация страницы
setup_page_config()
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
def main():
# Определяем активную страницу для заголовка
active_page = st.session_state.get("active_page", 0)
page_titles = {
0: "Синхронные парсеры",
1: "Асинхронная загрузка",
2: "Управление задачами"
}
st.title(f"🚀 NIN Excel Parsers API - {page_titles.get(active_page, 'Демонстрация')}")
st.title("🚀 NIN Excel Parsers API - Демонстрация")
st.markdown("---")
# Проверка доступности API
if not check_api_health():
st.error(f"❌ API недоступен по адресу {API_PUBLIC_URL}")
st.error(f"❌ API недоступен по адресу {API_BASE_URL}")
st.info("Убедитесь, что FastAPI сервер запущен")
return
st.success(f"✅ API доступен по адресу {API_PUBLIC_URL}")
# Обрабатываем клики по кнопкам в сайдбаре ПЕРЕД рендером
if st.session_state.get("sidebar_sync_clicked", False):
st.session_state.sidebar_sync_clicked = False
st.session_state.active_page = 0
elif st.session_state.get("sidebar_async_clicked", False):
st.session_state.sidebar_async_clicked = False
st.session_state.active_page = 1
elif st.session_state.get("sidebar_tasks_clicked", False):
st.session_state.sidebar_tasks_clicked = False
st.session_state.active_page = 2
# Боковая панель с информацией
with st.sidebar:
st.header(" Информация")
# Информация о сервере
server_info = get_server_info()
if server_info:
st.subheader("Сервер")
st.write(f"PID: {server_info.get('process_id', 'N/A')}")
st.write(f"CPU ядер: {server_info.get('cpu_cores', 'N/A')}")
st.write(f"Память: {server_info.get('memory_mb', 'N/A'):.1f} MB")
# Доступные парсеры
parsers = get_available_parsers()
if parsers:
st.subheader("Доступные парсеры")
for parser in parsers:
st.write(f"{parser}")
# Определяем активную страницу
active_page = st.session_state.get("active_page", 0)
# Основные вкладки - по одной на каждый парсер
tab1, tab2, tab3 = st.tabs([
"📊 Сводки ПМ",
"🏭 Сводки СА",
"⛽ Мониторинг топлива"
])
# Боковая панель с информацией и навигацией
render_sidebar()
# Вкладка 1: Сводки ПМ - полный функционал
with tab1:
st.header("📊 Сводки ПМ - Полный функционал")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_pm = st.file_uploader(
"Выберите ZIP архив со сводками ПМ",
type=['zip'],
key="pm_upload"
)
if uploaded_pm is not None:
if st.button("📤 Загрузить сводки ПМ", key="upload_pm_btn"):
with st.spinner("Загружаю файл..."):
result, status = upload_file_to_api(
"/svodka_pm/upload-zip",
uploaded_pm.read(),
uploaded_pm.name
)
if status == 200:
st.success(f"{result.get('message', 'Файл загружен')}")
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
st.markdown("---")
# Секция получения данных
st.subheader("🔍 Получение данных")
col1, col2 = st.columns(2)
with col1:
st.subheader("Данные по одному ОГ")
og_id = st.selectbox(
"Выберите ОГ",
["SNPZ", "KNPZ", "ANHK", "AchNPZ", "UNPZ", "UNH", "NOV",
"NovKuybNPZ", "KuybNPZ", "CyzNPZ", "TuapsNPZ", "RNPK",
"NVNPO", "KLNPZ", "PurNP", "YANOS"],
key="pm_single_og"
)
codes = st.multiselect(
"Выберите коды строк",
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
default=[78, 79],
key="pm_single_codes"
)
columns = st.multiselect(
"Выберите столбцы",
["БП", "ПП", "СЭБ", "Факт", "План"],
default=["БП", "ПП"],
key="pm_single_columns"
)
if st.button("🔍 Получить данные по ОГ", key="pm_single_btn"):
if codes and columns:
with st.spinner("Получаю данные..."):
data = {
"id": og_id,
"codes": codes,
"columns": columns
}
result, status = make_api_request("/svodka_pm/get_single_og", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите коды и столбцы")
with col2:
st.subheader("Данные по всем ОГ")
codes_total = st.multiselect(
"Выберите коды строк",
[78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
default=[78, 79, 394, 395],
key="pm_total_codes"
)
columns_total = st.multiselect(
"Выберите столбцы",
["БП", "ПП", "СЭБ", "Факт", "План"],
default=["БП", "ПП", "СЭБ"],
key="pm_total_columns"
)
if st.button("🔍 Получить данные по всем ОГ", key="pm_total_btn"):
if codes_total and columns_total:
with st.spinner("Получаю данные..."):
data = {
"codes": codes_total,
"columns": columns_total
}
result, status = make_api_request("/svodka_pm/get_total_ogs", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите коды и столбцы")
# Рендерим соответствующую страницу
if active_page == 0:
render_sync_parsers_page()
elif active_page == 1:
render_async_upload_page()
else:
render_tasks_page()
# Вкладка 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_ca_data", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите режимы и таблицы")
# Вкладка 3: Мониторинг топлива - полный функционал
with tab3:
st.header("⛽ Мониторинг топлива - Полный функционал")
# Секция загрузки файлов
st.subheader("📤 Загрузка файлов")
uploaded_fuel = st.file_uploader(
"Выберите ZIP архив с мониторингом топлива",
type=['zip'],
key="fuel_upload"
)
if uploaded_fuel is not None:
if st.button("📤 Загрузить мониторинг топлива", key="upload_fuel_btn"):
with st.spinner("Загружаю файл..."):
result, status = upload_file_to_api(
"/monitoring_fuel/upload-zip",
uploaded_fuel.read(),
uploaded_fuel.name
)
if status == 200:
st.success(f"{result.get('message', 'Файл загружен')}")
st.info(f"ID объекта: {result.get('object_id', 'N/A')}")
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
st.markdown("---")
# Секция получения данных
st.subheader("🔍 Получение данных")
col1, col2 = st.columns(2)
with col1:
st.subheader("Агрегация по колонкам")
columns_fuel = st.multiselect(
"Выберите столбцы",
["normativ", "total", "total_1"],
default=["normativ", "total"],
key="fuel_columns"
)
if st.button("🔍 Получить агрегированные данные", key="fuel_total_btn"):
if columns_fuel:
with st.spinner("Получаю данные..."):
data = {
"columns": columns_fuel
}
result, status = make_api_request("/monitoring_fuel/get_total_by_columns", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите столбцы")
with col2:
st.subheader("Данные за месяц")
month = st.selectbox(
"Выберите месяц",
[f"{i:02d}" for i in range(1, 13)],
key="fuel_month"
)
if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"):
with st.spinner("Получаю данные..."):
data = {
"month": month
}
result, status = make_api_request("/monitoring_fuel/get_month_by_code", data)
if status == 200:
st.success("✅ Данные получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Новая секция для временных рядов
st.markdown("---")
st.subheader("📈 Временные ряды по ID и колонкам")
columns_series = st.multiselect(
"Выберите столбцы для временных рядов",
["normativ", "total", "total_1"],
default=["normativ", "total"],
key="fuel_series_columns"
)
if st.button("📈 Получить временные ряды", key="fuel_series_btn"):
if columns_series:
with st.spinner("Получаю временные ряды..."):
data = {
"columns": columns_series
}
result, status = make_api_request("/monitoring_fuel/get_series_by_id_and_columns", data)
if status == 200:
st.success("✅ Временные ряды получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите столбцы")
# Футер
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 (веб-интерфейс)
""")
if __name__ == "__main__":
main()

View File

@@ -1,54 +0,0 @@
"""
Страница синхронных парсеров
"""
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()

View File

@@ -1,186 +0,0 @@
"""
Страница управления задачами загрузки
"""
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("""
**Статусы задач:**
- 🟡 **Ожидает** - задача создана и ожидает выполнения
- 🔵 **Выполняется** - задача обрабатывается
- 🟢 **Завершена** - задача успешно выполнена
- 🔴 **Ошибка** - произошла ошибка при выполнении
- ⚫ **Отменена** - задача была отменена пользователем
**Действия:**
- ❌ **Отменить** - отменить выполнение задачи
- 🔄 **Обновить** - обновить статус задачи
- 📊 **Детали** - просмотреть подробную информацию
""")

Binary file not shown.

View File

@@ -1,44 +1,123 @@
# Тесты для парсеров
# API Endpoints Tests
Этот каталог содержит pytest тесты для всех парсеров и их геттеров.
Этот модуль содержит pytest тесты для всех API эндпоинтов проекта NIN Excel Parsers.
## Структура
- est_parsers.py - Основные тесты для всех парсеров
- conftest.py - Конфигурация pytest
-
equirements.txt - Зависимости для тестов
```
tests/
├── __init__.py
├── conftest.py # Конфигурация pytest
├── test_all_endpoints.py # Основной файл для запуска всех тестов
├── test_upload_endpoints.py # Тесты API эндпоинтов загрузки данных
├── test_svodka_pm_endpoints.py # Тесты API svodka_pm эндпоинтов
├── test_svodka_ca_endpoints.py # Тесты API svodka_ca эндпоинтов
├── test_monitoring_fuel_endpoints.py # Тесты API monitoring_fuel эндпоинтов
├── test_parsers_direct.py # Прямое тестирование парсеров
├── test_upload_with_local_storage.py # Тестирование загрузки в локальный storage
├── test_getters_with_local_storage.py # Тестирование геттеров с локальными данными
├── test_data/ # Тестовые данные
│ ├── svodka_ca.xlsx
│ ├── pm_plan.zip
│ └── monitoring.zip
├── local_storage/ # Локальный storage (создается автоматически)
│ ├── data/ # Сохраненные DataFrame
│ └── metadata/ # Метаданные объектов
├── requirements.txt # Зависимости для тестов
└── README.md # Этот файл
```
## Установка зависимостей
```bash
pip install -r tests/requirements.txt
```
## Запуск тестов
`ash
# Установка зависимостей
### Запуск всех тестов
```bash
cd tests
python test_all_endpoints.py
```
# Запуск всех тестов
### Запуск конкретных тестов
```bash
# API тесты (требуют запущенный сервер)
pytest test_upload_endpoints.py -v
pytest test_svodka_pm_endpoints.py -v
pytest test_svodka_ca_endpoints.py -v
pytest test_monitoring_fuel_endpoints.py -v
# Запуск конкретного теста
# Прямые тесты парсеров (не требуют сервер)
pytest test_parsers_direct.py -v
pytest test_upload_with_local_storage.py -v
pytest test_getters_with_local_storage.py -v
# Запуск с подробным выводом
# Все тесты с локальным storage
pytest test_parsers_direct.py test_upload_with_local_storage.py test_getters_with_local_storage.py -v
```
# Запуск с покрытием кода
pytest tests/ --cov=python_parser
## Предварительные условия
1. **API сервер должен быть запущен** на `http://localhost:8000` (только для API тестов)
2. **Тестовые данные** находятся в папке `test_data/`
3. **Локальный storage** используется для прямого тестирования парсеров
Тесты покрывают:
- Инициализацию всех парсеров
- Все геттеры каждого парсера
- Обработку валидных и невалидных параметров
## Последовательность тестирования
### Вариант 1: API тесты (требуют запущенный сервер)
1. **Загрузка данных** (`test_upload_endpoints.py`)
- Загрузка `svodka_ca.xlsx`
- Загрузка `pm_plan.zip`
- Загрузка `monitoring.zip`
При добавлении нового парсера:
1. Добавьте класс тестов в est_parsers.py
2. Создайте тесты для всех геттеров
2. **Тестирование эндпоинтов** (в любом порядке)
- `test_svodka_pm_endpoints.py`
- `test_svodka_ca_endpoints.py`
- `test_monitoring_fuel_endpoints.py`
### Вариант 2: Прямые тесты (не требуют сервер)
1. **Тестирование парсеров** (`test_parsers_direct.py`)
- Проверка регистрации парсеров
- Проверка локального storage
2. **Загрузка в локальный storage** (`test_upload_with_local_storage.py`)
- Загрузка всех файлов в локальный storage
- Проверка сохранения данных
3. **Тестирование геттеров** (`test_getters_with_local_storage.py`)
- Тестирование всех геттеров с локальными данными
- Выявление проблем в логике парсеров
## Ожидаемые результаты
Все тесты должны возвращать **статус 200** и содержать поле `"success": true` в ответе.
## Примеры тестовых запросов
Тесты используют примеры из Pydantic схем:
### svodka_pm
```json
{
"id": "SNPZ",
"codes": [78, 79],
"columns": ["ПП", "СЭБ"]
}
```
### svodka_ca
```json
{
"modes": ["fact", "plan"],
"tables": ["table1", "table2"]
}
```
### monitoring_fuel
```json
{
"columns": ["total", "normativ"]
}
```

71
tests/TEST_RESULTS.md Normal file
View File

@@ -0,0 +1,71 @@
# Результаты тестирования API эндпоинтов
## Сводка
Создана полная система тестирования с локальным storage для проверки всех API эндпоинтов проекта NIN Excel Parsers.
## Структура тестов
### 1. Прямые тесты парсеров (`test_parsers_direct.py`)
-**Регистрация парсеров** - все парсеры корректно регистрируются
-**Локальный storage** - работает корректно
-**ReportService** - корректно работает с локальным storage
### 2. Тесты загрузки (`test_upload_with_local_storage.py`)
-**svodka_ca.xlsx** - парсер возвращает `None`
-**pm_plan.zip** - парсер возвращает словарь с `None` значениями
-**monitoring.zip** - парсер возвращает пустой словарь
### 3. Тесты геттеров (`test_getters_with_local_storage.py`)
-**Все геттеры** - не работают из-за проблем с загрузкой данных
### 4. API тесты (`test_*_endpoints.py`)
-**Загрузка файлов** - эндпоинты работают
-**Геттеры** - не работают из-за проблем с данными
## Выявленные проблемы
### 1. Парсер svodka_ca
- **Проблема**: Возвращает `None` вместо DataFrame
- **Причина**: Парсер не может обработать тестовый файл `svodka_ca.xlsx`
- **Статус**: Требует исправления
### 2. Парсер svodka_pm
- **Проблема**: Возвращает словарь с `None` значениями
- **Причина**: Файлы в архиве `pm_plan.zip` не найдены (неправильные имена файлов)
- **Статус**: Требует исправления логики поиска файлов
### 3. Парсер monitoring_fuel
- **Проблема**: Возвращает пустой словарь
- **Причина**: Ошибки при загрузке файлов - "None of ['id'] are in the columns"
- **Статус**: Требует исправления логики обработки колонок
## Рекомендации
### Немедленные действия
1. **Исправить парсер svodka_ca** - проверить логику парсинга Excel файлов
2. **Исправить парсер svodka_pm** - проверить логику поиска файлов в архиве
3. **Исправить парсер monitoring_fuel** - проверить логику обработки колонок
### Долгосрочные улучшения
1. **Улучшить обработку ошибок** в парсерах
2. **Добавить валидацию данных** перед сохранением
3. **Создать более детальные тесты** для каждого парсера
## Техническая информация
### Локальный storage
- ✅ Создан `LocalStorageAdapter` для тестирования
- ✅ Поддерживает все операции: save, load, delete, list
- ✅ Автоматически очищается после тестов
### Инфраструктура тестов
- ✅ Pytest конфигурация с фикстурами
- ✅ Автоматическая регистрация парсеров
- ✅ Поддержка как API, так и прямых тестов
## Заключение
Система тестирования создана и работает корректно. Выявлены конкретные проблемы в парсерах, которые требуют исправления. После исправления парсеров все тесты должны пройти успешно.
**Следующий шаг**: Исправить выявленные проблемы в парсерах согласно результатам отладочных тестов.

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests package

View File

@@ -1,28 +1,97 @@
"""
Конфигурация pytest для тестов парсеров
"""
Конфигурация pytest для тестирования API эндпоинтов
"""
import pytest
import sys
import requests
import time
import os
import sys
from pathlib import Path
# Добавляем путь к проекту
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python_parser'))
# Добавляем путь к проекту для импорта модулей
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from adapters.local_storage import LocalStorageAdapter
# Базовый URL API
API_BASE_URL = "http://localhost:8000"
# Путь к тестовым данным
TEST_DATA_DIR = Path(__file__).parent / "test_data"
@pytest.fixture(scope="session")
def api_base_url():
"""Базовый URL для API"""
return API_BASE_URL
@pytest.fixture(scope="session")
def test_data_dir():
"""Путь к директории с тестовыми данными"""
return os.path.join(os.path.dirname(__file__), 'test_data')
"""Директория с тестовыми данными"""
return TEST_DATA_DIR
@pytest.fixture(scope="session")
def wait_for_api():
"""Ожидание готовности API"""
max_attempts = 30
for attempt in range(max_attempts):
try:
response = requests.get(f"{API_BASE_URL}/docs", timeout=5)
if response.status_code == 200:
print(f"✅ API готов после {attempt + 1} попыток")
return True
except requests.exceptions.RequestException:
pass
if attempt < max_attempts - 1:
time.sleep(2)
pytest.fail("❌ API не готов после 30 попыток")
@pytest.fixture
def mock_data():
"""Моковые данные для тестов"""
return {
'SNPZ': {
'data': 'test_data',
'records_count': 10
},
'KNPZ': {
'data': 'test_data_2',
'records_count': 5
}
}
def upload_file(test_data_dir):
"""Фикстура для загрузки файла"""
def _upload_file(filename):
file_path = test_data_dir / filename
if not file_path.exists():
pytest.skip(f"Файл {filename} не найден в {test_data_dir}")
return file_path
return _upload_file
@pytest.fixture(scope="session")
def local_storage():
"""Фикстура для локального storage"""
storage = LocalStorageAdapter("tests/local_storage")
yield storage
# Очищаем storage после всех тестов
storage.clear_all()
@pytest.fixture
def clean_storage(local_storage):
"""Фикстура для очистки storage перед каждым тестом"""
local_storage.clear_all()
yield local_storage
def make_api_request(url, method="GET", data=None, files=None, json_data=None):
"""Универсальная функция для API запросов"""
try:
if method.upper() == "GET":
response = requests.get(url, timeout=30)
elif method.upper() == "POST":
if files:
response = requests.post(url, files=files, timeout=30)
elif json_data:
response = requests.post(url, json=json_data, timeout=30)
else:
response = requests.post(url, data=data, timeout=30)
else:
raise ValueError(f"Неподдерживаемый метод: {method}")
return response
except requests.exceptions.RequestException as e:
pytest.fail(f"Ошибка API запроса: {e}")
@pytest.fixture
def api_request():
"""Фикстура для API запросов"""
return make_api_request

View File

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

View File

@@ -0,0 +1,20 @@
"""
Основной файл для запуска всех тестов API эндпоинтов
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту для импорта модулей
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
if __name__ == "__main__":
# Запуск всех тестов
pytest.main([
__file__.replace("test_all_endpoints.py", ""),
"-v", # подробный вывод
"--tb=short", # короткий traceback
"--color=yes", # цветной вывод
"-x", # остановка на первой ошибке
])

Binary file not shown.

Binary file not shown.

BIN
tests/test_data/pm_plan.zip Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,339 @@
"""
Тестирование геттеров с данными из локального storage
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from core.services import ReportService, PARSERS
from core.models import DataRequest, UploadRequest
from adapters.local_storage import LocalStorageAdapter
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
# Регистрируем парсеры
PARSERS.update({
'svodka_pm': SvodkaPMParser,
'svodka_ca': SvodkaCAParser,
'monitoring_fuel': MonitoringFuelParser,
})
class TestGettersWithLocalStorage:
"""Тестирование геттеров с локальным storage"""
@pytest.fixture(autouse=True)
def setup_storage(self, clean_storage):
"""Настройка локального storage для каждого теста"""
self.storage = clean_storage
self.report_service = ReportService(self.storage)
def test_svodka_pm_single_og_with_local_data(self, upload_file):
"""Тест svodka_pm single_og с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("pm_plan.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_pm',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='svodka_pm',
get_params={
'mode': 'single_og',
'id': 'SNPZ',
'codes': [78, 79],
'columns': ['ПП', 'СЭБ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ svodka_pm/single_og работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ svodka_pm/single_og не работает: {result.message}")
# Не делаем assert, чтобы увидеть все ошибки
def test_svodka_pm_total_ogs_with_local_data(self, upload_file):
"""Тест svodka_pm total_ogs с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("pm_plan.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_pm',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='svodka_pm',
get_params={
'mode': 'total_ogs',
'codes': [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
'columns': ['БП', 'ПП', 'СЭБ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ svodka_pm/total_ogs работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ svodka_pm/total_ogs не работает: {result.message}")
def test_svodka_ca_get_ca_data_with_local_data(self, upload_file):
"""Тест svodka_ca get_ca_data с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("svodka_ca.xlsx")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_ca',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='svodka_ca',
get_params={
'mode': 'get_ca_data',
'modes': ['fact', 'plan'],
'tables': ['table1', 'table2']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ svodka_ca/get_ca_data работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ svodka_ca/get_ca_data не работает: {result.message}")
def test_monitoring_fuel_get_total_by_columns_with_local_data(self, upload_file):
"""Тест monitoring_fuel get_total_by_columns с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='monitoring_fuel',
get_params={
'mode': 'total_by_columns',
'columns': ['total', 'normativ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ monitoring_fuel/get_total_by_columns работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ monitoring_fuel/get_total_by_columns не работает: {result.message}")
def test_monitoring_fuel_get_month_by_code_with_local_data(self, upload_file):
"""Тест monitoring_fuel get_month_by_code с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='monitoring_fuel',
get_params={
'mode': 'month_by_code',
'month': '02'
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ monitoring_fuel/get_month_by_code работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ monitoring_fuel/get_month_by_code не работает: {result.message}")
def test_monitoring_fuel_get_series_by_id_and_columns_with_local_data(self, upload_file):
"""Тест monitoring_fuel get_series_by_id_and_columns с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='monitoring_fuel',
get_params={
'mode': 'series_by_id_and_columns',
'columns': ['total', 'normativ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ monitoring_fuel/get_series_by_id_and_columns работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ monitoring_fuel/get_series_by_id_and_columns не работает: {result.message}")
def test_all_getters_with_loaded_data(self, upload_file):
"""Тест всех геттеров с предварительно загруженными данными"""
# Загружаем все данные
files_to_upload = [
("svodka_ca.xlsx", "svodka_ca", "file"),
("pm_plan.zip", "svodka_pm", "zip"),
("monitoring.zip", "monitoring_fuel", "zip")
]
for filename, report_type, upload_type in files_to_upload:
file_path = upload_file(filename)
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
upload_request = UploadRequest(
report_type=report_type,
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
result = self.report_service.upload_report(upload_request)
assert result.success is True, f"Загрузка {filename} не удалась: {result.message}"
print(f"{filename} загружен")
# Тестируем все геттеры
test_cases = [
# svodka_pm
{
'report_type': 'svodka_pm',
'mode': 'single_og',
'params': {'id': 'SNPZ', 'codes': [78, 79], 'columns': ['ПП', 'СЭБ']},
'name': 'svodka_pm/single_og'
},
{
'report_type': 'svodka_pm',
'mode': 'total_ogs',
'params': {'codes': [78, 79, 394, 395, 396, 397, 81, 82, 83, 84], 'columns': ['БП', 'ПП', 'СЭБ']},
'name': 'svodka_pm/total_ogs'
},
# svodka_ca
{
'report_type': 'svodka_ca',
'mode': 'get_ca_data',
'params': {'modes': ['fact', 'plan'], 'tables': ['table1', 'table2']},
'name': 'svodka_ca/get_ca_data'
},
# monitoring_fuel
{
'report_type': 'monitoring_fuel',
'mode': 'total_by_columns',
'params': {'columns': ['total', 'normativ']},
'name': 'monitoring_fuel/get_total_by_columns'
},
{
'report_type': 'monitoring_fuel',
'mode': 'month_by_code',
'params': {'month': '02'},
'name': 'monitoring_fuel/get_month_by_code'
},
{
'report_type': 'monitoring_fuel',
'mode': 'series_by_id_and_columns',
'params': {'columns': ['total', 'normativ']},
'name': 'monitoring_fuel/get_series_by_id_and_columns'
}
]
print("\n🧪 Тестирование всех геттеров с локальными данными:")
for test_case in test_cases:
request_params = test_case['params'].copy()
request_params['mode'] = test_case['mode']
data_request = DataRequest(
report_type=test_case['report_type'],
get_params=request_params
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"{test_case['name']}: работает")
else:
print(f"{test_case['name']}: {result.message}")
# Показываем содержимое storage
objects = self.storage.list_objects()
print(f"\n📊 Объекты в локальном storage: {len(objects)}")
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
if metadata:
print(f" 📁 {obj_id}: {metadata['shape']} колонки: {metadata['columns'][:3]}...")

View File

@@ -0,0 +1,102 @@
"""
Тесты для monitoring_fuel эндпоинтов
"""
import pytest
import requests
class TestMonitoringFuelEndpoints:
"""Тесты эндпоинтов monitoring_fuel"""
def test_monitoring_fuel_get_total_by_columns(self, wait_for_api, api_base_url):
"""Тест получения данных по колонкам и расчёт средних значений"""
# Пример из схемы MonitoringFuelTotalRequest
data = {
"columns": ["total", "normativ"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_total_by_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_total_by_columns работает: получены данные для колонок {data['columns']}")
def test_monitoring_fuel_get_month_by_code(self, wait_for_api, api_base_url):
"""Тест получения данных за месяц"""
# Пример из схемы MonitoringFuelMonthRequest
data = {
"month": "02"
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_month_by_code", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_month_by_code работает: получены данные за месяц {data['month']}")
def test_monitoring_fuel_get_series_by_id_and_columns(self, wait_for_api, api_base_url):
"""Тест получения временных рядов по ID и колонкам"""
# Пример из схемы MonitoringFuelSeriesRequest
data = {
"columns": ["total", "normativ"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_series_by_id_and_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_series_by_id_and_columns работает: получены временные ряды для колонок {data['columns']}")
def test_monitoring_fuel_get_total_by_columns_single_column(self, wait_for_api, api_base_url):
"""Тест получения данных по одной колонке"""
data = {
"columns": ["total"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_total_by_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_total_by_columns с одной колонкой работает: получены данные для колонки {data['columns'][0]}")
def test_monitoring_fuel_get_month_by_code_different_month(self, wait_for_api, api_base_url):
"""Тест получения данных за другой месяц"""
data = {
"month": "01"
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_month_by_code", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_month_by_code с другим месяцем работает: получены данные за месяц {data['month']}")
def test_monitoring_fuel_get_series_by_id_and_columns_single_column(self, wait_for_api, api_base_url):
"""Тест получения временных рядов по одной колонке"""
data = {
"columns": ["total"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_series_by_id_and_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_series_by_id_and_columns с одной колонкой работает: получены временные ряды для колонки {data['columns'][0]}")

View File

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

View File

@@ -0,0 +1,134 @@
"""
Прямое тестирование парсеров с локальным storage
Этот модуль тестирует парсеры напрямую, без API
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
from core.services import ReportService
from adapters.local_storage import LocalStorageAdapter
class TestParsersDirect:
"""Прямое тестирование парсеров с локальным storage"""
@pytest.fixture(autouse=True)
def setup_storage(self, clean_storage):
"""Настройка локального storage для каждого теста"""
self.storage = clean_storage
self.report_service = ReportService(self.storage)
def test_svodka_pm_parser_registration(self):
"""Тест регистрации парсера svodka_pm"""
parser = SvodkaPMParser()
getters = parser.get_available_getters()
assert "single_og" in getters
assert "total_ogs" in getters
# Проверяем параметры геттеров
single_og_getter = getters["single_og"]
assert "id" in single_og_getter["required_params"]
assert "codes" in single_og_getter["required_params"]
assert "columns" in single_og_getter["required_params"]
assert "search" in single_og_getter["optional_params"]
total_ogs_getter = getters["total_ogs"]
assert "codes" in total_ogs_getter["required_params"]
assert "columns" in total_ogs_getter["required_params"]
assert "search" in total_ogs_getter["optional_params"]
print("✅ svodka_pm парсер зарегистрирован корректно")
def test_svodka_ca_parser_registration(self):
"""Тест регистрации парсера svodka_ca"""
parser = SvodkaCAParser()
getters = parser.get_available_getters()
assert "get_ca_data" in getters
# Проверяем параметры геттера
getter = getters["get_ca_data"]
assert "modes" in getter["required_params"]
assert "tables" in getter["required_params"]
print("✅ svodka_ca парсер зарегистрирован корректно")
def test_monitoring_fuel_parser_registration(self):
"""Тест регистрации парсера monitoring_fuel"""
parser = MonitoringFuelParser()
getters = parser.get_available_getters()
assert "total_by_columns" in getters
assert "month_by_code" in getters
assert "series_by_id_and_columns" in getters
# Проверяем параметры геттеров
total_getter = getters["total_by_columns"]
assert "columns" in total_getter["required_params"]
month_getter = getters["month_by_code"]
assert "month" in month_getter["required_params"]
series_getter = getters["series_by_id_and_columns"]
assert "columns" in series_getter["required_params"]
print("✅ monitoring_fuel парсер зарегистрирован корректно")
def test_storage_operations(self):
"""Тест операций с локальным storage"""
import pandas as pd
# Создаем тестовый DataFrame
test_df = pd.DataFrame({
'col1': [1, 2, 3],
'col2': ['a', 'b', 'c']
})
# Сохраняем
success = self.storage.save_dataframe("test_object", test_df)
assert success is True
# Проверяем существование
exists = self.storage.object_exists("test_object")
assert exists is True
# Загружаем
loaded_df = self.storage.load_dataframe("test_object")
assert loaded_df is not None
assert loaded_df.shape == (3, 2)
assert list(loaded_df.columns) == ['col1', 'col2']
# Получаем метаданные
metadata = self.storage.get_object_metadata("test_object")
assert metadata is not None
assert metadata["shape"] == [3, 2]
# Получаем список объектов
objects = self.storage.list_objects()
assert "test_object" in objects
# Удаляем
delete_success = self.storage.delete_object("test_object")
assert delete_success is True
# Проверяем, что объект удален
exists_after = self.storage.object_exists("test_object")
assert exists_after is False
print("✅ Локальный storage работает корректно")
def test_report_service_with_local_storage(self):
"""Тест ReportService с локальным storage"""
# Проверяем, что ReportService может работать с локальным storage
assert self.report_service.storage is not None
assert hasattr(self.report_service.storage, 'save_dataframe')
assert hasattr(self.report_service.storage, 'load_dataframe')
print("✅ ReportService корректно работает с локальным storage")

View File

@@ -0,0 +1,58 @@
"""
Тесты для svodka_ca эндпоинтов
"""
import pytest
import requests
class TestSvodkaCAEndpoints:
"""Тесты эндпоинтов svodka_ca"""
def test_svodka_ca_get_ca_data(self, wait_for_api, api_base_url):
"""Тест получения данных из сводок СА"""
# Пример из схемы SvodkaCARequest
data = {
"modes": ["fact", "plan"],
"tables": ["table1", "table2"]
}
response = requests.post(f"{api_base_url}/svodka_ca/get_ca_data", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_ca/get_ca_data работает: получены данные для режимов {data['modes']}")
def test_svodka_ca_get_ca_data_single_mode(self, wait_for_api, api_base_url):
"""Тест получения данных из сводок СА для одного режима"""
data = {
"modes": ["fact"],
"tables": ["table1"]
}
response = requests.post(f"{api_base_url}/svodka_ca/get_ca_data", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_ca/get_ca_data с одним режимом работает: получены данные для режима {data['modes'][0]}")
def test_svodka_ca_get_ca_data_multiple_tables(self, wait_for_api, api_base_url):
"""Тест получения данных из сводок СА для нескольких таблиц"""
data = {
"modes": ["fact", "plan"],
"tables": ["table1", "table2", "table3"]
}
response = requests.post(f"{api_base_url}/svodka_ca/get_ca_data", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_ca/get_ca_data с несколькими таблицами работает: получены данные для {len(data['tables'])} таблиц")

View File

@@ -0,0 +1,79 @@
"""
Тесты для svodka_pm эндпоинтов
"""
import pytest
import requests
class TestSvodkaPMEndpoints:
"""Тесты эндпоинтов svodka_pm"""
def test_svodka_pm_single_og(self, wait_for_api, api_base_url):
"""Тест получения данных по одному ОГ"""
# Пример из схемы SvodkaPMSingleOGRequest
data = {
"id": "SNPZ",
"codes": [78, 79],
"columns": ["ПП", "СЭБ"]
}
response = requests.post(f"{api_base_url}/svodka_pm/single_og", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/single_og работает: получены данные для {data['id']}")
def test_svodka_pm_total_ogs(self, wait_for_api, api_base_url):
"""Тест получения данных по всем ОГ"""
# Пример из схемы SvodkaPMTotalOGsRequest
data = {
"codes": [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
"columns": ["БП", "ПП", "СЭБ"]
}
response = requests.post(f"{api_base_url}/svodka_pm/get_total_ogs", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/get_total_ogs работает: получены данные по всем ОГ")
def test_svodka_pm_single_og_with_search(self, wait_for_api, api_base_url):
"""Тест получения данных по одному ОГ с параметром search"""
data = {
"id": "SNPZ",
"codes": [78, 79],
"columns": ["ПП", "СЭБ"],
"search": "Итого"
}
response = requests.post(f"{api_base_url}/svodka_pm/single_og", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/single_og с search работает: получены данные для {data['id']} с фильтром")
def test_svodka_pm_total_ogs_with_search(self, wait_for_api, api_base_url):
"""Тест получения данных по всем ОГ с параметром search"""
data = {
"codes": [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
"columns": ["БП", "ПП", "СЭБ"],
"search": "Итого"
}
response = requests.post(f"{api_base_url}/svodka_pm/get_total_ogs", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/get_total_ogs с search работает: получены данные по всем ОГ с фильтром")

View File

@@ -0,0 +1,52 @@
"""
Тесты для эндпоинтов загрузки данных
"""
import pytest
import requests
from pathlib import Path
class TestUploadEndpoints:
"""Тесты эндпоинтов загрузки"""
def test_upload_svodka_ca(self, wait_for_api, upload_file, api_base_url):
"""Тест загрузки файла svodka_ca.xlsx"""
file_path = upload_file("svodka_ca.xlsx")
with open(file_path, 'rb') as f:
files = {'file': ('svodka_ca.xlsx', f, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
response = requests.post(f"{api_base_url}/svodka_ca/upload", files=files)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Загрузка не удалась: {result}"
print(f"✅ svodka_ca.xlsx загружен успешно: {result['message']}")
def test_upload_svodka_pm_plan(self, wait_for_api, upload_file, api_base_url):
"""Тест загрузки архива pm_plan.zip"""
file_path = upload_file("pm_plan.zip")
with open(file_path, 'rb') as f:
files = {'zip_file': ('pm_plan.zip', f, 'application/zip')}
response = requests.post(f"{api_base_url}/svodka_pm/upload-zip", files=files)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Загрузка не удалась: {result}"
print(f"✅ pm_plan.zip загружен успешно: {result['message']}")
def test_upload_monitoring_fuel(self, wait_for_api, upload_file, api_base_url):
"""Тест загрузки архива monitoring.zip"""
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
files = {'zip_file': ('monitoring.zip', f, 'application/zip')}
response = requests.post(f"{api_base_url}/monitoring_fuel/upload-zip", files=files)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Загрузка не удалась: {result}"
print(f"✅ monitoring.zip загружен успешно: {result['message']}")

View File

@@ -0,0 +1,183 @@
"""
Тестирование загрузки файлов с сохранением в локальный storage
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from core.services import ReportService, PARSERS
from core.models import UploadRequest
from adapters.local_storage import LocalStorageAdapter
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
# Регистрируем парсеры
PARSERS.update({
'svodka_pm': SvodkaPMParser,
'svodka_ca': SvodkaCAParser,
'monitoring_fuel': MonitoringFuelParser,
})
class TestUploadWithLocalStorage:
"""Тестирование загрузки файлов с локальным storage"""
@pytest.fixture(autouse=True)
def setup_storage(self, clean_storage):
"""Настройка локального storage для каждого теста"""
self.storage = clean_storage
self.report_service = ReportService(self.storage)
def test_upload_svodka_ca_to_local_storage(self, upload_file):
"""Тест загрузки svodka_ca.xlsx в локальный storage"""
file_path = upload_file("svodka_ca.xlsx")
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_ca',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
# Загружаем файл через ReportService
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка не удалась: {result.message}"
# Проверяем, что данные сохранились в локальном storage
objects = self.storage.list_objects()
assert len(objects) > 0, "Данные не сохранились в storage"
# Проверяем метаданные
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
assert metadata is not None, f"Метаданные для {obj_id} не найдены"
assert "shape" in metadata, f"Отсутствует shape в метаданных {obj_id}"
assert "columns" in metadata, f"Отсутствуют columns в метаданных {obj_id}"
print(f"✅ svodka_ca.xlsx загружен в локальный storage: {len(objects)} объектов")
print(f" Объекты: {objects}")
def test_upload_pm_plan_to_local_storage(self, upload_file):
"""Тест загрузки pm_plan.zip в локальный storage"""
file_path = upload_file("pm_plan.zip")
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_pm',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
# Загружаем архив через ReportService
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка не удалась: {result.message}"
# Проверяем, что данные сохранились в локальном storage
objects = self.storage.list_objects()
assert len(objects) > 0, "Данные не сохранились в storage"
# Проверяем метаданные
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
assert metadata is not None, f"Метаданные для {obj_id} не найдены"
assert "shape" in metadata, f"Отсутствует shape в метаданных {obj_id}"
assert "columns" in metadata, f"Отсутствуют columns в метаданных {obj_id}"
print(f"✅ pm_plan.zip загружен в локальный storage: {len(objects)} объектов")
print(f" Объекты: {objects}")
def test_upload_monitoring_to_local_storage(self, upload_file):
"""Тест загрузки monitoring.zip в локальный storage"""
file_path = upload_file("monitoring.zip")
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
# Загружаем архив через ReportService
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка не удалась: {result.message}"
# Проверяем, что данные сохранились в локальном storage
objects = self.storage.list_objects()
assert len(objects) > 0, "Данные не сохранились в storage"
# Проверяем метаданные
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
assert metadata is not None, f"Метаданные для {obj_id} не найдены"
assert "shape" in metadata, f"Отсутствует shape в метаданных {obj_id}"
assert "columns" in metadata, f"Отсутствуют columns в метаданных {obj_id}"
print(f"✅ monitoring.zip загружен в локальный storage: {len(objects)} объектов")
print(f" Объекты: {objects}")
def test_upload_all_files_sequence(self, upload_file):
"""Тест последовательной загрузки всех файлов"""
# Загружаем все файлы по очереди
files_to_upload = [
("svodka_ca.xlsx", "svodka_ca", "file"),
("pm_plan.zip", "svodka_pm", "zip"),
("monitoring.zip", "monitoring_fuel", "zip")
]
total_objects = 0
for filename, report_type, upload_type in files_to_upload:
file_path = upload_file(filename)
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type=report_type,
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка {filename} не удалась: {result.message}"
# Подсчитываем объекты
objects = self.storage.list_objects()
current_count = len(objects)
print(f"{filename} загружен: {current_count - total_objects} новых объектов")
total_objects = current_count
# Проверяем итоговое количество объектов
final_objects = self.storage.list_objects()
assert len(final_objects) > 0, "Ни один файл не был загружен"
print(f"Все файлы загружены. Итого объектов в storage: {len(final_objects)}")
print(f" Все объекты: {final_objects}")
# Выводим детальную информацию о каждом объекте
for obj_id in final_objects:
metadata = self.storage.get_object_metadata(obj_id)
if metadata:
print(f" 📊 {obj_id}: {metadata['shape']} колонки: {metadata['columns'][:5]}...")