diff --git a/python_parser/adapters/parsers/__init__.py b/python_parser/adapters/parsers/__init__.py index 2205596..eeb912e 100644 --- a/python_parser/adapters/parsers/__init__.py +++ b/python_parser/adapters/parsers/__init__.py @@ -2,10 +2,12 @@ from .monitoring_fuel import MonitoringFuelParser from .svodka_ca import SvodkaCAParser from .svodka_pm import SvodkaPMParser from .svodka_repair_ca import SvodkaRepairCAParser +from .statuses_repair_ca import StatusesRepairCAParser __all__ = [ 'MonitoringFuelParser', 'SvodkaCAParser', 'SvodkaPMParser', - 'SvodkaRepairCAParser' + 'SvodkaRepairCAParser', + 'StatusesRepairCAParser' ] diff --git a/python_parser/adapters/parsers/statuses_repair_ca.py b/python_parser/adapters/parsers/statuses_repair_ca.py new file mode 100644 index 0000000..19c5ecd --- /dev/null +++ b/python_parser/adapters/parsers/statuses_repair_ca.py @@ -0,0 +1,341 @@ +import pandas as pd +import os +import tempfile +import zipfile +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 + + +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]: + """Парсинг файла статусов ремонта СА""" + print(f"🔍 DEBUG: 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: + print(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] + print(f"🔍 DEBUG: Найден Excel файл в архиве: {excel_file}") + + return self._parse_excel_file(excel_file) + + def _parse_excel_file(self, file_path: str) -> Dict[str, Any]: + """Парсинг Excel файла""" + print(f"🔍 DEBUG: Парсинг Excel файла: {file_path}") + + # Парсим данные + df_statuses = self._parse_statuses_repair_ca(file_path, 0) + + if df_statuses.empty: + print("⚠️ Нет данных после парсинга") + 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 + + print(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): + """Обертка для получения статусов ремонта""" + print(f"🔍 DEBUG: _get_repair_statuses_wrapper вызван с параметрами: {params}") + + # Валидация параметров + validated_params = validate_params_with_schema(params, StatusesRepairCARequest) + + ids = validated_params.get('ids') + keys = validated_params.get('keys') + + print(f"🔍 DEBUG: Запрошенные ОГ: {ids}") + print(f"🔍 DEBUG: Запрошенные ключи: {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: + print("⚠️ Нет данных в парсере") + return [] + + print(f"🔍 DEBUG: Используем данные с {len(data_source)} записями") + + # Фильтруем данные + filtered_data = self._filter_statuses_data(data_source, ids, keys) + + print(f"🔍 DEBUG: Отфильтровано {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 \ No newline at end of file diff --git a/python_parser/app/main.py b/python_parser/app/main.py index 1e95f35..f4c1be7 100644 --- a/python_parser/app/main.py +++ b/python_parser/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI, File, UploadFile, HTTPException, status from fastapi.responses import JSONResponse from adapters.storage import MinIOStorageAdapter -from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, SvodkaRepairCAParser +from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, SvodkaRepairCAParser, StatusesRepairCAParser from core.models import UploadRequest, DataRequest from core.services import ReportService, PARSERS @@ -19,6 +19,7 @@ from app.schemas import ( MonitoringFuelMonthRequest, MonitoringFuelTotalRequest ) from app.schemas.svodka_repair_ca import SvodkaRepairCARequest +from app.schemas.statuses_repair_ca import StatusesRepairCARequest # Парсеры @@ -27,6 +28,7 @@ PARSERS.update({ 'svodka_ca': SvodkaCAParser, 'monitoring_fuel': MonitoringFuelParser, 'svodka_repair_ca': SvodkaRepairCAParser, + 'statuses_repair_ca': StatusesRepairCAParser, # 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser, }) @@ -730,6 +732,121 @@ async def get_svodka_repair_ca_data( raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") +@app.post("/statuses_repair_ca/upload", tags=[StatusesRepairCAParser.name], + summary="Загрузка отчета статусов ремонта СА") +async def upload_statuses_repair_ca( + file: UploadFile = File(...) +): + """ + Загрузка отчета статусов ремонта СА + + ### Поддерживаемые форматы: + - **Excel файлы**: `.xlsx`, `.xlsm`, `.xls` + - **ZIP архивы**: `.zip` (содержащие Excel файлы) + + ### Пример использования: + ```bash + curl -X POST "http://localhost:8000/statuses_repair_ca/upload" \ + -H "accept: application/json" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@statuses_repair_ca.xlsx" + ``` + """ + report_service = get_report_service() + + try: + # Проверяем тип файла + if not file.filename.endswith(('.xlsx', '.xlsm', '.xls', '.zip')): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или архивы (.zip)" + ) + + # Читаем содержимое файла + file_content = await file.read() + + # Создаем запрос на загрузку + upload_request = UploadRequest( + report_type='statuses_repair_ca', + file_content=file_content, + file_name=file.filename + ) + + # Загружаем отчет + result = report_service.upload_report(upload_request) + + if result.success: + return UploadResponse( + success=True, + message="Отчет успешно загружен и обработан", + report_id=result.object_id, + filename=file.filename + ).model_dump() + else: + return UploadErrorResponse( + success=False, + message=result.message, + error_code="ERR_UPLOAD", + details=None + ).model_dump() + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/statuses_repair_ca/get_data", tags=[StatusesRepairCAParser.name], + summary="Получение данных из отчета статусов ремонта СА") +async def get_statuses_repair_ca_data( + request_data: StatusesRepairCARequest +): + """ + Получение данных из отчета статусов ремонта СА + + ### Структура параметров: + - `ids`: **Массив ID ОГ** для фильтрации (опциональный) + - `keys`: **Массив ключей** для извлечения данных (опциональный) + + ### Пример тела запроса: + ```json + { + "ids": ["SNPZ", "KNPZ", "ANHK"], + "keys": [ + ["Дата начала ремонта"], + ["Готовность к КР", "Факт"], + ["Заключение договоров на СМР", "Договор", "%"] + ] + } + ``` + """ + report_service = get_report_service() + + try: + # Создаем запрос + request_dict = request_data.model_dump() + request = DataRequest( + report_type='statuses_repair_ca', + get_params=request_dict + ) + + # Получаем данные + result = report_service.get_data(request) + + if result.success: + return { + "success": True, + "data": result.data + } + else: + raise HTTPException(status_code=404, detail=result.message) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + # @app.post("/monitoring_fuel/upload", tags=[MonitoringFuelParser.name]) # async def upload_monitoring_fuel( # file: UploadFile = File(...), diff --git a/python_parser/app/schemas/statuses_repair_ca.py b/python_parser/app/schemas/statuses_repair_ca.py new file mode 100644 index 0000000..a2c6831 --- /dev/null +++ b/python_parser/app/schemas/statuses_repair_ca.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Union +from enum import Enum + +class StatusesRepairCARequest(BaseModel): + ids: Optional[List[str]] = Field( + None, + description="Массив ID ОГ для фильтрации (например, ['SNPZ', 'KNPZ'])", + example=["SNPZ", "KNPZ", "ANHK"] + ) + keys: Optional[List[List[str]]] = Field( + None, + description="Массив ключей для извлечения данных (например, [['Дата начала ремонта'], ['Готовность к КР', 'Факт']])", + example=[ + ["Дата начала ремонта"], + ["Отставание / опережение подготовки к КР", "Отставание / опережение"], + ["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"], + ["Готовность к КР", "Факт"], + ["Заключение договоров на СМР", "Договор", "%"], + ["Поставка МТР", "На складе, позиций", "%"] + ] + ) + + class Config: + json_schema_extra = { + "example": { + "ids": ["SNPZ", "KNPZ", "ANHK"], + "keys": [ + ["Дата начала ремонта"], + ["Готовность к КР", "Факт"], + ["Заключение договоров на СМР", "Договор", "%"] + ] + } + } \ No newline at end of file diff --git a/python_parser/core/services.py b/python_parser/core/services.py index 95a7e6a..b2171b5 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -139,6 +139,9 @@ class ReportService: 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_fuel': # Для monitoring_fuel определяем геттер из параметра mode getter_name = get_params.pop("mode", None) diff --git a/streamlit_app/streamlit_app.py b/streamlit_app/streamlit_app.py index 7ee43f5..07dbfe4 100644 --- a/streamlit_app/streamlit_app.py +++ b/streamlit_app/streamlit_app.py @@ -115,11 +115,12 @@ def main(): st.write(f"• {parser}") # Основные вкладки - по одной на каждый парсер - tab1, tab2, tab3, tab4 = st.tabs([ + tab1, tab2, tab3, tab4, tab5 = st.tabs([ "📊 Сводки ПМ", "🏭 Сводки СА", "⛽ Мониторинг топлива", - "🔧 Ремонт СА" + "🔧 Ремонт СА", + "📋 Статусы ремонта СА" ]) # Вкладка 1: Сводки ПМ - полный функционал @@ -493,6 +494,145 @@ def main(): else: st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + # Вкладка 5: Статусы ремонта СА + with tab5: + st.header("📋 Статусы ремонта СА") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + uploaded_file = st.file_uploader( + "Выберите файл статусов ремонта СА", + type=['xlsx', 'xlsm', 'xls', 'zip'], + key="statuses_repair_ca_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="statuses_repair_ca_upload_btn"): + with st.spinner("Загружаем файл..."): + file_data = uploaded_file.read() + result, status_code = upload_file_to_api("/statuses_repair_ca/upload", file_data, uploaded_file.name) + + if status_code == 200: + st.success("✅ Файл успешно загружен!") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result}") + + # Секция получения данных + st.subheader("📊 Получение данных") + + # Получаем доступные ОГ динамически + available_ogs = get_available_ogs("statuses_repair_ca") + + # Фильтр по ОГ + og_ids = st.multiselect( + "Выберите ОГ (оставьте пустым для всех)", + available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback + key="statuses_repair_ca_og_ids" + ) + + # Предустановленные ключи для извлечения + st.subheader("🔑 Ключи для извлечения данных") + + # Основные ключи + include_basic_keys = st.checkbox("Основные данные", value=True, key="statuses_basic_keys") + include_readiness_keys = st.checkbox("Готовность к КР", value=True, key="statuses_readiness_keys") + include_contract_keys = st.checkbox("Заключение договоров", value=True, key="statuses_contract_keys") + include_supply_keys = st.checkbox("Поставка МТР", value=True, key="statuses_supply_keys") + + # Формируем ключи на основе выбора + keys = [] + if include_basic_keys: + keys.append(["Дата начала ремонта"]) + keys.append(["Отставание / опережение подготовки к КР", "Отставание / опережение"]) + keys.append(["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"]) + + if include_readiness_keys: + keys.append(["Готовность к КР", "Факт"]) + + if include_contract_keys: + keys.append(["Заключение договоров на СМР", "Договор", "%"]) + + if include_supply_keys: + keys.append(["Поставка МТР", "На складе, позиций", "%"]) + + # Кнопка получения данных + if st.button("📊 Получить данные", key="statuses_repair_ca_get_data_btn"): + if not keys: + st.warning("⚠️ Выберите хотя бы одну группу ключей для извлечения") + else: + with st.spinner("Получаем данные..."): + request_data = { + "ids": og_ids if og_ids else None, + "keys": keys + } + + result, status_code = make_api_request("/statuses_repair_ca/get_data", request_data) + + if status_code == 200 and result.get("success"): + st.success("✅ Данные успешно получены!") + + data = result.get("data", {}).get("value", []) + if data: + # Отображаем данные в виде таблицы + if isinstance(data, list) and len(data) > 0: + # Преобразуем в DataFrame для лучшего отображения + df_data = [] + for item in data: + row = { + "ID": item.get("id", ""), + "Название": item.get("name", ""), + } + + # Добавляем основные поля + if "Дата начала ремонта" in item: + row["Дата начала ремонта"] = item["Дата начала ремонта"] + + # Добавляем готовность к КР + if "Готовность к КР" in item: + readiness = item["Готовность к КР"] + if isinstance(readiness, dict) and "Факт" in readiness: + row["Готовность к КР (Факт)"] = readiness["Факт"] + + # Добавляем отставание/опережение + if "Отставание / опережение подготовки к КР" in item: + delay = item["Отставание / опережение подготовки к КР"] + if isinstance(delay, dict): + if "Отставание / опережение" in delay: + row["Отставание/опережение"] = delay["Отставание / опережение"] + if "Динамика за прошедшую неделю" in delay: + row["Динамика за неделю"] = delay["Динамика за прошедшую неделю"] + + # Добавляем договоры + if "Заключение договоров на СМР" in item: + contracts = item["Заключение договоров на СМР"] + if isinstance(contracts, dict) and "Договор" in contracts: + contract = contracts["Договор"] + if isinstance(contract, dict) and "%" in contract: + row["Договоры (%)"] = contract["%"] + + # Добавляем поставки МТР + if "Поставка МТР" in item: + supply = item["Поставка МТР"] + if isinstance(supply, dict) and "На складе, позиций" in supply: + warehouse = supply["На складе, позиций"] + if isinstance(warehouse, dict) and "%" in warehouse: + row["МТР на складе (%)"] = warehouse["%"] + + df_data.append(row) + + if df_data: + df = pd.DataFrame(df_data) + st.dataframe(df, use_container_width=True) + else: + st.info("📋 Нет данных для отображения") + else: + st.json(result) + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + # Футер st.markdown("---") st.markdown("### 📚 Документация API") @@ -508,6 +648,7 @@ def main(): - 🏭 Парсинг сводок СА - ⛽ Мониторинг топлива - 🔧 Управление ремонтными работами СА + - 📋 Мониторинг статусов ремонта СА **Технологии:** - FastAPI