diff --git a/python_parser/adapters/parsers/__init__.py b/python_parser/adapters/parsers/__init__.py index 0502ef8..fa8534d 100644 --- a/python_parser/adapters/parsers/__init__.py +++ b/python_parser/adapters/parsers/__init__.py @@ -4,6 +4,7 @@ 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', @@ -11,5 +12,6 @@ __all__ = [ 'SvodkaCAParser', 'SvodkaPMParser', 'SvodkaRepairCAParser', - 'StatusesRepairCAParser' + 'StatusesRepairCAParser', + 'OperSpravkaTechPosParser' ] diff --git a/python_parser/adapters/parsers/oper_spravka_tech_pos.py b/python_parser/adapters/parsers/oper_spravka_tech_pos.py new file mode 100644 index 0000000..167a795 --- /dev/null +++ b/python_parser/adapters/parsers/oper_spravka_tech_pos.py @@ -0,0 +1,281 @@ +import os +import tempfile +import zipfile +import pandas as pd +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 + + +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 архив с файлами операционных справок технологических позиций""" + print(f"🔍 DEBUG: 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) + print(f"🔍 DEBUG: Создан DataFrame с {len(df)} записями") + return df + else: + print("🔍 DEBUG: Возвращаем пустой DataFrame") + return pd.DataFrame() + + def _parse_zip_archive(self, zip_path: str) -> Dict[str, pd.DataFrame]: + """Парсит ZIP архив с файлами операционных справок""" + print(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("В архиве не найдены файлы операционных справок технологических позиций") + + print(f"📁 Найдено {len(tech_pos_files)} файлов операционных справок") + + # Обрабатываем каждый файл + all_data = {} + for file_path in tech_pos_files: + print(f"📁 Обработка файла: {file_path}") + + # Извлекаем ID ОГ из имени файла + filename = os.path.basename(file_path) + og_id = self._extract_og_id_from_filename(filename) + print(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) + print(f"📅 Актуальный лист: {actual_sheet}") + + # Находим заголовок + header_row = self._find_header_row(file_path, actual_sheet) + print(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: + print(f"⚠️ Нет данных в файле {file_path}") + return {} + + except Exception as e: + print(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: + print(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(): + print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") + return idx + 1 # возвращаем индекс строки (0-based), который будет использован как `header=` + + raise ValueError(f"Не найдена строка с заголовком '{search_value}'.") + except Exception as e: + print(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) + ) + + print(f"🔍 DEBUG: Прочитано {len(df_temp)} строк из Excel") + print(f"🔍 DEBUG: Колонки: {list(df_temp.columns)}") + + # Фильтруем по валидным процессам + df_cleaned = df_temp[ + df_temp['Процесс'].str.strip().isin(valid_processes) & + df_temp['Процесс'].notna() + ].copy() + + print(f"🔍 DEBUG: После фильтрации осталось {len(df_cleaned)} строк") + + if df_cleaned.empty: + print("⚠️ Нет данных после фильтрации по процессам") + print(f"🔍 DEBUG: Доступные процессы в данных: {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) + print(f"🔍 DEBUG: Добавлены ID установок: {df_cleaned['id'].unique()}") + else: + print("⚠️ Колонка 'Установка' не найдена") + + print(f"✅ Получено {len(df_cleaned)} записей") + return df_cleaned + + except Exception as e: + print(f"❌ Ошибка при парсинге данных: {e}") + return pd.DataFrame() + + def _get_tech_pos_wrapper(self, params: Dict[str, Any] = None) -> str: + """Обертка для получения данных технологических позиций""" + print(f"🔍 DEBUG: _get_tech_pos_wrapper вызван с параметрами: {params}") + + # Получаем ID ОГ из параметров + og_id = params.get('id') if params else None + if not og_id: + print("❌ Не указан ID ОГ") + return "{}" + + # Получаем данные + tech_pos_data = {} + if hasattr(self, 'df') and self.df is not None and not self.df.empty: + # Данные из MinIO + print(f"🔍 DEBUG: Ищем данные для ОГ '{og_id}' в DataFrame с {len(self.df)} записями") + available_ogs = self.df['id'].tolist() + print(f"🔍 DEBUG: Доступные ОГ в данных: {available_ogs}") + + for _, row in self.df.iterrows(): + if row['id'] == og_id: + tech_pos_data = row['data'] + print(f"✅ Найдены данные для ОГ '{og_id}': {len(tech_pos_data)} записей") + break + else: + print(f"❌ Данные для ОГ '{og_id}' не найдены") + elif hasattr(self, 'data_dict') and self.data_dict: + # Локальные данные + print(f"🔍 DEBUG: Ищем данные для ОГ '{og_id}' в data_dict") + available_ogs = list(self.data_dict.keys()) + print(f"🔍 DEBUG: Доступные ОГ в data_dict: {available_ogs}") + + if og_id in self.data_dict: + tech_pos_data = self.data_dict[og_id].to_dict(orient='records') + print(f"✅ Найдены данные для ОГ '{og_id}': {len(tech_pos_data)} записей") + else: + print(f"❌ Данные для ОГ '{og_id}' не найдены в data_dict") + + # Конвертируем в список записей + try: + if isinstance(tech_pos_data, pd.DataFrame): + # Если это DataFrame, конвертируем в список словарей + result_list = tech_pos_data.to_dict(orient='records') + print(f"🔍 DEBUG: Конвертировано в список: {len(result_list)} записей") + return result_list + elif isinstance(tech_pos_data, list): + # Если уже список, возвращаем как есть + print(f"🔍 DEBUG: Уже список: {len(tech_pos_data)} записей") + return tech_pos_data + else: + print(f"🔍 DEBUG: Неожиданный тип данных: {type(tech_pos_data)}") + return [] + except Exception as e: + print(f"❌ Ошибка при конвертации данных: {e}") + return [] \ No newline at end of file diff --git a/python_parser/app/main.py b/python_parser/app/main.py index 8c63277..161eccb 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, MonitoringTarParser, SvodkaRepairCAParser, StatusesRepairCAParser +from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, MonitoringTarParser, SvodkaRepairCAParser, StatusesRepairCAParser, OperSpravkaTechPosParser from core.models import UploadRequest, DataRequest from core.services import ReportService, PARSERS @@ -18,6 +18,7 @@ from app.schemas import ( SvodkaCARequest, MonitoringFuelMonthRequest, MonitoringFuelTotalRequest ) +from app.schemas.oper_spravka_tech_pos import OperSpravkaTechPosRequest, OperSpravkaTechPosResponse from app.schemas.svodka_repair_ca import SvodkaRepairCARequest from app.schemas.statuses_repair_ca import StatusesRepairCARequest from app.schemas.monitoring_tar import MonitoringTarRequest, MonitoringTarFullRequest @@ -31,6 +32,7 @@ PARSERS.update({ 'monitoring_tar': MonitoringTarParser, 'svodka_repair_ca': SvodkaRepairCAParser, 'statuses_repair_ca': StatusesRepairCAParser, + 'oper_spravka_tech_pos': OperSpravkaTechPosParser, # 'svodka_plan_sarnpz': SvodkaPlanSarnpzParser, }) @@ -124,8 +126,8 @@ async def get_available_ogs(parser_name: str): parser_class = PARSERS[parser_name] - # Для svodka_repair_ca возвращаем ОГ из загруженных данных - if parser_name == "svodka_repair_ca": + # Для парсеров с данными в MinIO возвращаем ОГ из загруженных данных + if parser_name in ["svodka_repair_ca", "oper_spravka_tech_pos"]: try: # Создаем экземпляр сервиса и загружаем данные из MinIO report_service = get_report_service() @@ -135,10 +137,22 @@ async def get_available_ogs(parser_name: str): # Если данные загружены, извлекаем ОГ из них if loaded_data is not None and hasattr(loaded_data, 'data') and loaded_data.data is not None: # Для 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} + 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: print(f"⚠️ Ошибка при получении ОГ: {e}") import traceback @@ -1309,5 +1323,114 @@ async def get_monitoring_tar_full_data(): raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") +# ====== OPER SPRAVKA TECH POS ENDPOINTS ====== + +@app.post("/oper_spravka_tech_pos/upload", tags=[OperSpravkaTechPosParser.name], + summary="Загрузка отчета операционной справки технологических позиций") +async def upload_oper_spravka_tech_pos( + file: UploadFile = File(...) +): + """Загрузка и обработка отчета операционной справки технологических позиций + + ### Поддерживаемые форматы: + - **ZIP архивы** с файлами операционных справок + + ### Структура данных: + - Обрабатывает ZIP архивы с файлами операционных справок по технологическим позициям + - Извлекает данные по процессам: Первичная переработка, Гидроочистка топлив, Риформирование, Изомеризация + - Возвращает данные по установкам с планом и фактом + """ + report_service = get_report_service() + + try: + # Проверяем тип файла - только ZIP архивы + if not file.filename.endswith('.zip'): + raise HTTPException( + status_code=400, + detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)" + ) + + # Читаем содержимое файла + file_content = await file.read() + + # Создаем запрос на загрузку + upload_request = UploadRequest( + report_type="oper_spravka_tech_pos", + file_name=file.filename, + file_content=file_content, + parse_params={} + ) + + # Загружаем и обрабатываем отчет + result = report_service.upload_report(upload_request) + + if result.success: + return UploadResponse( + success=True, + message="Отчет успешно загружен и обработан", + object_id=result.object_id + ) + else: + return UploadErrorResponse( + success=False, + message=result.message, + error_code="ERR_UPLOAD", + details=None + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/oper_spravka_tech_pos/get_data", tags=[OperSpravkaTechPosParser.name], + summary="Получение данных операционной справки технологических позиций", + response_model=OperSpravkaTechPosResponse) +async def get_oper_spravka_tech_pos_data(request: OperSpravkaTechPosRequest): + """Получение данных операционной справки технологических позиций по ОГ + + ### Параметры: + - **id** (str): ID ОГ (например, 'SNPZ', 'KNPZ') + + ### Возвращает: + - Данные по технологическим позициям для указанного ОГ + - Включает информацию о процессах, установках, плане и факте + """ + report_service = get_report_service() + + try: + # Создаем запрос на получение данных + data_request = DataRequest( + report_type="oper_spravka_tech_pos", + get_params={"id": request.id} + ) + + # Получаем данные + result = report_service.get_data(data_request) + + if result.success: + # Извлекаем данные из результата + value_data = result.data.get("value", []) if isinstance(result.data.get("value"), list) else [] + print(f"🔍 DEBUG: API возвращает данные: {type(value_data)}, длина: {len(value_data) if isinstance(value_data, (list, dict)) else 'N/A'}") + + return OperSpravkaTechPosResponse( + success=True, + data=value_data, + message="Данные успешно получены" + ) + else: + return OperSpravkaTechPosResponse( + success=False, + data=None, + message=result.message + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/python_parser/app/schemas/oper_spravka_tech_pos.py b/python_parser/app/schemas/oper_spravka_tech_pos.py new file mode 100644 index 0000000..6268f28 --- /dev/null +++ b/python_parser/app/schemas/oper_spravka_tech_pos.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, Field +from typing import Optional, List + + +class OperSpravkaTechPosRequest(BaseModel): + """Запрос для получения данных операционной справки технологических позиций""" + id: str = Field(..., description="ID ОГ (например, 'SNPZ', 'KNPZ')") + + class Config: + json_schema_extra = { + "example": { + "id": "SNPZ" + } + } + + +class OperSpravkaTechPosResponse(BaseModel): + """Ответ с данными операционной справки технологических позиций""" + success: bool = Field(..., description="Статус успешности операции") + data: Optional[List[dict]] = Field(None, description="Данные по технологическим позициям") + message: Optional[str] = Field(None, description="Сообщение о результате операции") + + class Config: + json_schema_extra = { + "example": { + "success": True, + "data": [ + { + "Процесс": "Первичная переработка", + "Установка": "ЭЛОУ-АВТ-6", + "План, т": 14855.0, + "Факт, т": 15149.647, + "id": "SNPZ.EAVT6" + } + ], + "message": "Данные успешно получены" + } + } \ No newline at end of file diff --git a/python_parser/core/services.py b/python_parser/core/services.py index e121d8e..0e6becf 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -178,6 +178,9 @@ class ReportService: 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) diff --git a/streamlit_app/streamlit_app.py b/streamlit_app/streamlit_app.py index 0bb2313..22513ab 100644 --- a/streamlit_app/streamlit_app.py +++ b/streamlit_app/streamlit_app.py @@ -115,13 +115,14 @@ def main(): st.write(f"• {parser}") # Основные вкладки - по одной на каждый парсер - tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([ + tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs([ "📊 Сводки ПМ", "🏭 Сводки СА", "⛽ Мониторинг топлива", "🔧 Ремонт СА", "📋 Статусы ремонта СА", - "⚡ Мониторинг ТЭР" + "⚡ Мониторинг ТЭР", + "🏭 Операционные справки" ]) # Вкладка 1: Сводки ПМ - полный функционал @@ -740,6 +741,83 @@ def main(): else: st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + # Вкладка 7: Операционные справки технологических позиций + with tab7: + st.header("🏭 Операционные справки технологических позиций") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + + uploaded_file = st.file_uploader( + "Выберите ZIP архив с файлами операционных справок", + type=['zip'], + key="oper_spravka_tech_pos_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="oper_spravka_tech_pos_upload_btn"): + with st.spinner("Загружаем файл..."): + file_data = uploaded_file.read() + result, status_code = upload_file_to_api("/oper_spravka_tech_pos/upload", file_data, uploaded_file.name) + + if status_code == 200: + st.success("✅ Файл успешно загружен!") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result}") + + st.markdown("---") + + # Секция получения данных + st.subheader("📊 Получение данных") + + # Выбор формата отображения + display_format = st.radio( + "Формат отображения:", + ["JSON", "Таблица"], + key="oper_spravka_tech_pos_display_format", + horizontal=True + ) + + # Получаем доступные ОГ динамически + available_ogs = get_available_ogs("oper_spravka_tech_pos") + + # Выбор ОГ + og_id = st.selectbox( + "Выберите ОГ:", + available_ogs if available_ogs else ["SNPZ", "KNPZ", "ANHK", "BASH", "UNH", "NOV"], + key="oper_spravka_tech_pos_og_id" + ) + + if st.button("📊 Получить данные", key="oper_spravka_tech_pos_get_data_btn"): + with st.spinner("Получаем данные..."): + request_data = {"id": og_id} + result, status_code = make_api_request("/oper_spravka_tech_pos/get_data", request_data) + + if status_code == 200 and result.get("success"): + st.success("✅ Данные успешно получены!") + + # Показываем данные + data = result.get("data", []) + + if data and len(data) > 0: + st.subheader("📋 Результат:") + + if display_format == "JSON": + # Отображаем как JSON + st.json(data) + else: + # Отображаем как таблицу + if isinstance(data, list) and data: + df = pd.DataFrame(data) + st.dataframe(df, use_container_width=True) + else: + st.write("Нет данных") + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + # Футер st.markdown("---") st.markdown("### 📚 Документация API")