diff --git a/PARSER_DEVELOPMENT_GUIDE.md b/PARSER_DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..c9456c4 --- /dev/null +++ b/PARSER_DEVELOPMENT_GUIDE.md @@ -0,0 +1,1002 @@ +# 📚 Руководство по разработке парсеров + +Полное руководство по созданию новых парсеров для системы NIN Excel Parsers API. + +## 📋 Содержание + +1. [Архитектура системы](#архитектура-системы) +2. [Структура проекта](#структура-проекта) +3. [Создание нового парсера](#создание-нового-парсера) +4. [Регистрация парсера](#регистрация-парсера) +5. [Создание API эндпоинтов](#создание-api-эндпоинтов) +6. [Интеграция с Streamlit](#интеграция-с-streamlit) +7. [Тестирование](#тестирование) +8. [Лучшие практики](#лучшие-практики) +9. [Примеры](#примеры) + +--- + +## 🏗️ Архитектура системы + +### Hexagonal Architecture + +Система построена на принципах **Hexagonal Architecture** (Ports & Adapters): + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Streamlit UI │ │ FastAPI │ │ MinIO Storage │ +│ (Adapter) │◄──►│ (Application) │◄──►│ (Adapter) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ ParserPort │ + │ (Core) │ + └─────────────────┘ + ▲ + │ + ┌─────────────────┐ + │ Parser │ + │ (Adapter) │ + └─────────────────┘ +``` + +### Ключевые компоненты + +- **ParserPort** - базовый класс для всех парсеров +- **ReportService** - сервис для управления отчетами +- **MinIOStorageAdapter** - адаптер для хранения данных +- **FastAPI** - веб-фреймворк для API +- **Streamlit** - веб-интерфейс + +--- + +## 📁 Структура проекта + +``` +python_parser/ +├── adapters/ +│ ├── parsers/ # Парсеры (адаптеры) +│ │ ├── __init__.py +│ │ ├── monitoring_fuel.py +│ │ ├── monitoring_tar.py +│ │ ├── svodka_ca.py +│ │ ├── svodka_pm.py +│ │ ├── svodka_repair_ca.py +│ │ └── statuses_repair_ca.py +│ ├── pconfig.py # Конфигурация парсеров +│ └── storage.py # Адаптер хранилища +├── app/ +│ ├── main.py # FastAPI приложение +│ └── schemas/ # Pydantic схемы +│ ├── monitoring_fuel.py +│ ├── monitoring_tar.py +│ ├── svodka_ca.py +│ ├── svodka_pm.py +│ ├── svodka_repair_ca.py +│ └── statuses_repair_ca.py +├── core/ +│ ├── models.py # Модели данных +│ ├── ports.py # Базовые порты +│ ├── schema_utils.py # Утилиты схем +│ └── services.py # Сервисы +└── requirements.txt + +streamlit_app/ +├── streamlit_app.py # Streamlit интерфейс +└── requirements.txt +``` + +--- + +## 🔧 Создание нового парсера + +### 1. Создание Pydantic схем + +Создайте файл схемы в `python_parser/app/schemas/your_parser.py`: + +```python +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from enum import Enum + +class YourParserMode(str, Enum): + """Режимы работы парсера""" + MODE1 = "mode1" + MODE2 = "mode2" + +class YourParserRequest(BaseModel): + """Схема запроса для основного геттера""" + param1: Optional[str] = Field( + None, + description="Описание параметра 1", + example="example_value" + ) + param2: Optional[List[str]] = Field( + None, + description="Описание параметра 2", + example=["value1", "value2"] + ) + mode: Optional[YourParserMode] = Field( + None, + description="Режим работы парсера", + example="mode1" + ) + + class Config: + json_schema_extra = { + "example": { + "param1": "example_value", + "param2": ["value1", "value2"], + "mode": "mode1" + } + } + +class YourParserFullRequest(BaseModel): + """Схема запроса для получения всех данных""" + # Пустая схема - возвращает все данные без фильтрации + pass + + class Config: + json_schema_extra = { + "example": {} + } +``` + +### 2. Создание парсера + +Создайте файл парсера в `python_parser/adapters/parsers/your_parser.py`: + +```python +import pandas as pd +import os +import zipfile +import tempfile +from typing import Dict, Any, Optional +from core.ports import ParserPort +from adapters.pconfig import find_header_row, data_to_json +from app.schemas.your_parser import YourParserRequest, YourParserFullRequest + + +class YourParser(ParserPort): + """Парсер для вашего типа данных""" + + name = "your_parser" # Уникальное имя парсера + + def __init__(self): + super().__init__() + # Регистрируем геттеры + self.register_getter("get_data", YourParserRequest, self._get_data_wrapper) + self.register_getter("get_full_data", YourParserFullRequest, self._get_full_data_wrapper) + + # Данные парсера + self.data_dict = {} + + def parse(self, file_path: str, params: dict) -> Dict[str, Any]: + """Основной метод парсинга""" + print(f"🔍 DEBUG: YourParser.parse вызван с файлом: {file_path}") + + try: + # Проверяем тип файла (пример для ZIP-only парсера) + if not file_path.endswith('.zip'): + raise ValueError(f"Неподдерживаемый тип файла: {file_path}. Ожидается только ZIP архив.") + + # Обрабатываем ZIP архив + result = self._parse_zip_archive(file_path) + + # Сохраняем результат + self.data_dict = result + print(f"✅ Парсинг завершен. Получено {len(result)} записей") + return result + + except Exception as e: + print(f"❌ Ошибка при парсинге: {e}") + raise + + def _parse_zip_archive(self, zip_path: str) -> Dict[str, Any]: + """Парсинг 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) + + # Ищем нужные файлы + target_files = [] + for root, dirs, files in os.walk(temp_dir): + for file in files: + if file.endswith(('.xlsx', '.xls')): + target_files.append(os.path.join(root, file)) + + if not target_files: + raise ValueError("В архиве не найдены поддерживаемые файлы") + + # Парсим все найденные файлы + all_data = {} + for file_path in target_files: + file_data = self._parse_single_file(file_path) + # Объединяем данные + all_data.update(file_data) + + return all_data + + def _parse_single_file(self, file_path: str) -> Dict[str, Any]: + """Парсинг одного файла""" + print(f"📁 Обработка файла: {file_path}") + + try: + # Читаем Excel файл + excel_file = pd.ExcelFile(file_path) + available_sheets = excel_file.sheet_names + + # Обрабатываем нужные листы + result_data = {} + for sheet_name in available_sheets: + if self._should_process_sheet(sheet_name): + sheet_data = self._parse_sheet(file_path, sheet_name) + result_data.update(sheet_data) + + return result_data + + except Exception as e: + print(f"❌ Ошибка при обработке файла {file_path}: {e}") + return {} + + def _should_process_sheet(self, sheet_name: str) -> bool: + """Определяет, нужно ли обрабатывать лист""" + # Логика фильтрации листов + return True # Или ваша логика + + def _parse_sheet(self, file_path: str, sheet_name: str) -> Dict[str, Any]: + """Парсинг конкретного листа""" + try: + # Находим заголовок + header_num = find_header_row(file_path, sheet_name, search_value="1") + if header_num is None: + print(f"❌ Не найден заголовок в листе {sheet_name}") + return {} + + # Читаем данные + df = pd.read_excel( + file_path, + sheet_name=sheet_name, + header=header_num, + index_col=None + ) + + # Обрабатываем данные + processed_data = self._process_dataframe(df, sheet_name) + + return processed_data + + except Exception as e: + print(f"❌ Ошибка при обработке листа {sheet_name}: {e}") + return {} + + def _process_dataframe(self, df: pd.DataFrame, sheet_name: str) -> Dict[str, Any]: + """Обработка DataFrame""" + # Ваша логика обработки данных + return {"sheet_name": sheet_name, "data": df.to_dict('records')} + + def _get_data_wrapper(self, params: dict) -> Dict[str, Any]: + """Обертка для основного геттера""" + print(f"🔍 DEBUG: _get_data_wrapper вызван с параметрами: {params}") + + # Валидируем параметры + validated_params = YourParserRequest(**params) + + # Получаем данные из парсера + data_source = self._get_data_source() + + if not data_source: + print("⚠️ Нет данных в парсере") + return {} + + # Фильтруем данные по параметрам + filtered_data = self._filter_data(data_source, validated_params) + + # Конвертируем в JSON + try: + result_json = data_to_json(filtered_data) + return result_json + except Exception as e: + print(f"❌ Ошибка при конвертации данных в JSON: {e}") + return {} + + def _get_full_data_wrapper(self, params: dict) -> Dict[str, Any]: + """Обертка для геттера всех данных""" + print(f"🔍 DEBUG: _get_full_data_wrapper вызван с параметрами: {params}") + + # Получаем данные из парсера + data_source = self._get_data_source() + + if not data_source: + print("⚠️ Нет данных в парсере") + return {} + + # Конвертируем все данные в JSON + try: + result_json = data_to_json(data_source) + return result_json + except Exception as e: + print(f"❌ Ошибка при конвертации данных в JSON: {e}") + return {} + + def _get_data_source(self) -> Dict[str, Any]: + """Получает источник данных""" + if hasattr(self, 'df') and self.df is not None: + # Данные загружены из MinIO + if isinstance(self.df, dict): + return self.df + else: + return {} + elif hasattr(self, 'data_dict') and self.data_dict: + # Данные из локального парсинга + return self.data_dict + else: + return {} + + def _filter_data(self, data_source: Dict[str, Any], params: YourParserRequest) -> Dict[str, Any]: + """Фильтрует данные по параметрам""" + # Ваша логика фильтрации + return data_source +``` + +--- + +## 📝 Регистрация парсера + +### 1. Регистрация в __init__.py + +Добавьте импорт в `python_parser/adapters/parsers/__init__.py`: + +```python +from .monitoring_fuel import MonitoringFuelParser +from .monitoring_tar import MonitoringTarParser +from .your_parser import YourParser # Добавить эту строку +from .svodka_ca import SvodkaCAParser +from .svodka_pm import SvodkaPMParser +from .svodka_repair_ca import SvodkaRepairCAParser +from .statuses_repair_ca import StatusesRepairCAParser + +__all__ = [ + 'MonitoringFuelParser', + 'MonitoringTarParser', + 'YourParser', # Добавить эту строку + 'SvodkaCAParser', + 'SvodkaPMParser', + 'SvodkaRepairCAParser', + 'StatusesRepairCAParser' +] +``` + +### 2. Регистрация в main.py + +Добавьте импорт и регистрацию в `python_parser/app/main.py`: + +```python +# Импорты +from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser, MonitoringTarParser, YourParser, SvodkaRepairCAParser, StatusesRepairCAParser + +from app.schemas.your_parser import YourParserRequest, YourParserFullRequest + +# Регистрация парсера +PARSERS.update({ + 'svodka_pm': SvodkaPMParser, + 'svodka_ca': SvodkaCAParser, + 'monitoring_fuel': MonitoringFuelParser, + 'monitoring_tar': MonitoringTarParser, + 'your_parser': YourParser, # Добавить эту строку + 'svodka_repair_ca': SvodkaRepairCAParser, + 'statuses_repair_ca': StatusesRepairCAParser, +}) +``` + +### 3. Регистрация в services.py + +Добавьте логику выбора геттера в `python_parser/core/services.py`: + +```python +elif request.report_type == 'monitoring_tar': + # Для monitoring_tar используем геттер get_tar_data + getter_name = 'get_tar_data' +elif request.report_type == 'your_parser': # Добавить эту секцию + # Для your_parser используем геттер get_data + getter_name = 'get_data' +elif request.report_type == 'monitoring_fuel': +``` + +--- + +## 🌐 Создание API эндпоинтов + +Добавьте эндпоинты в `python_parser/app/main.py`: + +```python +# ====== YOUR PARSER ENDPOINTS ====== + +@app.post("/your_parser/upload", tags=[YourParser.name], + summary="Загрузка отчета вашего типа") +async def upload_your_parser( + 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='your_parser', + 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("/your_parser/get_data", tags=[YourParser.name], + summary="Получение данных из отчета") +async def get_your_parser_data( + request_data: YourParserRequest +): + """Получение данных из отчета + + ### Структура параметров: + - `param1`: **Описание параметра 1** (опциональный) + - `param2`: **Описание параметра 2** (опциональный) + - `mode`: **Режим работы** (опциональный) + + ### Пример тела запроса: + ```json + { + "param1": "example_value", + "param2": ["value1", "value2"], + "mode": "mode1" + } + ``` + """ + report_service = get_report_service() + + try: + # Создаем запрос + request_dict = request_data.model_dump() + request = DataRequest( + report_type='your_parser', + 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("/your_parser/get_full_data", tags=[YourParser.name], + summary="Получение всех данных из отчета") +async def get_your_parser_full_data(): + """Получение всех данных из отчета без фильтрации + + ### Возвращает: + - Все данные без фильтрации + - Полная структура данных + """ + report_service = get_report_service() + + try: + # Создаем запрос без параметров + request = DataRequest( + report_type='your_parser', + get_params={} + ) + + # Получаем данные через геттер get_full_data + result = report_service.get_data(request, getter_name='get_full_data') + + 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)}") +``` + +--- + +## 🎨 Интеграция с Streamlit + +### 1. Добавление новой вкладки + +В `streamlit_app/streamlit_app.py`: + +```python +# Обновите список вкладок +tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs([ + "📊 Сводки ПМ", + "🏭 Сводки СА", + "⛽ Мониторинг топлива", + "⚡ Мониторинг ТЭР", + "🔧 Ремонт СА", + "📋 Статусы ремонта СА", + "🆕 Ваш парсер" # Добавить новую вкладку +]) + +# Добавьте новую вкладку +with tab7: + st.header("🆕 Ваш парсер") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + uploaded_file = st.file_uploader( + "Выберите ZIP архив для вашего парсера", + type=['zip'], + key="your_parser_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="your_parser_upload_btn"): + with st.spinner("Загружаем файл..."): + file_data = uploaded_file.read() + result, status_code = upload_file_to_api("/your_parser/upload", file_data, uploaded_file.name) + + if status_code == 200: + st.success("✅ Файл успешно загружен!") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result}") + + # Секция получения данных + st.subheader("📊 Получение данных") + + col1, col2 = st.columns(2) + + with col1: + st.subheader("🔍 Фильтрованные данные") + + # Параметры запроса + param1 = st.text_input("Параметр 1:", key="your_parser_param1") + param2 = st.multiselect("Параметр 2:", ["value1", "value2", "value3"], key="your_parser_param2") + mode = st.selectbox("Режим:", ["mode1", "mode2"], key="your_parser_mode") + + if st.button("📊 Получить данные", key="your_parser_get_data_btn"): + with st.spinner("Получаем данные..."): + request_data = { + "param1": param1 if param1 else None, + "param2": param2 if param2 else None, + "mode": mode if mode else None + } + result, status_code = make_api_request("/your_parser/get_data", request_data) + + if status_code == 200 and result.get("success"): + st.success("✅ Данные успешно получены!") + + # Показываем данные + data = result.get("data", {}).get("value", {}) + if data: + st.subheader("📋 Результат:") + st.json(data) + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + + with col2: + st.subheader("📋 Все данные") + + if st.button("📊 Получить все данные", key="your_parser_get_full_data_btn"): + with st.spinner("Получаем все данные..."): + result, status_code = make_api_request("/your_parser/get_full_data", {}) + + if status_code == 200 and result.get("success"): + st.success("✅ Все данные успешно получены!") + + # Показываем данные + data = result.get("data", {}).get("value", {}) + if data: + st.subheader("📋 Результат:") + st.json(data) + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") +``` + +### 2. Обновление информации о проекте + +```python +# В секции "О проекте" +**Возможности:** +- 📊 Парсинг сводок ПМ (план и факт) +- 🏭 Парсинг сводок СА +- ⛽ Мониторинг топлива +- ⚡ Мониторинг ТЭР (Топливно-энергетические ресурсы) +- 🔧 Управление ремонтными работами СА +- 📋 Мониторинг статусов ремонта СА +- 🆕 Ваш новый парсер # Добавить эту строку +``` + +--- + +## 🧪 Тестирование + +### 1. Создание тестового скрипта + +Создайте `test_your_parser.py`: + +```python +#!/usr/bin/env python3 +""" +Тестовый скрипт для проверки парсера Your Parser +""" + +import requests +import os + +API_BASE_URL = "http://localhost:8000" + +def test_upload_file(): + """Тест загрузки файла""" + print("🔍 Тестируем загрузку файла...") + + # Используем тестовый файл + file_path = "path/to/your/test/file.xlsx" + + if not os.path.exists(file_path): + print(f"❌ Файл не найден: {file_path}") + return False + + print(f"📁 Найден файл: {file_path}") + + with open(file_path, 'rb') as f: + file_content = f.read() + + files = { + 'file': ('test_file.xlsx', file_content, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + } + + try: + response = requests.post(f"{API_BASE_URL}/your_parser/upload", files=files) + print(f"📊 Статус ответа: {response.status_code}") + print(f"📄 Ответ: {response.text}") + + if response.status_code == 200: + result = response.json() + if result.get("success"): + print("✅ Загрузка успешна!") + return True + else: + print("❌ Ошибка загрузки!") + return False + else: + print("❌ Ошибка HTTP!") + return False + + except Exception as e: + print(f"❌ Исключение: {e}") + return False + +def test_get_data(): + """Тест получения данных""" + print("\n🔍 Тестируем получение данных...") + + test_data = { + "param1": "test_value", + "param2": ["value1", "value2"], + "mode": "mode1" + } + + try: + response = requests.post(f"{API_BASE_URL}/your_parser/get_data", json=test_data) + print(f"📊 Статус ответа: {response.status_code}") + + if response.status_code == 200: + result = response.json() + if result.get("success"): + print("✅ Получение данных успешно!") + data = result.get("data", {}).get("value", {}) + print(f"📊 Получено данных: {len(data)} записей") + return True + else: + print("❌ Ошибка получения данных!") + print(f"📄 Ответ: {response.text}") + return False + else: + print("❌ Ошибка HTTP!") + print(f"📄 Ответ: {response.text}") + return False + + except Exception as e: + print(f"❌ Исключение: {e}") + return False + +def main(): + """Основная функция тестирования""" + print("🚀 Тестирование парсера Your Parser") + print("=" * 70) + + # Тест 1: Загрузка файла + upload_success = test_upload_file() + + if upload_success: + # Тест 2: Получение данных + get_data_success = test_get_data() + + print("\n" + "=" * 70) + print("📋 Результаты тестирования:") + print(f" ✅ Загрузка файла: {'ПРОЙДЕН' if upload_success else 'ПРОВАЛЕН'}") + print(f" ✅ Получение данных: {'ПРОЙДЕН' if get_data_success else 'ПРОВАЛЕН'}") + + if upload_success and get_data_success: + print("\n🎉 Все тесты пройдены! Парсер работает корректно!") + else: + print("\n❌ Некоторые тесты провалены.") + else: + print("\n❌ Загрузка файла провалена. Пропускаем остальные тесты.") + +if __name__ == "__main__": + main() +``` + +### 2. Запуск тестов + +```bash +# Запуск тестового скрипта +python test_your_parser.py + +# Проверка через curl +curl -X POST "http://localhost:8000/your_parser/upload" \ + -H "accept: application/json" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@test_file.xlsx" + +curl -X POST "http://localhost:8000/your_parser/get_data" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{"param1": "test_value", "mode": "mode1"}' +``` + +--- + +## 📋 Лучшие практики + +### 1. Именование + +- **Парсер**: `YourParser` (PascalCase) +- **Файл парсера**: `your_parser.py` (snake_case) +- **Имя парсера**: `"your_parser"` (snake_case) +- **Геттеры**: `get_data`, `get_full_data` (snake_case) +- **Эндпоинты**: `/your_parser/upload`, `/your_parser/get_data` + +### 2. Структура данных + +```python +# Рекомендуемая структура возвращаемых данных +{ + "installation_id": { + "data_type1": [ + {"field1": "value1", "field2": "value2"}, + {"field1": "value3", "field2": "value4"} + ], + "data_type2": [ + {"field1": "value5", "field2": "value6"} + ] + } +} +``` + +### 3. Обработка ошибок + +```python +try: + # Ваш код + result = some_operation() + return result +except SpecificException as e: + print(f"❌ Специфическая ошибка: {e}") + return {} +except Exception as e: + print(f"❌ Общая ошибка: {e}") + raise +``` + +### 4. Логирование + +```python +# Используйте эмодзи для разных типов сообщений +print(f"🔍 DEBUG: Отладочная информация") +print(f"📁 INFO: Информационное сообщение") +print(f"✅ SUCCESS: Успешная операция") +print(f"⚠️ WARNING: Предупреждение") +print(f"❌ ERROR: Ошибка") +``` + +### 5. Валидация данных + +```python +# Всегда валидируйте входные параметры +validated_params = YourParserRequest(**params) + +# Проверяйте наличие данных +if not data_source: + print("⚠️ Нет данных в парсере") + return {} +``` + +### 6. Документация + +- Добавляйте docstrings ко всем методам +- Описывайте параметры в Pydantic схемах +- Добавляйте примеры в FastAPI эндпоинты +- Комментируйте сложную логику + +--- + +## 📚 Примеры + +### Пример 1: Простой парсер + +```python +class SimpleParser(ParserPort): + """Простой парсер для демонстрации""" + + name = "simple_parser" + + def __init__(self): + super().__init__() + self.register_getter("get_data", SimpleRequest, self._get_data_wrapper) + + def parse(self, file_path: str, params: dict) -> Dict[str, Any]: + """Парсинг простого Excel файла""" + df = pd.read_excel(file_path) + return {"data": df.to_dict('records')} + + def _get_data_wrapper(self, params: dict) -> Dict[str, Any]: + """Обертка для геттера""" + data_source = self._get_data_source() + return data_to_json(data_source) +``` + +### Пример 2: Парсер с множественными геттерами + +```python +class MultiGetterParser(ParserPort): + """Парсер с несколькими геттерами""" + + name = "multi_getter_parser" + + def __init__(self): + super().__init__() + # Регистрируем несколько геттеров + self.register_getter("get_summary", SummaryRequest, self._get_summary_wrapper) + self.register_getter("get_details", DetailsRequest, self._get_details_wrapper) + self.register_getter("get_statistics", StatisticsRequest, self._get_statistics_wrapper) + + def _get_summary_wrapper(self, params: dict) -> Dict[str, Any]: + """Геттер для получения сводки""" + # Логика получения сводки + pass + + def _get_details_wrapper(self, params: dict) -> Dict[str, Any]: + """Геттер для получения деталей""" + # Логика получения деталей + pass + + def _get_statistics_wrapper(self, params: dict) -> Dict[str, Any]: + """Геттер для получения статистики""" + # Логика получения статистики + pass +``` + +### Пример 3: Парсер с фильтрацией + +```python +class FilteredParser(ParserPort): + """Парсер с продвинутой фильтрацией""" + + def _filter_data(self, data_source: Dict[str, Any], params: FilterRequest) -> Dict[str, Any]: + """Фильтрация данных по параметрам""" + filtered_data = {} + + for key, value in data_source.items(): + # Фильтр по дате + if params.start_date and value.get('date') < params.start_date: + continue + + # Фильтр по типу + if params.type and value.get('type') != params.type: + continue + + # Фильтр по статусу + if params.status and value.get('status') not in params.status: + continue + + filtered_data[key] = value + + return filtered_data +``` + +--- + +## 🚀 Заключение + +Это руководство покрывает все аспекты создания новых парсеров в системе NIN Excel Parsers API. Следуйте этим инструкциям для создания качественных, поддерживаемых и тестируемых парсеров. + +### Чек-лист для нового парсера: + +- [ ] Создана Pydantic схема +- [ ] Создан класс парсера с геттерами +- [ ] Парсер зарегистрирован в `__init__.py` +- [ ] Парсер зарегистрирован в `main.py` +- [ ] Добавлена логика в `services.py` +- [ ] Созданы FastAPI эндпоинты +- [ ] Добавлена вкладка в Streamlit +- [ ] Создан тестовый скрипт +- [ ] Проведено тестирование +- [ ] Обновлена документация + +### Полезные ссылки: + +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [Pydantic Documentation](https://pydantic-docs.helpmanual.io/) +- [Streamlit Documentation](https://docs.streamlit.io/) +- [Pandas Documentation](https://pandas.pydata.org/docs/) + +--- + +**Удачной разработки! 🚀** \ No newline at end of file diff --git a/monitoring_tar_correct.zip b/monitoring_tar_correct.zip new file mode 100644 index 0000000..7d9200b Binary files /dev/null and b/monitoring_tar_correct.zip differ diff --git a/monitoring_tar_test.zip b/monitoring_tar_test.zip new file mode 100644 index 0000000..7d9200b Binary files /dev/null and b/monitoring_tar_test.zip differ diff --git a/python_parser/adapters/monitoring_tar_test.zip b/python_parser/adapters/monitoring_tar_test.zip new file mode 100644 index 0000000..7d9200b Binary files /dev/null and b/python_parser/adapters/monitoring_tar_test.zip differ diff --git a/python_parser/adapters/parsers/__init__.py b/python_parser/adapters/parsers/__init__.py index 1ee36d2..fa8534d 100644 --- a/python_parser/adapters/parsers/__init__.py +++ b/python_parser/adapters/parsers/__init__.py @@ -1,9 +1,17 @@ from .monitoring_fuel import MonitoringFuelParser +from .monitoring_tar import MonitoringTarParser from .svodka_ca import SvodkaCAParser from .svodka_pm import SvodkaPMParser +from .svodka_repair_ca import SvodkaRepairCAParser +from .statuses_repair_ca import StatusesRepairCAParser +from .oper_spravka_tech_pos import OperSpravkaTechPosParser __all__ = [ 'MonitoringFuelParser', + 'MonitoringTarParser', 'SvodkaCAParser', - 'SvodkaPMParser' + 'SvodkaPMParser', + 'SvodkaRepairCAParser', + 'StatusesRepairCAParser', + 'OperSpravkaTechPosParser' ] diff --git a/python_parser/adapters/parsers/monitoring_tar.py b/python_parser/adapters/parsers/monitoring_tar.py new file mode 100644 index 0000000..d39134b --- /dev/null +++ b/python_parser/adapters/parsers/monitoring_tar.py @@ -0,0 +1,302 @@ +import os +import zipfile +import tempfile +import pandas as pd +from typing import Dict, Any, List +from core.ports import ParserPort +from adapters.pconfig import find_header_row, SNPZ_IDS, data_to_json + + +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 архив с файлами мониторинга ТЭР""" + print(f"🔍 DEBUG: 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) + 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, Any]: + """Парсит 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) + + # Ищем файлы мониторинга ТЭР + 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("В архиве не найдены файлы мониторинга ТЭР") + + print(f"📁 Найдено {len(tar_files)} файлов мониторинга ТЭР") + + # Обрабатываем каждый файл + all_data = {} + for file_path in tar_files: + print(f"📁 Обработка файла: {file_path}") + + # Извлекаем номер месяца из имени файла + filename = os.path.basename(file_path) + month_str = self._extract_month_from_filename(filename) + print(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: + print(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: + print(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: + print(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() + + print(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: + print(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: + print(f"❌ Не найдена строка с заголовком '1' в файле {file}, лист '{sheet}'") + return {'total': None, 'last_day': None} + elif filename.startswith('monitoring_'): + # Для файлов monitoring_*.xlsm заголовок находится в строке 5 + header_num = 5 + else: + print(f"❌ Неизвестный тип файла: {filename}") + return {'total': None, 'last_day': None} + + print(f"🔍 DEBUG: Используем заголовок в строке {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: + print(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: + print(f"❌ Ошибка при обработке файла {file}, лист '{sheet}': {e}") + return {'total': None, 'last_day': None} + + def _get_tar_data_wrapper(self, params: Dict[str, Any] = None) -> str: + """Обертка для получения данных мониторинга ТЭР с фильтрацией по режиму""" + print(f"🔍 DEBUG: _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: + print(f"❌ Ошибка при конвертации данных в JSON: {e}") + return "{}" + + def _get_tar_full_data_wrapper(self, params: Dict[str, Any] = None) -> str: + """Обертка для получения всех данных мониторинга ТЭР""" + print(f"🔍 DEBUG: _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: + print(f"❌ Ошибка при конвертации данных в JSON: {e}") + return "{}" \ No newline at end of file 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/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/adapters/parsers/svodka_repair_ca.py b/python_parser/adapters/parsers/svodka_repair_ca.py new file mode 100644 index 0000000..63ea6fc --- /dev/null +++ b/python_parser/adapters/parsers/svodka_repair_ca.py @@ -0,0 +1,377 @@ +import pandas as pd +import numpy as np +import os +import tempfile +import shutil +import zipfile +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 + + +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): + """Получение данных о ремонтных работах""" + print(f"🔍 DEBUG: _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) + + print(f"🔍 DEBUG: Запрошенные ОГ: {og_ids}") + print(f"🔍 DEBUG: Запрошенные типы ремонта: {repair_types}") + print(f"🔍 DEBUG: Включать плановые: {include_planned}, фактические: {include_factual}") + + # Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки) + if hasattr(self, 'data_dict') and self.data_dict is not None: + # Данные из парсинга + data_source = self.data_dict + print(f"🔍 DEBUG: Используем 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() + print(f"🔍 DEBUG: Используем df, преобразованный в data_dict с {len(data_source)} записями") + else: + print(f"🔍 DEBUG: Нет данных! 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()) + print(f"🔍 DEBUG: Отфильтровано {total_records} записей из {len(data_source)}") + print(f"🔍 DEBUG: Группировано по {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""" + print(f"🔍 DEBUG: 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 + print(f"🔍 DEBUG: Создан DataFrame с {len(data_rows)} записями") + return df + + # Если данных нет, возвращаем пустой DataFrame + self.df = pd.DataFrame() + print(f"🔍 DEBUG: Возвращаем пустой DataFrame") + return self.df + + def _parse_zip_archive(self, file_path: str, params: dict) -> List[Dict]: + """Парсинг ZIP архива с файлами ремонта СА""" + print(f"🔍 DEBUG: Парсинг ZIP архива: {file_path}") + + all_data = [] + temp_dir = None + + try: + # Создаем временную директорию + temp_dir = tempfile.mkdtemp() + print(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)) + + print(f"📊 Найдено Excel файлов: {len(excel_files)}") + + # Обрабатываем каждый найденный файл + for excel_file in excel_files: + print(f"📊 Обработка файла: {excel_file}") + file_data = self._parse_single_file(excel_file, params) + if file_data: + all_data.extend(file_data) + + print(f"🎯 Всего обработано записей: {len(all_data)}") + return all_data + + except Exception as e: + print(f"❌ Ошибка при обработке ZIP архива: {e}") + return [] + finally: + # Удаляем временную директорию + if temp_dir: + shutil.rmtree(temp_dir, ignore_errors=True) + print(f"🗑️ Временная директория удалена: {temp_dir}") + + def _parse_single_file(self, file_path: str, params: dict) -> List[Dict]: + """Парсинг одиночного Excel файла""" + print(f"🔍 DEBUG: Парсинг файла: {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: + print(f"❌ Не найден заголовок в файле {file_path}") + return [] + + print(f"🔍 DEBUG: Заголовок найден в строке {header_num}") + + # Читаем Excel файл + df = pd.read_excel( + file_path, + sheet_name=sheet_name, + header=header_num, + usecols=None, + index_col=None + ) + + if df.empty: + print(f"❌ Файл {file_path} пуст") + return [] + + if "ОГ" not in df.columns: + print(f"⚠️ Предупреждение: Колонка 'ОГ' не найдена в файле {file_path}") + return [] + + # Обрабатываем данные + return self._process_repair_data(df) + + except Exception as e: + print(f"❌ Ошибка при парсинге файла {file_path}: {e}") + return [] + + def _process_repair_data(self, df: pd.DataFrame) -> List[Dict]: + """Обработка данных о ремонте""" + print(f"🔍 DEBUG: Обработка данных с {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: + print(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: + print("⚠️ Предупреждение: Колонка 'Вид простоя' не найдена.") + 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: + print(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: + print(f"⚠️ Ошибка при обработке строки: {e}") + continue + + print(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 \ No newline at end of file diff --git a/python_parser/app/main.py b/python_parser/app/main.py index d3151bf..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 +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,10 @@ 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 # Парсеры @@ -25,6 +29,10 @@ 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, }) @@ -80,22 +88,81 @@ async def root(): description="Возвращает список идентификаторов всех доступных парсеров", response_model=Dict[str, List[str]], responses={ - 200: { - "content": { - "application/json": { - "example": { - "parsers": ["monitoring_fuel", "svodka_ca", "svodka_pm"] - } - } - } - } - },) + 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}/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: + print(f"⚠️ Ошибка при получении ОГ: {e}") + import traceback + traceback.print_exc() + + # Для других парсеров или если нет данных возвращаем статический список из pconfig + from adapters.pconfig import SINGLE_OGS + return {"parser": parser_name, "available_ogs": SINGLE_OGS} + + @app.get("/parsers/{parser_name}/getters", tags=["Общее"], summary="Информация о геттерах парсера", description="Возвращает информацию о доступных геттерах для указанного парсера", @@ -556,6 +623,246 @@ async def get_svodka_ca_data( raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") +@app.post("/svodka_repair_ca/upload", tags=[SvodkaRepairCAParser.name], + summary="Загрузка файла отчета сводки ремонта СА", + response_model=UploadResponse, + responses={ + 400: {"model": UploadErrorResponse, "description": "Неверный формат файла"}, + 500: {"model": UploadErrorResponse, "description": "Внутренняя ошибка сервера"} + },) +async def upload_svodka_repair_ca( + file: UploadFile = File(..., description="Excel файл или ZIP архив сводки ремонта СА (.xlsx, .xlsm, .xls, .zip)") +): + """ + Загрузка и обработка Excel файла или ZIP архива отчета сводки ремонта СА + + **Поддерживаемые форматы:** + - Excel (.xlsx, .xlsm, .xls) + - ZIP архив (.zip) + """ + report_service = get_report_service() + + try: + # Проверяем тип файла + if not file.filename.lower().endswith(('.xlsx', '.xlsm', '.xls', '.zip')): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=UploadErrorResponse( + message="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или ZIP архивы (.zip)", + error_code="INVALID_FILE_TYPE", + details={ + "expected_formats": [".xlsx", ".xlsm", ".xls", ".zip"], + "received_format": file.filename.split('.')[-1] if '.' in file.filename else "unknown" + } + ).model_dump() + ) + + # Читаем содержимое файла + file_content = await file.read() + + # Создаем запрос + request = UploadRequest( + report_type='svodka_repair_ca', + file_content=file_content, + file_name=file.filename + ) + + # Загружаем отчет + result = report_service.upload_report(request) + + if result.success: + return UploadResponse( + success=True, + message=result.message, + object_id=result.object_id + ) + else: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=UploadErrorResponse( + message=result.message, + error_code="ERR_UPLOAD" + ).model_dump(), + ) + + except HTTPException: + raise + except Exception as e: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=UploadErrorResponse( + message=f"Внутренняя ошибка сервера: {str(e)}", + error_code="INTERNAL_SERVER_ERROR" + ).model_dump() + ) + + +@app.post("/svodka_repair_ca/get_data", tags=[SvodkaRepairCAParser.name], + summary="Получение данных из отчета сводки ремонта СА") +async def get_svodka_repair_ca_data( + request_data: SvodkaRepairCARequest +): + """ + Получение данных из отчета сводки ремонта СА + + ### Структура параметров: + - `og_ids`: **Массив ID ОГ** для фильтрации (опциональный) + - `repair_types`: **Массив типов ремонта** - `КР`, `КП`, `ТР` (опциональный) + - `include_planned`: **Включать плановые данные** (по умолчанию true) + - `include_factual`: **Включать фактические данные** (по умолчанию true) + + ### Пример тела запроса: + ```json + { + "og_ids": ["SNPZ", "KNPZ"], + "repair_types": ["КР", "КП"], + "include_planned": true, + "include_factual": true + } + ``` + """ + report_service = get_report_service() + + try: + # Создаем запрос + request_dict = request_data.model_dump() + request = DataRequest( + report_type='svodka_repair_ca', + get_params=request_dict + ) + + # Получаем данные + result = report_service.get_data(request) + + if result.success: + return { + "success": True, + "data": result.data + } + else: + raise HTTPException(status_code=404, detail=result.message) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/statuses_repair_ca/upload", tags=[StatusesRepairCAParser.name], + summary="Загрузка отчета статусов ремонта СА") +async def upload_statuses_repair_ca( + file: UploadFile = File(...) +): + """ + Загрузка отчета статусов ремонта СА + + ### Поддерживаемые форматы: + - **Excel файлы**: `.xlsx`, `.xlsm`, `.xls` + - **ZIP архивы**: `.zip` (содержащие Excel файлы) + + ### Пример использования: + ```bash + curl -X POST "http://localhost:8000/statuses_repair_ca/upload" \ + -H "accept: application/json" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@statuses_repair_ca.xlsx" + ``` + """ + report_service = get_report_service() + + try: + # Проверяем тип файла + if not file.filename.endswith(('.xlsx', '.xlsm', '.xls', '.zip')): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Поддерживаются только Excel файлы (.xlsx, .xlsm, .xls) или архивы (.zip)" + ) + + # Читаем содержимое файла + file_content = await file.read() + + # Создаем запрос на загрузку + upload_request = UploadRequest( + report_type='statuses_repair_ca', + file_content=file_content, + file_name=file.filename + ) + + # Загружаем отчет + result = report_service.upload_report(upload_request) + + if result.success: + return UploadResponse( + success=True, + message="Отчет успешно загружен и обработан", + report_id=result.object_id, + filename=file.filename + ).model_dump() + else: + return UploadErrorResponse( + success=False, + message=result.message, + error_code="ERR_UPLOAD", + details=None + ).model_dump() + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/statuses_repair_ca/get_data", tags=[StatusesRepairCAParser.name], + summary="Получение данных из отчета статусов ремонта СА") +async def get_statuses_repair_ca_data( + request_data: StatusesRepairCARequest +): + """ + Получение данных из отчета статусов ремонта СА + + ### Структура параметров: + - `ids`: **Массив ID ОГ** для фильтрации (опциональный) + - `keys`: **Массив ключей** для извлечения данных (опциональный) + + ### Пример тела запроса: + ```json + { + "ids": ["SNPZ", "KNPZ", "ANHK"], + "keys": [ + ["Дата начала ремонта"], + ["Готовность к КР", "Факт"], + ["Заключение договоров на СМР", "Договор", "%"] + ] + } + ``` + """ + report_service = get_report_service() + + try: + # Создаем запрос + request_dict = request_data.model_dump() + request = DataRequest( + report_type='statuses_repair_ca', + get_params=request_dict + ) + + # Получаем данные + result = report_service.get_data(request) + + if result.success: + return { + "success": True, + "data": result.data + } + else: + raise HTTPException(status_code=404, detail=result.message) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + # @app.post("/monitoring_fuel/upload", tags=[MonitoringFuelParser.name]) # async def upload_monitoring_fuel( # file: UploadFile = File(...), @@ -872,5 +1179,258 @@ async def get_monitoring_fuel_month_by_code( raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") +# ====== MONITORING TAR ENDPOINTS ====== + +@app.post("/monitoring_tar/upload", tags=[MonitoringTarParser.name], + summary="Загрузка отчета мониторинга ТЭР") +async def upload_monitoring_tar( + file: UploadFile = File(...) +): + """Загрузка и обработка отчета мониторинга ТЭР (Топливно-энергетических ресурсов) + + ### Поддерживаемые форматы: + - **ZIP архивы** с файлами мониторинга ТЭР + + ### Структура данных: + - Обрабатывает ZIP архивы с файлами по месяцам (svodka_tar_SNPZ_01.xlsx - svodka_tar_SNPZ_12.xlsx) + - Извлекает данные по установкам (SNPZ_IDS) + - Возвращает два типа данных: 'total' (строки "Всего") и 'last_day' (последние строки) + """ + report_service = get_report_service() + + try: + # Проверяем тип файла - только ZIP архивы + if not file.filename.endswith('.zip'): + raise HTTPException( + status_code=400, + detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)" + ) + + # Читаем содержимое файла + file_content = await file.read() + + # Создаем запрос на загрузку + upload_request = UploadRequest( + report_type='monitoring_tar', + file_content=file_content, + file_name=file.filename + ) + + # Загружаем отчет + result = report_service.upload_report(upload_request) + + if result.success: + return UploadResponse( + success=True, + message="Отчет успешно загружен и обработан", + report_id=result.object_id, + filename=file.filename + ).model_dump() + else: + return UploadErrorResponse( + success=False, + message=result.message, + error_code="ERR_UPLOAD", + details=None + ).model_dump() + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/monitoring_tar/get_data", tags=[MonitoringTarParser.name], + summary="Получение данных из отчета мониторинга ТЭР") +async def get_monitoring_tar_data( + request_data: MonitoringTarRequest +): + """Получение данных из отчета мониторинга ТЭР + + ### Структура параметров: + - `mode`: **Режим получения данных** (опциональный) + - `"total"` - строки "Всего" (агрегированные данные) + - `"last_day"` - последние строки данных + - Если не указан, возвращаются все данные + + ### Пример тела запроса: + ```json + { + "mode": "total" + } + ``` + """ + report_service = get_report_service() + + try: + # Создаем запрос + request_dict = request_data.model_dump() + request = DataRequest( + report_type='monitoring_tar', + get_params=request_dict + ) + + # Получаем данные + result = report_service.get_data(request) + + if result.success: + return { + "success": True, + "data": result.data + } + else: + raise HTTPException(status_code=404, detail=result.message) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/monitoring_tar/get_full_data", tags=[MonitoringTarParser.name], + summary="Получение всех данных из отчета мониторинга ТЭР") +async def get_monitoring_tar_full_data(): + """Получение всех данных из отчета мониторинга ТЭР без фильтрации + + ### Возвращает: + - Все данные по всем установкам + - И данные 'total', и данные 'last_day' + - Полная структура данных мониторинга ТЭР + """ + report_service = get_report_service() + + try: + # Создаем запрос без параметров + request = DataRequest( + report_type='monitoring_tar', + get_params={} + ) + + # Получаем данные + result = report_service.get_data(request) + + if result.success: + return { + "success": True, + "data": result.data + } + else: + raise HTTPException(status_code=404, detail=result.message) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +# ====== OPER SPRAVKA TECH POS ENDPOINTS ====== + +@app.post("/oper_spravka_tech_pos/upload", tags=[OperSpravkaTechPosParser.name], + summary="Загрузка отчета операционной справки технологических позиций") +async def upload_oper_spravka_tech_pos( + file: UploadFile = File(...) +): + """Загрузка и обработка отчета операционной справки технологических позиций + + ### Поддерживаемые форматы: + - **ZIP архивы** с файлами операционных справок + + ### Структура данных: + - Обрабатывает ZIP архивы с файлами операционных справок по технологическим позициям + - Извлекает данные по процессам: Первичная переработка, Гидроочистка топлив, Риформирование, Изомеризация + - Возвращает данные по установкам с планом и фактом + """ + report_service = get_report_service() + + try: + # Проверяем тип файла - только ZIP архивы + if not file.filename.endswith('.zip'): + raise HTTPException( + status_code=400, + detail="Неподдерживаемый тип файла. Ожидается только ZIP архив (.zip)" + ) + + # Читаем содержимое файла + file_content = await file.read() + + # Создаем запрос на загрузку + upload_request = UploadRequest( + report_type="oper_spravka_tech_pos", + file_name=file.filename, + file_content=file_content, + parse_params={} + ) + + # Загружаем и обрабатываем отчет + result = report_service.upload_report(upload_request) + + if result.success: + return UploadResponse( + success=True, + message="Отчет успешно загружен и обработан", + object_id=result.object_id + ) + else: + return UploadErrorResponse( + success=False, + message=result.message, + error_code="ERR_UPLOAD", + details=None + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + + +@app.post("/oper_spravka_tech_pos/get_data", tags=[OperSpravkaTechPosParser.name], + summary="Получение данных операционной справки технологических позиций", + response_model=OperSpravkaTechPosResponse) +async def get_oper_spravka_tech_pos_data(request: OperSpravkaTechPosRequest): + """Получение данных операционной справки технологических позиций по ОГ + + ### Параметры: + - **id** (str): ID ОГ (например, 'SNPZ', 'KNPZ') + + ### Возвращает: + - Данные по технологическим позициям для указанного ОГ + - Включает информацию о процессах, установках, плане и факте + """ + report_service = get_report_service() + + try: + # Создаем запрос на получение данных + data_request = DataRequest( + report_type="oper_spravka_tech_pos", + get_params={"id": request.id} + ) + + # Получаем данные + result = report_service.get_data(data_request) + + if result.success: + # Извлекаем данные из результата + value_data = result.data.get("value", []) if isinstance(result.data.get("value"), list) else [] + 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/monitoring_tar.py b/python_parser/app/schemas/monitoring_tar.py new file mode 100644 index 0000000..db9def3 --- /dev/null +++ b/python_parser/app/schemas/monitoring_tar.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field +from typing import Optional, Literal +from enum import Enum + +class TarMode(str, Enum): + """Режимы получения данных мониторинга ТЭР""" + TOTAL = "total" + LAST_DAY = "last_day" + +class MonitoringTarRequest(BaseModel): + """Схема запроса для получения данных мониторинга ТЭР""" + mode: Optional[TarMode] = Field( + None, + description="Режим получения данных: 'total' (строки 'Всего') или 'last_day' (последние строки). Если не указан, возвращаются все данные", + example="total" + ) + + class Config: + json_schema_extra = { + "example": { + "mode": "total" + } + } + +class MonitoringTarFullRequest(BaseModel): + """Схема запроса для получения всех данных мониторинга ТЭР""" + # Пустая схема - возвращает все данные без фильтрации + pass + + class Config: + json_schema_extra = { + "example": {} + } \ No newline at end of file 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/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/app/schemas/svodka_repair_ca.py b/python_parser/app/schemas/svodka_repair_ca.py new file mode 100644 index 0000000..ca26bda --- /dev/null +++ b/python_parser/app/schemas/svodka_repair_ca.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from enum import Enum + + +class RepairType(str, Enum): + """Типы ремонтных работ""" + KR = "КР" # Капитальный ремонт + KP = "КП" # Капитальный ремонт + TR = "ТР" # Текущий ремонт + + +class SvodkaRepairCARequest(BaseModel): + """Запрос на получение данных сводки ремонта СА""" + + og_ids: Optional[List[str]] = Field( + default=None, + description="Список ID ОГ для фильтрации. Если не указан, возвращаются данные по всем ОГ", + example=["SNPZ", "KNPZ", "BASH"] + ) + + repair_types: Optional[List[RepairType]] = Field( + default=None, + description="Список типов ремонта для фильтрации. Если не указан, возвращаются все типы", + example=[RepairType.KR, RepairType.KP] + ) + + include_planned: bool = Field( + default=True, + description="Включать ли плановые данные" + ) + + include_factual: bool = Field( + default=True, + description="Включать ли фактические данные" + ) + + class Config: + json_schema_extra = { + "example": { + "og_ids": ["SNPZ", "KNPZ"], + "repair_types": ["КР", "КП"], + "include_planned": True, + "include_factual": True + } + } \ No newline at end of file diff --git a/python_parser/core/services.py b/python_parser/core/services.py index 75f70c3..0e6becf 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -136,6 +136,20 @@ class ReportService: 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) @@ -164,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 43252b4..22513ab 100644 --- a/streamlit_app/streamlit_app.py +++ b/streamlit_app/streamlit_app.py @@ -4,7 +4,7 @@ import json import pandas as pd import io import zipfile -from typing import Dict, Any +from typing import Dict, Any, List import os # Конфигурация страницы @@ -50,7 +50,12 @@ def get_server_info(): def upload_file_to_api(endpoint: str, file_data: bytes, filename: str): """Загрузка файла на API""" try: - files = {"zip_file": (filename, file_data, "application/zip")} + # Определяем правильное имя поля в зависимости от эндпоинта + if "zip" in endpoint: + files = {"zip_file": (filename, file_data, "application/zip")} + else: + files = {"file": (filename, file_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")} + response = requests.post(f"{API_BASE_URL}{endpoint}", files=files) return response.json(), response.status_code except Exception as e: @@ -64,6 +69,20 @@ def make_api_request(endpoint: str, data: Dict[str, Any]): 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 [] + def main(): st.title("🚀 NIN Excel Parsers API - Демонстрация") st.markdown("---") @@ -96,10 +115,14 @@ def main(): st.write(f"• {parser}") # Основные вкладки - по одной на каждый парсер - tab1, tab2, tab3 = st.tabs([ + tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs([ "📊 Сводки ПМ", "🏭 Сводки СА", - "⛽ Мониторинг топлива" + "⛽ Мониторинг топлива", + "🔧 Ремонт СА", + "📋 Статусы ремонта СА", + "⚡ Мониторинг ТЭР", + "🏭 Операционные справки" ]) # Вкладка 1: Сводки ПМ - полный функционал @@ -371,6 +394,430 @@ def main(): else: st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + # Вкладка 4: Ремонт СА + with tab4: + st.header("🔧 Ремонт СА - Управление ремонтными работами") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + + uploaded_file = st.file_uploader( + "Выберите Excel файл или ZIP архив с данными о ремонте СА", + type=['xlsx', 'xlsm', 'xls', 'zip'], + key="repair_ca_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="repair_ca_upload_btn"): + with st.spinner("Загружаю файл..."): + file_data = uploaded_file.read() + result, status = upload_file_to_api("/svodka_repair_ca/upload", file_data, uploaded_file.name) + + if status == 200: + st.success("✅ Файл успешно загружен") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result.get('message', 'Неизвестная ошибка')}") + + st.markdown("---") + + # Секция получения данных + st.subheader("🔍 Получение данных") + + col1, col2 = st.columns(2) + + with col1: + st.subheader("Фильтры") + + # Получаем доступные ОГ динамически + available_ogs = get_available_ogs("svodka_repair_ca") + + # Фильтр по ОГ + og_ids = st.multiselect( + "Выберите ОГ (оставьте пустым для всех)", + available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback + key="repair_ca_og_ids" + ) + + # Фильтр по типам ремонта + repair_types = st.multiselect( + "Выберите типы ремонта (оставьте пустым для всех)", + ["КР", "КП", "ТР"], + key="repair_ca_types" + ) + + # Включение плановых/фактических данных + include_planned = st.checkbox("Включать плановые данные", value=True, key="repair_ca_planned") + include_factual = st.checkbox("Включать фактические данные", value=True, key="repair_ca_factual") + + with col2: + st.subheader("Действия") + + if st.button("🔍 Получить данные о ремонте", key="repair_ca_get_btn"): + with st.spinner("Получаю данные..."): + data = { + "include_planned": include_planned, + "include_factual": include_factual + } + + # Добавляем фильтры только если они выбраны + if og_ids: + data["og_ids"] = og_ids + if repair_types: + data["repair_types"] = repair_types + + result, status = make_api_request("/svodka_repair_ca/get_data", data) + + if status == 200: + st.success("✅ Данные получены") + + # Отображаем данные в виде таблицы, если возможно + if result.get("data") and isinstance(result["data"], list): + df_data = [] + for item in result["data"]: + df_data.append({ + "ID ОГ": item.get("id", ""), + "Наименование": item.get("name", ""), + "Тип ремонта": item.get("type", ""), + "Дата начала": item.get("start_date", ""), + "Дата окончания": item.get("end_date", ""), + "План": item.get("plan", ""), + "Факт": item.get("fact", ""), + "Простой": item.get("downtime", "") + }) + + if df_data: + df = pd.DataFrame(df_data) + st.dataframe(df, use_container_width=True) + else: + st.info("📋 Нет данных для отображения") + else: + st.json(result) + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + + # Вкладка 5: Статусы ремонта СА + with tab5: + st.header("📋 Статусы ремонта СА") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + uploaded_file = st.file_uploader( + "Выберите файл статусов ремонта СА", + type=['xlsx', 'xlsm', 'xls', 'zip'], + key="statuses_repair_ca_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="statuses_repair_ca_upload_btn"): + with st.spinner("Загружаем файл..."): + file_data = uploaded_file.read() + result, status_code = upload_file_to_api("/statuses_repair_ca/upload", file_data, uploaded_file.name) + + if status_code == 200: + st.success("✅ Файл успешно загружен!") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result}") + + # Секция получения данных + st.subheader("📊 Получение данных") + + # Получаем доступные ОГ динамически + available_ogs = get_available_ogs("statuses_repair_ca") + + # Фильтр по ОГ + og_ids = st.multiselect( + "Выберите ОГ (оставьте пустым для всех)", + available_ogs if available_ogs else ["KNPZ", "ANHK", "SNPZ", "BASH", "UNH", "NOV"], # fallback + key="statuses_repair_ca_og_ids" + ) + + # Предустановленные ключи для извлечения + st.subheader("🔑 Ключи для извлечения данных") + + # Основные ключи + include_basic_keys = st.checkbox("Основные данные", value=True, key="statuses_basic_keys") + include_readiness_keys = st.checkbox("Готовность к КР", value=True, key="statuses_readiness_keys") + include_contract_keys = st.checkbox("Заключение договоров", value=True, key="statuses_contract_keys") + include_supply_keys = st.checkbox("Поставка МТР", value=True, key="statuses_supply_keys") + + # Формируем ключи на основе выбора + keys = [] + if include_basic_keys: + keys.append(["Дата начала ремонта"]) + keys.append(["Отставание / опережение подготовки к КР", "Отставание / опережение"]) + keys.append(["Отставание / опережение подготовки к КР", "Динамика за прошедшую неделю"]) + + if include_readiness_keys: + keys.append(["Готовность к КР", "Факт"]) + + if include_contract_keys: + keys.append(["Заключение договоров на СМР", "Договор", "%"]) + + if include_supply_keys: + keys.append(["Поставка МТР", "На складе, позиций", "%"]) + + # Кнопка получения данных + if st.button("📊 Получить данные", key="statuses_repair_ca_get_data_btn"): + if not keys: + st.warning("⚠️ Выберите хотя бы одну группу ключей для извлечения") + else: + with st.spinner("Получаем данные..."): + request_data = { + "ids": og_ids if og_ids else None, + "keys": keys + } + + result, status_code = make_api_request("/statuses_repair_ca/get_data", request_data) + + if status_code == 200 and result.get("success"): + st.success("✅ Данные успешно получены!") + + data = result.get("data", {}).get("value", []) + if data: + # Отображаем данные в виде таблицы + if isinstance(data, list) and len(data) > 0: + # Преобразуем в DataFrame для лучшего отображения + df_data = [] + for item in data: + row = { + "ID": item.get("id", ""), + "Название": item.get("name", ""), + } + + # Добавляем основные поля + if "Дата начала ремонта" in item: + row["Дата начала ремонта"] = item["Дата начала ремонта"] + + # Добавляем готовность к КР + if "Готовность к КР" in item: + readiness = item["Готовность к КР"] + if isinstance(readiness, dict) and "Факт" in readiness: + row["Готовность к КР (Факт)"] = readiness["Факт"] + + # Добавляем отставание/опережение + if "Отставание / опережение подготовки к КР" in item: + delay = item["Отставание / опережение подготовки к КР"] + if isinstance(delay, dict): + if "Отставание / опережение" in delay: + row["Отставание/опережение"] = delay["Отставание / опережение"] + if "Динамика за прошедшую неделю" in delay: + row["Динамика за неделю"] = delay["Динамика за прошедшую неделю"] + + # Добавляем договоры + if "Заключение договоров на СМР" in item: + contracts = item["Заключение договоров на СМР"] + if isinstance(contracts, dict) and "Договор" in contracts: + contract = contracts["Договор"] + if isinstance(contract, dict) and "%" in contract: + row["Договоры (%)"] = contract["%"] + + # Добавляем поставки МТР + if "Поставка МТР" in item: + supply = item["Поставка МТР"] + if isinstance(supply, dict) and "На складе, позиций" in supply: + warehouse = supply["На складе, позиций"] + if isinstance(warehouse, dict) and "%" in warehouse: + row["МТР на складе (%)"] = warehouse["%"] + + df_data.append(row) + + if df_data: + df = pd.DataFrame(df_data) + st.dataframe(df, use_container_width=True) + else: + st.info("📋 Нет данных для отображения") + else: + st.json(result) + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + + # Вкладка 6: Мониторинг ТЭР + with tab6: + st.header("⚡ Мониторинг ТЭР (Топливно-энергетических ресурсов)") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + uploaded_file = st.file_uploader( + "Выберите ZIP архив с файлами мониторинга ТЭР", + type=['zip'], + key="monitoring_tar_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="monitoring_tar_upload_btn"): + with st.spinner("Загружаем файл..."): + file_data = uploaded_file.read() + result, status_code = upload_file_to_api("/monitoring_tar/upload", file_data, uploaded_file.name) + + if status_code == 200: + st.success("✅ Файл успешно загружен!") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result}") + + # Секция получения данных + st.subheader("📊 Получение данных") + + # Выбор формата отображения + display_format = st.radio( + "Формат отображения:", + ["JSON", "Таблица"], + key="monitoring_tar_display_format", + horizontal=True + ) + + # Выбор режима данных + mode = st.selectbox( + "Выберите режим данных:", + ["all", "total", "last_day"], + help="total - строки 'Всего' (агрегированные данные), last_day - последние строки данных, all - все данные", + key="monitoring_tar_mode" + ) + + if st.button("📊 Получить данные", key="monitoring_tar_get_data_btn"): + with st.spinner("Получаем данные..."): + # Выбираем эндпоинт в зависимости от режима + if mode == "all": + # Используем полный эндпоинт + result, status_code = make_api_request("/monitoring_tar/get_full_data", {}) + else: + # Используем фильтрованный эндпоинт + request_data = {"mode": mode} + result, status_code = make_api_request("/monitoring_tar/get_data", request_data) + + if status_code == 200 and result.get("success"): + st.success("✅ Данные успешно получены!") + + # Показываем данные + data = result.get("data", {}).get("value", {}) + if data: + st.subheader("📋 Результат:") + + # # Отладочная информация + # st.write(f"🔍 Тип данных: {type(data)}") + # if isinstance(data, str): + # st.write(f"🔍 Длина строки: {len(data)}") + # st.write(f"🔍 Первые 200 символов: {data[:200]}...") + + # Парсим данные, если они пришли как строка + if isinstance(data, str): + try: + import json + data = json.loads(data) + st.write("✅ JSON успешно распарсен") + except json.JSONDecodeError as e: + st.error(f"❌ Ошибка при парсинге JSON данных: {e}") + st.write("Сырые данные:", data) + return + + if display_format == "JSON": + # Отображаем как JSON + st.json(data) + else: + # Отображаем как таблицы + if isinstance(data, dict): + # Показываем данные по установкам + for installation_id, installation_data in data.items(): + with st.expander(f"🏭 {installation_id}"): + if isinstance(installation_data, dict): + # Показываем структуру данных + for data_type, type_data in installation_data.items(): + st.write(f"**{data_type}:**") + if isinstance(type_data, list) and type_data: + df = pd.DataFrame(type_data) + st.dataframe(df) + else: + st.write("Нет данных") + else: + st.write("Нет данных") + else: + st.json(data) + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + + # Вкладка 7: Операционные справки технологических позиций + with tab7: + st.header("🏭 Операционные справки технологических позиций") + + # Секция загрузки файлов + st.subheader("📤 Загрузка файлов") + + uploaded_file = st.file_uploader( + "Выберите ZIP архив с файлами операционных справок", + type=['zip'], + key="oper_spravka_tech_pos_upload" + ) + + if uploaded_file is not None: + if st.button("📤 Загрузить файл", key="oper_spravka_tech_pos_upload_btn"): + with st.spinner("Загружаем файл..."): + file_data = uploaded_file.read() + result, status_code = upload_file_to_api("/oper_spravka_tech_pos/upload", file_data, uploaded_file.name) + + if status_code == 200: + st.success("✅ Файл успешно загружен!") + st.json(result) + else: + st.error(f"❌ Ошибка загрузки: {result}") + + st.markdown("---") + + # Секция получения данных + st.subheader("📊 Получение данных") + + # Выбор формата отображения + display_format = st.radio( + "Формат отображения:", + ["JSON", "Таблица"], + key="oper_spravka_tech_pos_display_format", + horizontal=True + ) + + # Получаем доступные ОГ динамически + available_ogs = get_available_ogs("oper_spravka_tech_pos") + + # Выбор ОГ + og_id = st.selectbox( + "Выберите ОГ:", + available_ogs if available_ogs else ["SNPZ", "KNPZ", "ANHK", "BASH", "UNH", "NOV"], + key="oper_spravka_tech_pos_og_id" + ) + + if st.button("📊 Получить данные", key="oper_spravka_tech_pos_get_data_btn"): + with st.spinner("Получаем данные..."): + request_data = {"id": og_id} + result, status_code = make_api_request("/oper_spravka_tech_pos/get_data", request_data) + + if status_code == 200 and result.get("success"): + st.success("✅ Данные успешно получены!") + + # Показываем данные + data = result.get("data", []) + + if data and len(data) > 0: + st.subheader("📋 Результат:") + + if display_format == "JSON": + # Отображаем как JSON + st.json(data) + else: + # Отображаем как таблицу + if isinstance(data, list) and data: + df = pd.DataFrame(data) + st.dataframe(df, use_container_width=True) + else: + st.write("Нет данных") + else: + st.info("📋 Нет данных для отображения") + else: + st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") + # Футер st.markdown("---") st.markdown("### 📚 Документация API") @@ -385,6 +832,9 @@ def main(): - 📊 Парсинг сводок ПМ (план и факт) - 🏭 Парсинг сводок СА - ⛽ Мониторинг топлива + - ⚡ Мониторинг ТЭР (Топливно-энергетические ресурсы) + - 🔧 Управление ремонтными работами СА + - 📋 Мониторинг статусов ремонта СА **Технологии:** - FastAPI diff --git a/test_repair_ca.zip b/test_repair_ca.zip new file mode 100644 index 0000000..9971f25 Binary files /dev/null and b/test_repair_ca.zip differ