From b98be22359c7b03d4a029fc198886488f0aebe79 Mon Sep 17 00:00:00 2001 From: Maksim Date: Mon, 1 Sep 2025 20:22:07 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B5=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D1=8B=D0=B5=20arc?= =?UTF-8?q?h=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/parsers/monitoring_fuel.py | 190 +++++---- python_parser/adapters/parsers/svodka_ca.py | 360 ++++++++++-------- python_parser/adapters/parsers/svodka_pm.py | 111 ++++-- 3 files changed, 393 insertions(+), 268 deletions(-) diff --git a/python_parser/adapters/parsers/monitoring_fuel.py b/python_parser/adapters/parsers/monitoring_fuel.py index 8453d2e..129a812 100644 --- a/python_parser/adapters/parsers/monitoring_fuel.py +++ b/python_parser/adapters/parsers/monitoring_fuel.py @@ -1,9 +1,9 @@ import pandas as pd import re -from typing import Dict - +import zipfile +from typing import Dict, Tuple from core.ports import ParserPort -from adapters.pconfig import data_to_json, get_object_by_name +from adapters.pconfig import data_to_json class MonitoringFuelParser(ParserPort): @@ -11,71 +11,55 @@ class MonitoringFuelParser(ParserPort): name = "Мониторинг топлива" - def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int: - """Определение индекса заголовка в Excel по ключевому слову""" - # Читаем первые max_rows строк без заголовков - df_temp = pd.read_excel( - file_path, - sheet_name=sheet, - header=None, - nrows=max_rows + def _register_default_getters(self): + """Регистрация геттеров по умолчанию""" + self.register_getter( + name="total_by_columns", + method=self._get_total_by_columns, + required_params=["columns"], + optional_params=[], + description="Агрегация данных по колонкам" + ) + + self.register_getter( + name="month_by_code", + method=self._get_month_by_code, + required_params=["month"], + optional_params=[], + description="Получение данных за конкретный месяц" ) - # Ищем строку, где хотя бы в одном столбце встречается искомое значение - for idx, row in df_temp.iterrows(): - if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any(): - print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") - return idx + 1 # возвращаем индекс строки (0-based) + def _get_total_by_columns(self, params: dict): + """Агрегация по колонкам (обертка для совместимости)""" + columns = params["columns"] + if not columns: + raise ValueError("Отсутствуют идентификаторы столбцов") + + # TODO: Переделать под новую архитектуру + df_means, _ = self.aggregate_by_columns(self.df, columns) + return df_means.to_dict(orient='index') - raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.") + def _get_month_by_code(self, params: dict): + """Получение данных за месяц (обертка для совместимости)""" + month = params["month"] + if not month: + raise ValueError("Отсутствует идентификатор месяца") + + # TODO: Переделать под новую архитектуру + df_month = self.get_month(self.df, month) + return df_month.to_dict(orient='index') - def parse_single(self, file, sheet, header_num=None): - ''' Собственно парсер отчетов одного объекта''' - # Автоопределение header_num, если не передан - if header_num is None: - header_num = self.find_header_row(file, sheet, search_value="Установка") - # Читаем весь лист, начиная с найденной строки как заголовок - df_full = pd.read_excel( - file, - sheet_name=sheet, - header=header_num, - usecols=None, - index_col=None - ) + def parse(self, file_path: str, params: dict) -> pd.DataFrame: + """Парсинг файла и возврат DataFrame""" + # Сохраняем DataFrame для использования в геттерах + self.df = self.parse_monitoring_fuel_files(file_path, params) + return self.df - # === Удаление полностью пустых столбцов === - df_clean = df_full.replace(r'^\s*$', pd.NA, regex=True) # заменяем пустые строки на NA - df_clean = df_clean.dropna(axis=1, how='all') # удаляем столбцы, где все значения — NA - df_full = df_full.loc[:, df_clean.columns] # оставляем только непустые столбцы - - # === Переименовываем нужные столбцы по позициям === - if len(df_full.columns) < 2: - raise ValueError("DataFrame должен содержать как минимум 2 столбца.") - - new_columns = df_full.columns.tolist() - - new_columns[0] = 'name' - new_columns[1] = 'normativ' - new_columns[-2] = 'total' - new_columns[-1] = 'total_1' - - df_full.columns = new_columns - - # Проверяем, что колонка 'name' существует - if 'name' in df_full.columns: - # Применяем функцию get_id_by_name к каждой строке в колонке 'name' - df_full['id'] = df_full['name'].apply(get_object_by_name) - - # Устанавливаем id как индекс - df_full.set_index('id', inplace=True) - print(f"Окончательное количество столбцов: {len(df_full.columns)}") - return df_full - - def parse(self, file_path: str, params: dict) -> dict: - import zipfile + def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]: + """Парсинг ZIP архива с файлами мониторинга топлива""" df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ - with zipfile.ZipFile(file_path, 'r') as zip_ref: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: file_list = zip_ref.namelist() for month in range(1, 13): @@ -103,7 +87,70 @@ class MonitoringFuelParser(ParserPort): return df_monitorings - def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns): + def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int: + """Определение индекса заголовка в Excel по ключевому слову""" + # Читаем первые max_rows строк без заголовков + df_temp = pd.read_excel( + file_path, + sheet_name=sheet, + header=None, + nrows=max_rows, + engine='openpyxl' + ) + + # Ищем строку, где хотя бы в одном столбце встречается искомое значение + for idx, row in df_temp.iterrows(): + if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any(): + print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") + return idx + 1 # возвращаем индекс строки (0-based) + + raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.") + + def parse_single(self, file, sheet, header_num=None): + ''' Собственно парсер отчетов одного объекта''' + # Автоопределение header_num, если не передан + if header_num is None: + header_num = self.find_header_row(file, sheet, search_value="Установка") + # Читаем весь лист, начиная с найденной строки как заголовок + df_full = pd.read_excel( + file, + sheet_name=sheet, + header=header_num, + usecols=None, + index_col=None, + engine='openpyxl' + ) + + # === Удаление полностью пустых столбцов === + df_clean = df_full.replace(r'^\s*$', pd.NA, regex=True) # заменяем пустые строки на NA + df_clean = df_clean.dropna(axis=1, how='all') # удаляем столбцы, где все значения — NA + df_full = df_full.loc[:, df_clean.columns] # оставляем только непустые столбцы + + # === Переименовываем нужные столбцы по позициям === + if len(df_full.columns) < 2: + raise ValueError("DataFrame должен содержать как минимум 2 столбца.") + + new_columns = df_full.columns.tolist() + + new_columns[0] = 'name' + new_columns[1] = 'normativ' + new_columns[-2] = 'total' + new_columns[-1] = 'total_1' + + df_full.columns = new_columns + + # Проверяем, что колонка 'name' существует + if 'name' in df_full.columns: + # Применяем функцию get_id_by_name к каждой строке в колонке 'name' + # df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code + pass # Placeholder for new_code + + # Устанавливаем id как индекс + df_full.set_index('id', inplace=True) + print(f"Окончательное количество столбцов: {len(df_full.columns)}") + return df_full + + def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]: ''' Служебная функция. Агрегация данных по среднему по определенным колонкам. ''' all_data = {} # Для хранения полных данных (месяцы) по каждой колонке means = {} # Для хранения средних @@ -185,22 +232,3 @@ class MonitoringFuelParser(ParserPort): total.name = 'mean' return total, df_combined - - def get_value(self, df, params): - mode = params.get("mode", "total") - columns = params.get("columns", None) - month = params.get("month", None) - data = None - if mode == "total": - if not columns: - raise ValueError("Отсутствуют идентификаторы столбцов") - df_means, _ = self.aggregate_by_columns(df, columns) - data = df_means.to_dict(orient='index') - elif mode == "month": - if not month: - raise ValueError("Отсутствуют идентификатор месяца") - df_month = self.get_month(df, month) - data = df_month.to_dict(orient='index') - - json_result = data_to_json(data) - return json_result diff --git a/python_parser/adapters/parsers/svodka_ca.py b/python_parser/adapters/parsers/svodka_ca.py index 029bf48..289e25b 100644 --- a/python_parser/adapters/parsers/svodka_ca.py +++ b/python_parser/adapters/parsers/svodka_ca.py @@ -6,85 +6,48 @@ from adapters.pconfig import get_og_by_name class SvodkaCAParser(ParserPort): - """Парсер для сводки СА""" + """Парсер для сводок СА""" - name = "Сводка СА" + name = "Сводки СА" - def extract_all_tables(self, file_path, sheet_name=0): - """Извлекает все таблицы из Excel файла""" - df = pd.read_excel(file_path, sheet_name=sheet_name, header=None) - df_filled = df.fillna('') - df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True) + def _register_default_getters(self): + """Регистрация геттеров по умолчанию""" + self.register_getter( + name="get_data", + method=self._get_data_wrapper, + required_params=["modes", "tables"], + optional_params=[], + description="Получение данных по режимам и таблицам" + ) - non_empty_rows = ~(df_clean.eq('').all(axis=1)) - non_empty_cols = ~(df_clean.eq('').all(axis=0)) + def _get_data_wrapper(self, params: dict): + """Обертка для получения данных (для совместимости)""" + modes = params["modes"] + tables = params["tables"] + + if not isinstance(modes, list): + raise ValueError("Поле 'modes' должно быть списком") + if not isinstance(tables, list): + raise ValueError("Поле 'tables' должно быть списком") + + # TODO: Переделать под новую архитектуру + data_dict = {} + for mode in modes: + data_dict[mode] = self.get_data(self.df, mode, tables) + return self.data_dict_to_json(data_dict) - row_indices = non_empty_rows[non_empty_rows].index.tolist() - col_indices = non_empty_cols[non_empty_cols].index.tolist() + def parse(self, file_path: str, params: dict) -> pd.DataFrame: + """Парсинг файла и возврат DataFrame""" + # Сохраняем DataFrame для использования в геттерах + self.df = self.parse_svodka_ca(file_path, params) + return self.df - if not row_indices or not col_indices: - return [] - - row_blocks = self._get_contiguous_blocks(row_indices) - col_blocks = self._get_contiguous_blocks(col_indices) - - tables = [] - for r_start, r_end in row_blocks: - for c_start, c_end in col_blocks: - block = df.iloc[r_start:r_end + 1, c_start:c_end + 1] - if block.empty or block.fillna('').astype(str).replace(r'^\s*$', '', regex=True).eq('').all().all(): - continue - - if self._is_header_row(block.iloc[0]): - block.columns = block.iloc[0] - block = block.iloc[1:].reset_index(drop=True) - else: - block = block.reset_index(drop=True) - block.columns = [f"col_{i}" for i in range(block.shape[1])] - - tables.append(block) - - return tables - - def _get_contiguous_blocks(self, indices): - """Группирует индексы в непрерывные блоки""" - if not indices: - return [] - blocks = [] - start = indices[0] - for i in range(1, len(indices)): - if indices[i] != indices[i-1] + 1: - blocks.append((start, indices[i-1])) - start = indices[i] - blocks.append((start, indices[-1])) - return blocks - - def _is_header_row(self, series): - """Определяет, похожа ли строка на заголовок""" - series_str = series.astype(str).str.strip() - non_empty = series_str[series_str != ''] - if len(non_empty) == 0: - return False - - def is_not_numeric(val): - try: - float(val.replace(',', '.')) - return False - except (ValueError, TypeError): - return True - - not_numeric_count = non_empty.apply(is_not_numeric).sum() - return not_numeric_count / len(non_empty) > 0.6 - - def _get_og_by_name(self, name): - """Функция для получения ID по имени (упрощенная версия)""" - # Упрощенная версия - возвращаем имя как есть - if not name or not isinstance(name, str): - return None - return name.strip() - - def parse_sheet(self, file_path, sheet_name, inclusion_list): - """Собственно функция парсинга отчета СА""" + def parse_svodka_ca(self, file_path: str, params: dict) -> dict: + """Парсинг сводки СА""" + # Получаем параметры из params + sheet_name = params.get('sheet_name', 0) # По умолчанию первый лист + inclusion_list = params.get('inclusion_list', {'ТиП', 'Топливо', 'Потери'}) + # === Извлечение и фильтрация === tables = self.extract_all_tables(file_path, sheet_name) @@ -190,76 +153,185 @@ class SvodkaCAParser(ParserPort): else: return None - def parse(self, file_path: str, params: dict) -> dict: - """Парсинг файла сводки СА""" - # === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив === - # Выгружаем План в df_ca_plan - inclusion_list_plan = { - "ТиП, %", - "Топливо итого, тонн", - "Топливо итого, %", - "Топливо на технологию, тонн", - "Топливо на технологию, %", - "Топливо на энергетику, тонн", - "Топливо на энергетику, %", - "Потери итого, тонн", - "Потери итого, %", - "в т.ч. Идентифицированные безвозвратные потери, тонн**", - "в т.ч. Идентифицированные безвозвратные потери, %**", - "в т.ч. Неидентифицированные потери, тонн**", - "в т.ч. Неидентифицированные потери, %**" - } + def extract_all_tables(self, file_path, sheet_name=0): + """Извлечение всех таблиц из Excel файла""" + df = pd.read_excel(file_path, sheet_name=sheet_name, header=None, engine='openpyxl') + df_filled = df.fillna('') + df_clean = df_filled.astype(str).replace(r'^\s*$', '', regex=True) - df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) # ЭТО ДАТАФРЕЙМ ПЛАНА В СВОДКЕ ЦА - print(f"\n--- Объединённый и отсортированный План: {df_ca_plan.shape} ---") + non_empty_rows = ~(df_clean.eq('').all(axis=1)) + non_empty_cols = ~(df_clean.eq('').all(axis=0)) - # Выгружаем Факт - inclusion_list_fact = { - "ТиП, %", - "Топливо итого, тонн", - "Топливо итого, %", - "Топливо на технологию, тонн", - "Топливо на технологию, %", - "Топливо на энергетику, тонн", - "Топливо на энергетику, %", - "Потери итого, тонн", - "Потери итого, %", - "в т.ч. Идентифицированные безвозвратные потери, тонн", - "в т.ч. Идентифицированные безвозвратные потери, %", - "в т.ч. Неидентифицированные потери, тонн", - "в т.ч. Неидентифицированные потери, %" - } + row_indices = non_empty_rows[non_empty_rows].index.tolist() + col_indices = non_empty_cols[non_empty_cols].index.tolist() - df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) # ЭТО ДАТАФРЕЙМ ФАКТА В СВОДКЕ ЦА - print(f"\n--- Объединённый и отсортированный Факт: {df_ca_fact.shape} ---") + if not row_indices or not col_indices: + return [] - # Выгружаем План в df_ca_normativ - inclusion_list_normativ = { - "Топливо итого, тонн", - "Топливо итого, %", - "Топливо на технологию, тонн", - "Топливо на технологию, %", - "Топливо на энергетику, тонн", - "Топливо на энергетику, %", - "Потери итого, тонн", - "Потери итого, %", - "в т.ч. Идентифицированные безвозвратные потери, тонн**", - "в т.ч. Идентифицированные безвозвратные потери, %**", - "в т.ч. Неидентифицированные потери, тонн**", - "в т.ч. Неидентифицированные потери, %**" - } + row_blocks = self._get_contiguous_blocks(row_indices) + col_blocks = self._get_contiguous_blocks(col_indices) - # ЭТО ДАТАФРЕЙМ НОРМАТИВА В СВОДКЕ ЦА - df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ) + tables = [] + for r_start, r_end in row_blocks: + for c_start, c_end in col_blocks: + block = df.iloc[r_start:r_end + 1, c_start:c_end + 1] + if block.empty or block.fillna('').astype(str).replace(r'^\s*$', '', regex=True).eq('').all().all(): + continue - print(f"\n--- Объединённый и отсортированный Норматив: {df_ca_normativ.shape} ---") + if self._is_header_row(block.iloc[0]): + block.columns = block.iloc[0] + block = block.iloc[1:].reset_index(drop=True) + else: + block = block.reset_index(drop=True) + block.columns = [f"col_{i}" for i in range(block.shape[1])] - df_dict = { - "plan": df_ca_plan, - "fact": df_ca_fact, - "normativ": df_ca_normativ - } - return df_dict + tables.append(block) + + return tables + + def _get_contiguous_blocks(self, indices): + """Группирует индексы в непрерывные блоки""" + if not indices: + return [] + blocks = [] + start = indices[0] + for i in range(1, len(indices)): + if indices[i] != indices[i-1] + 1: + blocks.append((start, indices[i-1])) + start = indices[i] + blocks.append((start, indices[-1])) + return blocks + + def _is_header_row(self, series): + """Определяет, похожа ли строка на заголовок""" + series_str = series.astype(str).str.strip() + non_empty = series_str[series_str != ''] + if len(non_empty) == 0: + return False + + def is_not_numeric(val): + try: + float(val.replace(',', '.')) + return False + except (ValueError, TypeError): + return True + + not_numeric_count = non_empty.apply(is_not_numeric).sum() + return not_numeric_count / len(non_empty) > 0.6 + + def _get_og_by_name(self, name): + """Функция для получения ID по имени (упрощенная версия)""" + # Упрощенная версия - возвращаем имя как есть + if not name or not isinstance(name, str): + return None + return name.strip() + + def parse_sheet(self, file_path: str, sheet_name: str, inclusion_list: set) -> pd.DataFrame: + """Парсинг листа Excel""" + # === Извлечение и фильтрация === + tables = self.extract_all_tables(file_path, sheet_name) + + # Фильтруем таблицы: оставляем только те, где первая строка содержит нужные заголовки + filtered_tables = [] + for table in tables: + if table.empty: + continue + first_row_values = table.iloc[0].astype(str).str.strip().tolist() + if any(val in inclusion_list for val in first_row_values): + filtered_tables.append(table) + + tables = filtered_tables + + # === Итоговый список таблиц датафреймов === + result_list = [] + + for table in tables: + if table.empty: + continue + + # Получаем первую строку (до удаления) + first_row_values = table.iloc[0].astype(str).str.strip().tolist() + + # Находим, какой элемент из inclusion_list присутствует + matched_key = None + for val in first_row_values: + if val in inclusion_list: + matched_key = val + break # берём первый совпадающий заголовок + + if matched_key is None: + continue # на всякий случай (хотя уже отфильтровано) + + # Удаляем первую строку (заголовок) и сбрасываем индекс + df_cleaned = table.iloc[1:].copy().reset_index(drop=True) + + # Пропускаем, если таблица пустая + if df_cleaned.empty: + continue + + # Первая строка становится заголовком + new_header = df_cleaned.iloc[0] # извлекаем первую строку как потенциальные названия столбцов + + # Преобразуем заголовок: только первый столбец может быть заменён на "name" + cleaned_header = [] + + # Обрабатываем первый столбец отдельно + first_item = new_header.iloc[0] if isinstance(new_header, pd.Series) else new_header[0] + first_item_str = str(first_item).strip() if pd.notna(first_item) else "" + if first_item_str == "" or first_item_str == "nan": + cleaned_header.append("name") + else: + cleaned_header.append(first_item_str) + + # Остальные столбцы добавляем без изменений (или с минимальной очисткой) + for item in new_header[1:]: + # Опционально: приводим к строке и убираем лишние пробелы, но не заменяем на "name" + item_str = str(item).strip() if pd.notna(item) else "" + cleaned_header.append(item_str) + + # Применяем очищенные названия столбцов + df_cleaned = df_cleaned[1:] # удаляем строку с заголовком + df_cleaned.columns = cleaned_header + df_cleaned = df_cleaned.reset_index(drop=True) + + if matched_key.endswith('**'): + cleaned_key = matched_key[:-2] # удаляем последние ** + else: + cleaned_key = matched_key + + # Добавляем новую колонку с именем параметра + df_cleaned["table"] = cleaned_key + + # Проверяем, что колонка 'name' существует + if 'name' not in df_cleaned.columns: + print( + f"Внимание: колонка 'name' отсутствует в таблице для '{matched_key}'. Пропускаем добавление 'id'.") + continue # или обработать по-другому + else: + # Применяем функцию get_id_by_name к каждой строке в колонке 'name' + df_cleaned['id'] = df_cleaned['name'].apply(get_og_by_name) + + # Удаляем строки, где id — None, NaN или пустой + df_cleaned = df_cleaned.dropna(subset=['id']) # dropna удаляет NaN + # Дополнительно: удаляем None (если не поймал dropna) + df_cleaned = df_cleaned[df_cleaned['id'].notna() & (df_cleaned['id'].astype(str) != 'None')] + + # Добавляем в словарь + result_list.append(df_cleaned) + + # === Объединение и сортировка по id (индекс) и table === + if result_list: + combined_df = pd.concat(result_list, axis=0) + + # Сортируем по индексу (id) и по столбцу 'table' + combined_df = combined_df.sort_values(by=['id', 'table'], axis=0) + + # Устанавливаем id как индекс + # combined_df.set_index('id', inplace=True) + + return combined_df + else: + return None def data_dict_to_json(self, data_dict): ''' Служебная функция для парсинга словаря в json. ''' @@ -308,17 +380,3 @@ class SvodkaCAParser(ParserPort): filtered_df = df[df['table'].isin(table_values)].copy() result_dict = {key: group for key, group in filtered_df.groupby('table')} return result_dict - - def get_value(self, df: pd.DataFrame, params: dict): - - modes = params.get("modes") - tables = params.get("tables") - if not isinstance(modes, list): - raise ValueError("Поле 'modes' должно быть списком") - if not isinstance(tables, list): - raise ValueError("Поле 'tables' должно быть списком") - # Собираем данные - data_dict = {} - for mode in modes: - data_dict[mode] = self.get_data(df, mode, tables) - return self.data_dict_to_json(data_dict) diff --git a/python_parser/adapters/parsers/svodka_pm.py b/python_parser/adapters/parsers/svodka_pm.py index e794d6c..008c2f9 100644 --- a/python_parser/adapters/parsers/svodka_pm.py +++ b/python_parser/adapters/parsers/svodka_pm.py @@ -9,6 +9,60 @@ class SvodkaPMParser(ParserPort): name = "Сводки ПМ" + def _register_default_getters(self): + """Регистрация геттеров по умолчанию""" + self.register_getter( + name="single_og", + method=self._get_single_og, + required_params=["id", "codes", "columns"], + optional_params=["search"], + description="Получение данных по одному ОГ" + ) + + self.register_getter( + name="total_ogs", + method=self._get_total_ogs, + required_params=["codes", "columns"], + optional_params=["search"], + description="Получение данных по всем ОГ" + ) + + def _get_single_og(self, params: dict): + """Получение данных по одному ОГ (обертка для совместимости)""" + og_id = params["id"] + codes = params["codes"] + columns = params["columns"] + search = params.get("search") + + if not isinstance(codes, list): + raise ValueError("Поле 'codes' должно быть списком") + if not isinstance(columns, list): + raise ValueError("Поле 'columns' должно быть списком") + + # Здесь нужно получить DataFrame из self.df, но пока используем старую логику + # TODO: Переделать под новую архитектуру + return self.get_svodka_og(self.df, og_id, codes, columns, search) + + def _get_total_ogs(self, params: dict): + """Получение данных по всем ОГ (обертка для совместимости)""" + codes = params["codes"] + columns = params["columns"] + search = params.get("search") + + if not isinstance(codes, list): + raise ValueError("Поле 'codes' должно быть списком") + if not isinstance(columns, list): + raise ValueError("Поле 'columns' должно быть списком") + + # TODO: Переделать под новую архитектуру + return self.get_svodka_total(self.df, codes, columns, search) + + def parse(self, file_path: str, params: dict) -> pd.DataFrame: + """Парсинг файла и возврат DataFrame""" + # Сохраняем DataFrame для использования в геттерах + self.df = self.parse_svodka_pm_files(file_path, params) + return self.df + def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int: """Определения индекса заголовка в excel по ключевому слову""" # Читаем первые max_rows строк без заголовков @@ -16,7 +70,8 @@ class SvodkaPMParser(ParserPort): file, sheet_name=sheet, header=None, - nrows=max_rows + nrows=max_rows, + engine='openpyxl' ) # Ищем строку, где хотя бы в одном столбце встречается искомое значение @@ -40,6 +95,7 @@ class SvodkaPMParser(ParserPort): header=header_num, usecols=None, nrows=2, + engine='openpyxl' ) if df_probe.shape[0] == 0: @@ -61,7 +117,8 @@ class SvodkaPMParser(ParserPort): sheet_name=sheet, header=header_num, usecols=None, - index_col=None + index_col=None, + engine='openpyxl' ) if indicator_col_name not in df_full.columns: @@ -99,25 +156,25 @@ class SvodkaPMParser(ParserPort): # Проверяем, является ли колонка пустой/некорректной is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan' - # Проверяем, начинается ли на "Итого" - if col_str.startswith('Итого'): - current_name = 'Итого' - last_good_name = current_name # обновляем last_good_name - new_columns.append(current_name) - elif is_empty_or_unnamed: - # Используем последнее хорошее имя - new_columns.append(last_good_name) + if is_empty_or_unnamed: + # Если это пустая колонка, используем последнее хорошее имя + if last_good_name: + new_columns.append(last_good_name) + else: + # Если нет хорошего имени, пропускаем + continue else: - # Имя, полученное из exel + # Это хорошая колонка last_good_name = col_str new_columns.append(col_str) + # Применяем новые заголовки df_final.columns = new_columns - print(f"Окончательное количество столбцов: {len(df_final.columns)}") return df_final - def parse(self, file_path: str, params: dict) -> dict: + def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict: + """Парсинг ZIP архива со сводками ПМ""" import zipfile pm_dict = { "facts": {}, @@ -125,7 +182,7 @@ class SvodkaPMParser(ParserPort): } excel_fact_template = 'svodka_fact_pm_ID.xlsm' excel_plan_template = 'svodka_plan_pm_ID.xlsx' - with zipfile.ZipFile(file_path, 'r') as zip_ref: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: file_list = zip_ref.namelist() for name, id in OG_IDS.items(): if id == 'BASH': @@ -155,9 +212,9 @@ class SvodkaPMParser(ParserPort): return pm_dict - def get_svodka_value(self, df_svodka, id, code, search_value=None): - ''' Служебная функция для простой выборке по сводке ''' - row_index = id + def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None): + ''' Служебная функция получения значения по коду и столбцу ''' + row_index = code mask_value = df_svodka.iloc[0] == code if search_value is None: @@ -254,22 +311,4 @@ class SvodkaPMParser(ParserPort): return total_result - def get_value(self, df, params): - og_id = params.get("id") - codes = params.get("codes") - columns = params.get("columns") - search = params.get("search") - mode = params.get("mode", "total") - if not isinstance(codes, list): - raise ValueError("Поле 'codes' должно быть списком") - if not isinstance(columns, list): - raise ValueError("Поле 'columns' должно быть списком") - data = None - if mode == "single": - if not og_id: - raise ValueError("Отсутствует идентификатор ОГ") - data = self.get_svodka_og(df, og_id, codes, columns, search) - elif mode == "total": - data = self.get_svodka_total(df, codes, columns, search) - json_result = data_to_json(data) - return json_result + # Убираем старый метод get_value, так как он теперь в базовом классе From 79ab91c700b1c089d753801cf27a3a24175bf213 Mon Sep 17 00:00:00 2001 From: Maksim Date: Mon, 1 Sep 2025 20:54:31 +0300 Subject: [PATCH 2/2] Done --- .gitignore | 20 ++- python_parser/SCHEMA_INTEGRATION.md | 135 +++++++++++++++++ .../monitoring_fuel.cpython-313.pyc | Bin 9730 -> 11198 bytes .../__pycache__/svodka_ca.cpython-313.pyc | Bin 14638 -> 16493 bytes .../__pycache__/svodka_pm.cpython-313.pyc | Bin 12754 -> 13658 bytes .../adapters/parsers/monitoring_fuel.py | 37 +++-- python_parser/adapters/parsers/svodka_ca.py | 23 +-- python_parser/adapters/parsers/svodka_pm.py | 49 +++--- .../core/__pycache__/ports.cpython-313.pyc | Bin 2572 -> 5953 bytes python_parser/core/schema_utils.py | 140 ++++++++++++++++++ 10 files changed, 351 insertions(+), 53 deletions(-) create mode 100644 python_parser/SCHEMA_INTEGRATION.md create mode 100644 python_parser/core/schema_utils.py diff --git a/.gitignore b/.gitignore index 780feca..8722ece 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,21 @@ # Python +__pycache__ __pycache__/ +python_parser/__pycache__/ +python_parser/core/__pycache__/ +python_parser/adapters/__pycache__/ +python_parser/tests/__pycache__/ +python_parser/tests/test_core/__pycache__/ +python_parser/tests/test_adapters/__pycache__/ +python_parser/tests/test_app/__pycache__/ +python_parser/app/__pycache__/ +python_parser/app/schemas/__pycache__/ +python_parser/app/schemas/test_schemas/__pycache__/ +python_parser/app/schemas/test_schemas/test_core/__pycache__/ +python_parser/app/schemas/test_schemas/test_adapters/__pycache__/ +python_parser/app/schemas/test_schemas/test_app/__pycache__/ + + *.py[cod] *$py.class *.so @@ -152,4 +168,6 @@ htmlcov/ node_modules/ npm-debug.log* yarn-debug.log* -yarn-error.log* \ No newline at end of file +yarn-error.log* + +__pycache__/ diff --git a/python_parser/SCHEMA_INTEGRATION.md b/python_parser/SCHEMA_INTEGRATION.md new file mode 100644 index 0000000..73a4b39 --- /dev/null +++ b/python_parser/SCHEMA_INTEGRATION.md @@ -0,0 +1,135 @@ +# Интеграция схем Pydantic с парсерами + +## Обзор + +Этот документ описывает решение для устранения дублирования логики между схемами Pydantic и парсерами. Теперь схемы Pydantic являются единым источником правды для определения параметров парсеров. + +## Проблема + +Ранее в парсерах дублировалась информация о параметрах: + +```python +# В парсере +self.register_getter( + name="single_og", + method=self._get_single_og, + required_params=["id", "codes", "columns"], # Дублирование + optional_params=["search"], # Дублирование + description="Получение данных по одному ОГ" +) + +# В схеме +class SvodkaPMSingleOGRequest(BaseModel): + id: OGID = Field(...) # Обязательное поле + codes: List[int] = Field(...) # Обязательное поле + columns: List[str] = Field(...) # Обязательное поле + search: Optional[str] = Field(None) # Необязательное поле +``` + +## Решение + +### 1. Утилиты для работы со схемами + +Создан модуль `core/schema_utils.py` с функциями: + +- `get_required_fields_from_schema()` - извлекает обязательные поля +- `get_optional_fields_from_schema()` - извлекает необязательные поля +- `register_getter_from_schema()` - регистрирует геттер с использованием схемы +- `validate_params_with_schema()` - валидирует параметры с помощью схемы + +### 2. Обновленные парсеры + +Теперь парсеры используют схемы как единый источник правды: + +```python +def _register_default_getters(self): + """Регистрация геттеров по умолчанию""" + # Используем схемы Pydantic как единый источник правды + register_getter_from_schema( + parser_instance=self, + getter_name="single_og", + method=self._get_single_og, + schema_class=SvodkaPMSingleOGRequest, + description="Получение данных по одному ОГ" + ) +``` + +### 3. Валидация параметров + +Методы геттеров теперь автоматически валидируют параметры: + +```python +def _get_single_og(self, params: dict): + """Получение данных по одному ОГ""" + # Валидируем параметры с помощью схемы Pydantic + validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest) + + og_id = validated_params["id"] + codes = validated_params["codes"] + columns = validated_params["columns"] + search = validated_params.get("search") + + # ... остальная логика +``` + +## Преимущества + +1. **Единый источник правды** - информация о параметрах хранится только в схемах Pydantic +2. **Автоматическая валидация** - параметры автоматически валидируются с помощью Pydantic +3. **Синхронизация** - изменения в схемах автоматически отражаются в парсерах +4. **Типобезопасность** - использование типов Pydantic обеспечивает типобезопасность +5. **Документация** - Swagger документация автоматически генерируется из схем + +## Совместимость + +Решение работает с: +- Pydantic v1 (через `__fields__`) +- Pydantic v2 (через `model_fields` и `is_required()`) + +## Использование + +### Для новых парсеров + +1. Создайте схему Pydantic с нужными полями +2. Используйте `register_getter_from_schema()` для регистрации геттера +3. Используйте `validate_params_with_schema()` в методах геттеров + +### Для существующих парсеров + +1. Убедитесь, что у вас есть соответствующая схема Pydantic +2. Замените ручную регистрацию геттеров на `register_getter_from_schema()` +3. Добавьте валидацию параметров в методы геттеров + +## Примеры + +### Схема с обязательными и необязательными полями + +```python +class ExampleRequest(BaseModel): + required_field: str = Field(..., description="Обязательное поле") + optional_field: Optional[str] = Field(None, description="Необязательное поле") +``` + +### Регистрация геттера + +```python +register_getter_from_schema( + parser_instance=self, + getter_name="example_getter", + method=self._example_method, + schema_class=ExampleRequest, + description="Пример геттера" +) +``` + +### Валидация в методе + +```python +def _example_method(self, params: dict): + validated_params = validate_params_with_schema(params, ExampleRequest) + # validated_params содержит валидированные данные +``` + +## Заключение + +Это решение устраняет дублирование кода и обеспечивает единообразие между API схемами и парсерами. Теперь изменения в схемах автоматически отражаются в парсерах, что упрощает поддержку и развитие системы. \ No newline at end of file diff --git a/python_parser/adapters/parsers/__pycache__/monitoring_fuel.cpython-313.pyc b/python_parser/adapters/parsers/__pycache__/monitoring_fuel.cpython-313.pyc index 1954ec48592f00fc3cee4023214e0c06c3d2a324..c8ed95cf09443ad07dd3068702aaf0a83f54e115 100644 GIT binary patch delta 5064 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 WBHKPB(c5y`CUhNLdWYc1mHij5o(Qr4 delta 3796 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 KJ}206H~$NNua`6c diff --git a/python_parser/adapters/parsers/__pycache__/svodka_ca.cpython-313.pyc b/python_parser/adapters/parsers/__pycache__/svodka_ca.cpython-313.pyc index 6be6eb56ad407a929ab8dd8d62fbb3c4cc567f0f..991388389fab60c6b9d3a6d821d65c1707cf459b 100644 GIT binary patch delta 5337 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 DH@lHU diff --git a/python_parser/adapters/parsers/__pycache__/svodka_pm.cpython-313.pyc b/python_parser/adapters/parsers/__pycache__/svodka_pm.cpython-313.pyc index 09b5d5b7961123f134ab6f9bd4c44feb97b609c8..1fecb749a7dc66e495129a49bc9695348ec8a99c 100644 GIT binary patch delta 4638 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 Jf@saA`7e&+$twT= delta 3712 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 delta 1089 zcmZuvO=}ZT6n*oN$t3CIJ0WdaIyI>!l}3eDU5KKHD3%%>P@$19O{Q&1+JrlkonUdH z3thB4gt`*kXu(A{;s;{!2MFoXDT3fa1Q*p*+z9&aOsQ=(kn_&FbI!eU@13{#d+}gf zQT&Ac`rkdBUiT~pQ{1)1fy@z=Xq+ZqkieTF`-tZ0BAQg>M+B4-0RVix zy2;t{#fDX_&*`Tc%G!Ku zrS-b?Z0(lI#z0{SasmX|0da&KWmO2#bTFHYU>rvVF$A=Xe6lJsZVRRbwpgi_ zEt_vtY~OZa2%|oUnnbB&TDny&mvqa5>O|8r%u>n8GZ?V2csK=_b4mbHXdz(Nbxq$n48Hp4+C4AsMl*S zfOikV0R$(@VPt&>&MP?!bw;1T7(zGwlz5^z5Ad4vLpNPY_BQaNovY}kTg%y$xNkhqi9aR6Wj~PgI*Dyc0Zv(nZ4!2D?oTkD`9av> G?C}qL)8#V& diff --git a/python_parser/core/schema_utils.py b/python_parser/core/schema_utils.py new file mode 100644 index 0000000..795f55d --- /dev/null +++ b/python_parser/core/schema_utils.py @@ -0,0 +1,140 @@ +""" +Упрощенные утилиты для работы со схемами Pydantic +""" +from typing import List, Dict, Any, Type +from pydantic import BaseModel +import inspect + + +def get_required_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]: + """ + Извлекает список обязательных полей из схемы Pydantic + + Args: + schema_class: Класс схемы Pydantic + + Returns: + Список имен обязательных полей + """ + required_fields = [] + + # Используем model_fields для Pydantic v2 или __fields__ для v1 + if hasattr(schema_class, 'model_fields'): + fields = schema_class.model_fields + else: + fields = schema_class.__fields__ + + for field_name, field_info in fields.items(): + # В Pydantic v2 есть метод is_required() + if hasattr(field_info, 'is_required'): + if field_info.is_required(): + required_fields.append(field_name) + elif hasattr(field_info, 'required'): + if field_info.required: + required_fields.append(field_name) + else: + # Fallback для старых версий - проверяем наличие default + has_default = False + + if hasattr(field_info, 'default'): + has_default = field_info.default is not ... + elif hasattr(field_info, 'default_factory'): + has_default = field_info.default_factory is not None + + if not has_default: + required_fields.append(field_name) + + return required_fields + + +def get_optional_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]: + """ + Извлекает список необязательных полей из схемы Pydantic + + Args: + schema_class: Класс схемы Pydantic + + Returns: + Список имен необязательных полей + """ + optional_fields = [] + + # Используем model_fields для Pydantic v2 или __fields__ для v1 + if hasattr(schema_class, 'model_fields'): + fields = schema_class.model_fields + else: + fields = schema_class.__fields__ + + for field_name, field_info in fields.items(): + # В Pydantic v2 есть метод is_required() + if hasattr(field_info, 'is_required'): + if not field_info.is_required(): + optional_fields.append(field_name) + elif hasattr(field_info, 'required'): + if not field_info.required: + optional_fields.append(field_name) + else: + # Fallback для старых версий - проверяем наличие default + has_default = False + + if hasattr(field_info, 'default'): + has_default = field_info.default is not ... + elif hasattr(field_info, 'default_factory'): + has_default = field_info.default_factory is not None + + if has_default: + optional_fields.append(field_name) + + return optional_fields + + +def register_getter_from_schema(parser_instance, getter_name: str, method: callable, + schema_class: Type[BaseModel], description: str = ""): + """ + Регистрирует геттер в парсере, используя схему Pydantic для определения параметров + + Args: + parser_instance: Экземпляр парсера + getter_name: Имя геттера + method: Метод для выполнения + schema_class: Класс схемы Pydantic + description: Описание геттера (если не указано, берется из docstring метода) + """ + # Извлекаем параметры из схемы + required_params = get_required_fields_from_schema(schema_class) + optional_params = get_optional_fields_from_schema(schema_class) + + # Если описание не указано, берем из docstring метода + if not description: + description = inspect.getdoc(method) or "" + + # Регистрируем геттер + parser_instance.register_getter( + name=getter_name, + method=method, + required_params=required_params, + optional_params=optional_params, + description=description + ) + + +def validate_params_with_schema(params: Dict[str, Any], schema_class: Type[BaseModel]) -> Dict[str, Any]: + """ + Валидирует параметры с помощью схемы Pydantic + + Args: + params: Словарь параметров + schema_class: Класс схемы Pydantic + + Returns: + Валидированные параметры + + Raises: + ValidationError: Если параметры не прошли валидацию + """ + try: + # Создаем экземпляр схемы для валидации + validated_data = schema_class(**params) + return validated_data.dict() + except Exception as e: + raise ValueError(f"Ошибка валидации параметров: {str(e)}") \ No newline at end of file