From 631e58dad7bdd8a313d106448675b19b1129709c Mon Sep 17 00:00:00 2001 From: Maksim Date: Wed, 3 Sep 2025 11:35:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/pconfig.cpython-313.pyc | Bin 8206 -> 10543 bytes .../adapters/parsers/README_svodka_pm.md | 88 +++++ .../monitoring_fuel.cpython-313.pyc | Bin 11198 -> 14436 bytes .../__pycache__/svodka_ca.cpython-313.pyc | Bin 17230 -> 18750 bytes .../__pycache__/svodka_pm.cpython-313.pyc | Bin 13658 -> 12621 bytes .../adapters/parsers/svodka_pm copy.py | 326 ++++++++++++++++ python_parser/adapters/parsers/svodka_pm.py | 365 +++++++++--------- python_parser/adapters/pconfig.py | 82 +++- python_parser/app/schemas/svodka_pm.py | 2 +- 9 files changed, 674 insertions(+), 189 deletions(-) create mode 100644 python_parser/adapters/parsers/README_svodka_pm.md create mode 100644 python_parser/adapters/parsers/svodka_pm copy.py diff --git a/python_parser/adapters/__pycache__/pconfig.cpython-313.pyc b/python_parser/adapters/__pycache__/pconfig.cpython-313.pyc index 04c2debfca9f66d3b422f35a36a05254739069a5..7816f04a6ef941be27dbacdb83a734f4c73ce612 100644 GIT binary patch delta 4276 zcmahMZEzFU@!d(Muajljl4V)=Lm1gce3%a#uwCMqf^De5seQ;0Oi&c-Yzt(`c_)J* zY2_8n0Pn5s1KaSg2)u_h4FZ z2&IBiFbRc%Tkr_wLZwhuYi(tybrlh+yBKN{YJ{aiP*^4`7gh)>g<4^iuv%CntgYoK zFVy)6wF~ut^MrMP9edeIBCHoSG%`YikF3Mp0cidLe&Ygug;Qt(wF2QG;U_+_pP_|9 zv#?R8Tmww-;puv2j7^ibAXR3mz57jEVwkGoKwy#XGiQf zIjel2KC3fM7z2n@<0G9fY}LKmqO5sFiEgQBN250L{=J|ZWg0z`#~ zT!`F+C1m=dp_yU6k?$G@*&arIjB7{>>`GWpu)VmU4&Aun1JI4TJ^&$h9g|}%om+|a z6Y?W=(C3AahRJt^M!?76`X4cY!9D;XD`)woD!=rLa{u{( zGXv*`&J0cNm?>{kEsKs>Z@E3E+Q-{x-2v4dnAknzu03u&X3P{XIz2G{%rW~dukXD1 zjQPC%jD5oX=#2NFV|llEuVVDdU8Z08@a|}i0WlR$MV^3T!5HZ(VD6ItYpP(DO&6Ov zmRTy7SsR&%yvy2o(U>rXjNP2v>$rJ${3OIlte*r3PyD183!n~yUEd=wWB_@`ck&_= z%pU>!M0s7g1||5CauLS+Fs>+<)f4_SybxD(-g{8Er~Jyh@PJH1{eB;izV#5MWqDKQv1$1sRHQnj%u_@UWP>z37x?=}RV3kyt{~43WfP zjT@%1L`t*uMAGpQu@$sv(5xA;>_PtH`Du1ZjL^Qp@WBX3XqKVKXjr#xj`oLB;t&*w zSaeh~AP-+q%yxiW!sqwLAkTAWk6Kg$i8C;y89-wM3SfT0iJ=#VGM2PGK5jW?$ncI6O~;#l)A!QDvwXSAm(TJ6l@HAD)wBFEm0vc? z*QtEnROGUK>Y3|ZN`3oBqS6~y_&SAue3p-@d~}xYpW*v&*$Wi+mg~ax){jaQp+|Xi zk1C}1DBZnE?_QL5xz(%?oztvUC{I3>CFE&l zI}1Oa9qe5~_)es`PL}F50CRpG@U7Qtz;A)r$L%w{0$rVt44Fb)$W+e?EHqJ;A&x62 zJ2|=DxxOJl7B6_?%_IWJl`!_=>FOXr;c$?;`@Mn42wcLr$vFL{^Hau0G08MzOiDz8 zjAlqavHyOFlnd*ai}K$KtNpr@bFGDL6OF?*mpmBYf4yjXDsr+ii+u3*D1yIM!fJ%4q zD=YyMujtNfg$6KLAdsf5gd8NVaDK7_oWgS$U^c8Vpq&9w(4mvo!*7tK7`?_El(3ze z%R0INdG&IMZ*i!0V?5axiA&A38wbRMI66$5{|&2>8_<`KfQrBEpLMO1k%_)Hy+GX4 zAMED}TuiWx>hGR@nXFqPsYn~ewxJ&sWowDV9F%`ivNF8`rh0XOauSczE|_XotcJs> zSTYgh=^^AWVMhpr{M=#~?k*h#aS2;m z|2&M1>BWUY9g#i0fawF2=$vlxJ>@MtRG=AJr z)zM7;QEo%rT(sR|_@s#0?vdXqO{HZ}qXz(CX^z6Dpsyn5H9(quuImaTUt>f(6_PtN zOlkehT3T||*p4#Lb%pm!$prm9EWZsy>H&0bjNEaWxT0^J>o|Wl!5F!I=0`K}X3UOaMo#r5rKewSkDl0Pq7nJ!&U-m-177%n+B84Mp9I3VFh7J<1h*8&2% z1Af|qZoPeko_r(${dO`gQOp2(4iG%ZBSXW7>9a_ihM_BrNO}RIJ`{mWAev3s*4e(j zHT+1sK(`tt&-A89AyYA2u079d?nxax_+Sr^i7!c|T*WQ&mY z5H2vWCtHlP7wMjC3DQ2o`N4@rNSBh`+=|Iv*)p7z6P>O=+E2KZlLOhsNLQjm=Ij!r z1EeC5ae6XtA7WQU##xeadNa${W(rI0tOJQL)19V52Rmky$17HFcBbS`x%}6Phh1Oy SFixhE{ieEuVfq>UJpT_2HG#7L delta 2025 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 Z$79X(5A3P>`m!UUP&KmVZv;oK_HPwIJ|6%8 delta 1965 zcmaJ>Yitx%6rQ^~JNsyNx82?DZo6em-<`s?1$TkgSOR^JXuzaH%Oew(ad+A--R^Gg zOraE$HpcLXB9E(rF=+9J0x_Z3L`;mv5aSP{q)my)!~_!kxFJmDK%BRQkCQYB{%$_B6;O9uu~RyNo6QML}J%8Ww zswKR?r%N8xRI154l`$pC5Div88xSiHfje2 z>HuJ0IYS;xdf2e*`D8ki(nW!JTy^D+^l)-qRbV$sY62=!*S4|efy{6#0$06x?(m(h z`<5d67aZXSzPj1YMPKZseOcf_k-Ne6#bEm!f75=${#j@#xb0NQNym@g&|PoMqPJ%D z$bz?K!P~yzXs1yymwKEEP>x$I!7Kk?0?$7s$nnMWho4A41f~qzcs{3((mLR@9^mh+ zohWpUsCu%f(1Z%jMTIKlH3%&TF#yA?1e((DG}vkH7JC~It)FAxd84h6av*%6`&{>I z!$-}Ro3A96{2Tr|U3SgiSiYKJKiGJS8{}B67+@>@^*kILVN(^)5(~Rs5v{_oL@PBT z+5p@POT+$PL&0^CP9PdaI>SK({?J|D#zo)8xy~hD-~5`;I^Jejvucj*jI@!>?Bz&| z#Mre+oHVifk+md#zB)m+&Tpz7;z<`fy;hqxEQPe;qN_#;^G?uQ8 z7Yu7AJ!x3cg@a?qYWqo&-Kzc5(+d3nb+M+Phl|yj|GDlmaT1P;a)bOph$;1ccDunv zD%rh;Pkg53A+QYRy9d4OWtSSm75jls*$&8BYr}4l&;uyxV&69Y;daw3P7Wd{?E9wr z_U%YV0SrOUXET~^I5lVw^VXQTiS}YXyHIg}H8pP&`cS==5r2iRpioUsYMI~=?p44dlg;jr^>i0dW+$mf@RaL;ZU;KWE$jf3(QMwg|`5+ z)0M1dIEFJ>H94jprPyRfnE)?RMum4`T+vgbG{z#aC=pp#Y>m)}3L>V-Xe>b9n?Diz z!a@$SzK;5q^Q-RoG|d;F{${i8z%5SmZWVp7vmH^=#;$kNTfX7u?{qNB^lP~Fb%fIZ zQx>_`;OHATKZAe;qh}E=0*G#U4tcR+$V|@+7&PU?OJ>dJ+bF<8ES~HIWVqWbmgsz< zLLYN$VEGct%mXzafcJ5F8DWxHaO^-GQu 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 c5a523e73e2677c40cb234800492e84ee3f10402..08aa240b9e1f3c1a58f7f69efa74db3ef341115a 100644 GIT binary patch delta 3237 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 delta 2125 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 literal 13658 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 diff --git a/python_parser/adapters/parsers/svodka_pm copy.py b/python_parser/adapters/parsers/svodka_pm copy.py new file mode 100644 index 0000000..3901a08 --- /dev/null +++ b/python_parser/adapters/parsers/svodka_pm copy.py @@ -0,0 +1,326 @@ +import pandas as pd + +from core.ports import ParserPort +from core.schema_utils import register_getter_from_schema, validate_params_with_schema +from app.schemas.svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest +from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json + + +class SvodkaPMParser(ParserPort): + """Парсер для сводок ПМ (план и факт)""" + + name = "Сводки ПМ" + + def _register_default_getters(self): + """Регистрация геттеров по умолчанию""" + # Используем схемы Pydantic как единый источник правды + register_getter_from_schema( + parser_instance=self, + getter_name="single_og", + method=self._get_single_og, + schema_class=SvodkaPMSingleOGRequest, + description="Получение данных по одному ОГ" + ) + + register_getter_from_schema( + parser_instance=self, + getter_name="total_ogs", + method=self._get_total_ogs, + schema_class=SvodkaPMTotalOGsRequest, + description="Получение данных по всем ОГ" + ) + + def _get_single_og(self, params: dict): + """Получение данных по одному ОГ""" + # Валидируем параметры с помощью схемы Pydantic + validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest) + + og_id = validated_params["id"] + codes = validated_params["codes"] + columns = validated_params["columns"] + search = validated_params.get("search") + + # Здесь нужно получить DataFrame из self.df, но пока используем старую логику + # TODO: Переделать под новую архитектуру + return self.get_svodka_og(self.df, og_id, codes, columns, search) + + def _get_total_ogs(self, params: dict): + """Получение данных по всем ОГ""" + # Валидируем параметры с помощью схемы Pydantic + validated_params = validate_params_with_schema(params, SvodkaPMTotalOGsRequest) + + codes = validated_params["codes"] + columns = validated_params["columns"] + search = validated_params.get("search") + + # TODO: Переделать под новую архитектуру + return self.get_svodka_total(self.df, codes, columns, search) + + def parse(self, file_path: str, params: dict) -> pd.DataFrame: + """Парсинг файла и возврат DataFrame""" + # Сохраняем DataFrame для использования в геттерах + self.df = self.parse_svodka_pm_files(file_path, params) + return self.df + + def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int: + """Определения индекса заголовка в excel по ключевому слову""" + # Читаем первые max_rows строк без заголовков + df_temp = pd.read_excel( + file, + sheet_name=sheet, + header=None, + nrows=max_rows, + engine='openpyxl' + ) + + # Ищем строку, где хотя бы в одном столбце встречается искомое значение + for idx, row in df_temp.iterrows(): + if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any(): + print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") + return idx # 0-based index — то, что нужно для header= + + raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.") + + def parse_svodka_pm(self, file, sheet, header_num=None): + ''' Собственно парсер отчетов одного ОГ для БП, ПП и факта ''' + # Автоопределение header_num, если не передан + if header_num is None: + header_num = self.find_header_row(file, sheet, search_value="Итого") + + # Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID + df_probe = pd.read_excel( + file, + sheet_name=sheet, + header=header_num, + usecols=None, + nrows=2, + engine='openpyxl' + ) + + if df_probe.shape[0] == 0: + raise ValueError("Файл пуст или не содержит данных.") + + first_data_row = df_probe.iloc[0] + + # Находим столбец с 'INDICATOR_ID' + indicator_cols = first_data_row[first_data_row == 'INDICATOR_ID'] + if len(indicator_cols) == 0: + raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.') + + indicator_col_name = indicator_cols.index[0] + print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}") + + # Читаем весь лист + df_full = pd.read_excel( + file, + sheet_name=sheet, + header=header_num, + usecols=None, + index_col=None, + engine='openpyxl' + ) + + if indicator_col_name not in df_full.columns: + raise ValueError(f"Столбец {indicator_col_name} отсутствует при полной загрузке.") + + # Перемещаем INDICATOR_ID в начало и делаем индексом + cols = [indicator_col_name] + [col for col in df_full.columns if col != indicator_col_name] + df_full = df_full[cols] + df_full.set_index(indicator_col_name, inplace=True) + + # Обрезаем до "Итого" + 1 + header_list = [str(h).strip() for h in df_full.columns] + try: + itogo_idx = header_list.index("Итого") + num_cols_needed = itogo_idx + 2 + except ValueError: + print('Столбец "Итого" не найден. Оставляем все столбцы.') + num_cols_needed = len(header_list) + + num_cols_needed = min(num_cols_needed, len(header_list)) + df_final = df_full.iloc[:, :num_cols_needed] + + # === Удаление полностью пустых столбцов === + df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True) + df_clean = df_clean.where(pd.notnull(df_clean), pd.NA) + non_empty_mask = df_clean.notna().any() + df_final = df_final.loc[:, non_empty_mask] + + # === Обработка заголовков: Unnamed и "Итого" → "Итого" === + new_columns = [] + last_good_name = None + for col in df_final.columns: + col_str = str(col).strip() + + # Проверяем, является ли колонка пустой/некорректной + is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan' + + if is_empty_or_unnamed: + # Если это пустая колонка, используем последнее хорошее имя + if last_good_name: + new_columns.append(last_good_name) + else: + # Если нет хорошего имени, используем имя по умолчанию + new_columns.append(f"col_{len(new_columns)}") + else: + # Это хорошая колонка + last_good_name = col_str + new_columns.append(col_str) + + # Убеждаемся, что количество столбцов совпадает + if len(new_columns) != len(df_final.columns): + # Если количество не совпадает, обрезаем или дополняем + if len(new_columns) > len(df_final.columns): + new_columns = new_columns[:len(df_final.columns)] + else: + # Дополняем недостающие столбцы + while len(new_columns) < len(df_final.columns): + new_columns.append(f"col_{len(new_columns)}") + + # Применяем новые заголовки + df_final.columns = new_columns + + return df_final + + def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict: + """Парсинг ZIP архива со сводками ПМ""" + import zipfile + pm_dict = { + "facts": {}, + "plans": {} + } + excel_fact_template = 'svodka_fact_pm_ID.xlsm' + excel_plan_template = 'svodka_plan_pm_ID.xlsx' + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + file_list = zip_ref.namelist() + for name, id in OG_IDS.items(): + if id == 'BASH': + continue # пропускаем BASH + + current_fact = replace_id_in_path(excel_fact_template, id) + fact_candidates = [f for f in file_list if current_fact in f] + if len(fact_candidates) == 1: + print(f'Загрузка {current_fact}') + with zip_ref.open(fact_candidates[0]) as excel_file: + pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка') + print(f"✅ Факт загружен: {current_fact}") + else: + print(f"⚠️ Файл не найден (Факт): {current_fact}") + pm_dict['facts'][id] = None + + current_plan = replace_id_in_path(excel_plan_template, id) + plan_candidates = [f for f in file_list if current_plan in f] + if len(plan_candidates) == 1: + print(f'Загрузка {current_plan}') + with zip_ref.open(plan_candidates[0]) as excel_file: + pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка') + print(f"✅ План загружен: {current_plan}") + else: + print(f"⚠️ Файл не найден (План): {current_plan}") + pm_dict['plans'][id] = None + + return pm_dict + + def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None): + ''' Служебная функция получения значения по коду и столбцу ''' + row_index = code + + mask_value = df_svodka.iloc[0] == code + if search_value is None: + mask_name = df_svodka.columns != 'Итого' + else: + mask_name = df_svodka.columns == search_value + + # Убедимся, что маски совпадают по длине + if len(mask_value) != len(mask_name): + raise ValueError( + f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}" + ) + + final_mask = mask_value & mask_name # булевая маска по позициям столбцов + col_positions = final_mask.values # numpy array или Series булевых значений + + if not final_mask.any(): + print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'") + return 0 + else: + if row_index in df_svodka.index: + # Получаем позицию строки + row_loc = df_svodka.index.get_loc(row_index) + + # Извлекаем значения по позициям столбцов + values = df_svodka.iloc[row_loc, col_positions] + + # Преобразуем в числовой формат + numeric_values = pd.to_numeric(values, errors='coerce') + + # Агрегация данных (NaN игнорируются) + if search_value is None: + return numeric_values + else: + return numeric_values.iloc[0] + else: + return None + + def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None): + ''' Служебная функция получения данных по одному ОГ ''' + result = {} + + # Безопасно получаем данные, проверяя их наличие + fact_df = pm_dict.get('facts', {}).get(id) if 'facts' in pm_dict else None + plan_df = pm_dict.get('plans', {}).get(id) if 'plans' in pm_dict else None + + # Определяем, какие столбцы из какого датафрейма брать + for col in columns: + col_result = {} + + if col in ['ПП', 'БП']: + if plan_df is None: + print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}") + col_result = {code: None for code in codes} + else: + for code in codes: + val = self.get_svodka_value(plan_df, code, col, search_value) + col_result[code] = val + + elif col in ['ТБ', 'СЭБ', 'НЭБ']: + if fact_df is None: + print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}") + col_result = {code: None for code in codes} + else: + for code in codes: + val = self.get_svodka_value(fact_df, code, col, search_value) + col_result[code] = val + else: + print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.") + col_result = {code: None for code in codes} + + result[col] = col_result + + return result + + def get_svodka_total(self, pm_dict, codes, columns, search_value=None): + ''' Служебная функция агрегации данные по всем ОГ ''' + total_result = {} + + for name, og_id in OG_IDS.items(): + if og_id == 'BASH': + continue + + # print(f"📊 Обработка: {name} ({og_id})") + try: + data = self.get_svodka_og( + pm_dict, + og_id, + codes, + columns, + search_value + ) + total_result[og_id] = data + except Exception as e: + print(f"❌ Ошибка при обработке {name} ({og_id}): {e}") + total_result[og_id] = None + + return total_result + + # Убираем старый метод get_value, так как он теперь в базовом классе diff --git a/python_parser/adapters/parsers/svodka_pm.py b/python_parser/adapters/parsers/svodka_pm.py index 3901a08..9f8d5cd 100644 --- a/python_parser/adapters/parsers/svodka_pm.py +++ b/python_parser/adapters/parsers/svodka_pm.py @@ -1,9 +1,11 @@ -import pandas as pd + +import pandas as pd +import os +import json +from typing import Dict, Any, List, Optional from core.ports import ParserPort -from core.schema_utils import register_getter_from_schema, validate_params_with_schema -from app.schemas.svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest -from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json +from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json class SvodkaPMParser(ParserPort): @@ -11,91 +13,66 @@ class SvodkaPMParser(ParserPort): name = "Сводки ПМ" + def __init__(self): + super().__init__() + self._register_default_getters() + def _register_default_getters(self): - """Регистрация геттеров по умолчанию""" - # Используем схемы Pydantic как единый источник правды - register_getter_from_schema( - parser_instance=self, - getter_name="single_og", + """Регистрация геттеров для Сводки ПМ""" + self.register_getter( + name="single_og", method=self._get_single_og, - schema_class=SvodkaPMSingleOGRequest, - description="Получение данных по одному ОГ" + required_params=["id", "codes", "columns"], + optional_params=["search"], + description="Получение данных по одному ОГ из сводки ПМ" ) - register_getter_from_schema( - parser_instance=self, - getter_name="total_ogs", + self.register_getter( + name="total_ogs", method=self._get_total_ogs, - schema_class=SvodkaPMTotalOGsRequest, - description="Получение данных по всем ОГ" + required_params=["codes", "columns"], + optional_params=["search"], + description="Получение данных по всем ОГ из сводки ПМ" ) - def _get_single_og(self, params: dict): - """Получение данных по одному ОГ""" - # Валидируем параметры с помощью схемы Pydantic - validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest) - - og_id = validated_params["id"] - codes = validated_params["codes"] - columns = validated_params["columns"] - search = validated_params.get("search") - - # Здесь нужно получить DataFrame из self.df, но пока используем старую логику - # TODO: Переделать под новую архитектуру - return self.get_svodka_og(self.df, og_id, codes, columns, search) - - def _get_total_ogs(self, params: dict): - """Получение данных по всем ОГ""" - # Валидируем параметры с помощью схемы Pydantic - validated_params = validate_params_with_schema(params, SvodkaPMTotalOGsRequest) - - codes = validated_params["codes"] - columns = validated_params["columns"] - search = validated_params.get("search") - - # TODO: Переделать под новую архитектуру - return self.get_svodka_total(self.df, codes, columns, search) - def parse(self, file_path: str, params: dict) -> pd.DataFrame: - """Парсинг файла и возврат DataFrame""" - # Сохраняем DataFrame для использования в геттерах - self.df = self.parse_svodka_pm_files(file_path, params) - return self.df + """Парсинг файла сводки ПМ и возврат DataFrame""" + # Проверяем расширение файла + if not file_path.lower().endswith(('.xlsx', '.xlsm', '.xls')): + raise ValueError(f"Неподдерживаемый формат файла: {file_path}") + + # Определяем тип файла по имени файла + filename = os.path.basename(file_path).lower() + + if "plan" in filename or "план" in filename: + sheet_name = "Сводка Нефтепереработка" + return self._parse_svodka_pm(file_path, sheet_name) + elif "fact" in filename or "факт" in filename: + sheet_name = "Сводка Нефтепереработка" + return self._parse_svodka_pm(file_path, sheet_name) + else: + # По умолчанию пытаемся парсить как есть + sheet_name = "Сводка Нефтепереработка" + return self._parse_svodka_pm(file_path, sheet_name) - def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int: - """Определения индекса заголовка в excel по ключевому слову""" - # Читаем первые max_rows строк без заголовков - df_temp = pd.read_excel( - file, - sheet_name=sheet, - header=None, - nrows=max_rows, - engine='openpyxl' - ) + def _parse_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame: + """Парсинг отчетов одного ОГ для БП, ПП и факта""" + try: + # Автоопределение header_num, если не передан + if header_num is None: + header_num = find_header_row(file_path, sheet_name, search_value="Итого") - # Ищем строку, где хотя бы в одном столбце встречается искомое значение - for idx, row in df_temp.iterrows(): - if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any(): - print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") - return idx # 0-based index — то, что нужно для header= - - raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.") - - def parse_svodka_pm(self, file, sheet, header_num=None): - ''' Собственно парсер отчетов одного ОГ для БП, ПП и факта ''' - # Автоопределение header_num, если не передан - if header_num is None: - header_num = self.find_header_row(file, sheet, search_value="Итого") - - # Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID - df_probe = pd.read_excel( - file, - sheet_name=sheet, - header=header_num, - usecols=None, - nrows=2, - engine='openpyxl' - ) + # Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID + df_probe = pd.read_excel( + file_path, + sheet_name=sheet_name, + header=header_num, + usecols=None, + nrows=2, + engine='openpyxl' # Явно указываем движок + ) + except Exception as e: + raise ValueError(f"Ошибка при чтении файла {file_path}: {str(e)}") if df_probe.shape[0] == 0: raise ValueError("Файл пуст или не содержит данных.") @@ -108,16 +85,15 @@ class SvodkaPMParser(ParserPort): raise ValueError('Не найден столбец со значением "INDICATOR_ID" в первой строке данных.') indicator_col_name = indicator_cols.index[0] - print(f"Найден INDICATOR_ID в столбце: {indicator_col_name}") # Читаем весь лист df_full = pd.read_excel( - file, - sheet_name=sheet, + file_path, + sheet_name=sheet_name, header=header_num, usecols=None, index_col=None, - engine='openpyxl' + engine='openpyxl' # Явно указываем движок ) if indicator_col_name not in df_full.columns: @@ -134,19 +110,18 @@ class SvodkaPMParser(ParserPort): itogo_idx = header_list.index("Итого") num_cols_needed = itogo_idx + 2 except ValueError: - print('Столбец "Итого" не найден. Оставляем все столбцы.') num_cols_needed = len(header_list) num_cols_needed = min(num_cols_needed, len(header_list)) df_final = df_full.iloc[:, :num_cols_needed] - # === Удаление полностью пустых столбцов === + # Удаление полностью пустых столбцов df_clean = df_final.replace(r'^\s*$', pd.NA, regex=True) df_clean = df_clean.where(pd.notnull(df_clean), pd.NA) non_empty_mask = df_clean.notna().any() df_final = df_final.loc[:, non_empty_mask] - # === Обработка заголовков: Unnamed и "Итого" → "Итого" === + # Обработка заголовков: Unnamed и "Итого" → "Итого" new_columns = [] last_good_name = None for col in df_final.columns: @@ -155,104 +130,69 @@ class SvodkaPMParser(ParserPort): # Проверяем, является ли колонка пустой/некорректной is_empty_or_unnamed = col_str.startswith('Unnamed') or col_str == '' or col_str.lower() == 'nan' - if is_empty_or_unnamed: - # Если это пустая колонка, используем последнее хорошее имя - if last_good_name: - new_columns.append(last_good_name) - else: - # Если нет хорошего имени, используем имя по умолчанию - new_columns.append(f"col_{len(new_columns)}") + # Проверяем, начинается ли на "Итого" + if col_str.startswith('Итого'): + current_name = 'Итого' + last_good_name = current_name + new_columns.append(current_name) + elif is_empty_or_unnamed: + # Используем последнее хорошее имя + new_columns.append(last_good_name) else: - # Это хорошая колонка + # Имя, полученное из excel last_good_name = col_str new_columns.append(col_str) - # Убеждаемся, что количество столбцов совпадает - if len(new_columns) != len(df_final.columns): - # Если количество не совпадает, обрезаем или дополняем - if len(new_columns) > len(df_final.columns): - new_columns = new_columns[:len(df_final.columns)] - else: - # Дополняем недостающие столбцы - while len(new_columns) < len(df_final.columns): - new_columns.append(f"col_{len(new_columns)}") - - # Применяем новые заголовки df_final.columns = new_columns return df_final - def parse_svodka_pm_files(self, zip_path: str, params: dict) -> dict: - """Парсинг ZIP архива со сводками ПМ""" - import zipfile - pm_dict = { - "facts": {}, - "plans": {} - } - excel_fact_template = 'svodka_fact_pm_ID.xlsm' - excel_plan_template = 'svodka_plan_pm_ID.xlsx' - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - file_list = zip_ref.namelist() - for name, id in OG_IDS.items(): - if id == 'BASH': - continue # пропускаем BASH - - current_fact = replace_id_in_path(excel_fact_template, id) - fact_candidates = [f for f in file_list if current_fact in f] - if len(fact_candidates) == 1: - print(f'Загрузка {current_fact}') - with zip_ref.open(fact_candidates[0]) as excel_file: - pm_dict['facts'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка') - print(f"✅ Факт загружен: {current_fact}") - else: - print(f"⚠️ Файл не найден (Факт): {current_fact}") - pm_dict['facts'][id] = None - - current_plan = replace_id_in_path(excel_plan_template, id) - plan_candidates = [f for f in file_list if current_plan in f] - if len(plan_candidates) == 1: - print(f'Загрузка {current_plan}') - with zip_ref.open(plan_candidates[0]) as excel_file: - pm_dict['plans'][id] = self.parse_svodka_pm(excel_file, 'Сводка Нефтепереработка') - print(f"✅ План загружен: {current_plan}") - else: - print(f"⚠️ Файл не найден (План): {current_plan}") - pm_dict['plans'][id] = None - - return pm_dict - - def get_svodka_value(self, df_svodka, code, search_value, search_value_filter=None): - ''' Служебная функция получения значения по коду и столбцу ''' - row_index = code + def _get_svodka_value(self, df_svodka: pd.DataFrame, id: str, code: int, search_value: Optional[str] = None): + """Служебная функция для простой выборке по сводке""" + row_index = id + + print(f"🔍 DEBUG: Ищем код '{code}' в строке '{row_index}'") + print(f"🔍 DEBUG: Первая строка данных: {df_svodka.iloc[0].tolist()}") + print(f"🔍 DEBUG: Доступные индексы: {list(df_svodka.index)}") + # Ищем столбцы, где в первой строке есть значение code mask_value = df_svodka.iloc[0] == code if search_value is None: mask_name = df_svodka.columns != 'Итого' else: mask_name = df_svodka.columns == search_value + print(f"🔍 DEBUG: mask_value = {mask_value.tolist()}") + print(f"🔍 DEBUG: mask_name = {mask_name.tolist()}") + # Убедимся, что маски совпадают по длине if len(mask_value) != len(mask_name): raise ValueError( - f"Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}" + f"❌ Несоответствие длин масок: mask_value={len(mask_value)}, mask_name={len(mask_name)}" ) - final_mask = mask_value & mask_name # булевая маска по позициям столбцов - col_positions = final_mask.values # numpy array или Series булевых значений + final_mask = mask_value & mask_name + col_positions = final_mask.values + + print(f"🔍 DEBUG: final_mask = {final_mask.tolist()}") + print(f"🔍 DEBUG: col_positions = {col_positions}") if not final_mask.any(): - print(f"Нет столбцов с '{code}' в первой строке и именем, не начинающимся с '{search_value}'") + print(f"⚠️ Код '{code}' не найден в первой строке") return 0 else: if row_index in df_svodka.index: # Получаем позицию строки row_loc = df_svodka.index.get_loc(row_index) + print(f"🔍 DEBUG: Найдена строка '{row_index}' в позиции {row_loc}") # Извлекаем значения по позициям столбцов values = df_svodka.iloc[row_loc, col_positions] + print(f"🔍 DEBUG: Извлеченные значения: {values.tolist()}") # Преобразуем в числовой формат numeric_values = pd.to_numeric(values, errors='coerce') + print(f"🔍 DEBUG: Числовые значения: {numeric_values.tolist()}") # Агрегация данных (NaN игнорируются) if search_value is None: @@ -260,15 +200,43 @@ class SvodkaPMParser(ParserPort): else: return numeric_values.iloc[0] else: + print(f"⚠️ Строка '{row_index}' не найдена в индексе") return None - def get_svodka_og(self, pm_dict, id, codes, columns, search_value=None): - ''' Служебная функция получения данных по одному ОГ ''' + def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None): + """Служебная функция получения данных по одному ОГ""" result = {} - # Безопасно получаем данные, проверяя их наличие - fact_df = pm_dict.get('facts', {}).get(id) if 'facts' in pm_dict else None - plan_df = pm_dict.get('plans', {}).get(id) if 'plans' in pm_dict else None + # Пути к файлам + exel_fact = 'data/pm_fact/svodka_fact_pm_ID' + exel_plan = 'data/pm_plan/svodka_plan_pm_ID' + + current_fact = replace_id_in_path(exel_fact, og_id) + current_plan = replace_id_in_path(exel_plan, og_id) + + # Загружаем данные + fact_df = None + plan_df = None + + if os.path.exists(current_fact): + try: + fact_df = self._parse_svodka_pm(current_fact, 'Сводка Нефтепереработка') + print(f"✅ Файл факта загружен: {current_fact}") + print(f"📊 Столбцы факта: {list(fact_df.columns)}") + print(f"📊 Индексы факта: {list(fact_df.index)}") + except Exception as e: + print(f"❌ Ошибка при загрузке файла факта {current_fact}: {e}") + fact_df = None + + if os.path.exists(current_plan): + try: + plan_df = self._parse_svodka_pm(current_plan, 'Сводка Нефтепереработка') + print(f"✅ Файл плана загружен: {current_plan}") + print(f"📊 Столбцы плана: {list(plan_df.columns)}") + print(f"📊 Индексы плана: {list(plan_df.index)}") + except Exception as e: + print(f"❌ Ошибка при загрузке файла плана {current_plan}: {e}") + plan_df = None # Определяем, какие столбцы из какого датафрейма брать for col in columns: @@ -276,51 +244,88 @@ class SvodkaPMParser(ParserPort): if col in ['ПП', 'БП']: if plan_df is None: - print(f"❌ Невозможно обработать '{col}': нет данных плана для {id}") - col_result = {code: None for code in codes} + print(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}") else: for code in codes: - val = self.get_svodka_value(plan_df, code, col, search_value) - col_result[code] = val + val = self._get_svodka_value(plan_df, og_id, code, search_value) + col_result[str(code)] = val elif col in ['ТБ', 'СЭБ', 'НЭБ']: if fact_df is None: - print(f"❌ Невозможно обработать '{col}': нет данных факта для {id}") - col_result = {code: None for code in codes} + print(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}") else: for code in codes: - val = self.get_svodka_value(fact_df, code, col, search_value) - col_result[code] = val + val = self._get_svodka_value(fact_df, og_id, code, search_value) + col_result[str(code)] = val else: print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.") - col_result = {code: None for code in codes} + col_result = {str(code): None for code in codes} result[col] = col_result return result - def get_svodka_total(self, pm_dict, codes, columns, search_value=None): - ''' Служебная функция агрегации данные по всем ОГ ''' + def _get_single_og(self, params: Dict[str, Any]) -> str: + """API функция для получения данных по одному ОГ""" + # Если на входе строка — парсим как JSON + if isinstance(params, str): + try: + params = json.loads(params) + except json.JSONDecodeError as e: + raise ValueError(f"Некорректный JSON: {e}") + + # Проверяем структуру + if not isinstance(params, dict): + raise TypeError("Конфиг должен быть словарём или JSON-строкой") + + og_id = params.get("id") + codes = params.get("codes") + columns = params.get("columns") + search = params.get("search") + + if not isinstance(codes, list): + raise ValueError("Поле 'codes' должно быть списком") + if not isinstance(columns, list): + raise ValueError("Поле 'columns' должно быть списком") + + data = self._get_svodka_og(og_id, codes, columns, search) + json_result = data_to_json(data) + return json_result + + def _get_total_ogs(self, params: Dict[str, Any]) -> str: + """API функция для получения данных по всем ОГ""" + # Если на входе строка — парсим как JSON + if isinstance(params, str): + try: + params = json.loads(params) + except json.JSONDecodeError as e: + raise ValueError(f"❌Некорректный JSON: {e}") + + # Проверяем структуру + if not isinstance(params, dict): + raise TypeError("Конфиг должен быть словарём или JSON-строкой") + + codes = params.get("codes") + columns = params.get("columns") + search = params.get("search") + + if not isinstance(codes, list): + raise ValueError("Поле 'codes' должно быть списком") + if not isinstance(columns, list): + raise ValueError("Поле 'columns' должно быть списком") + total_result = {} - for name, og_id in OG_IDS.items(): + for og_id in SINGLE_OGS: if og_id == 'BASH': continue - # print(f"📊 Обработка: {name} ({og_id})") try: - data = self.get_svodka_og( - pm_dict, - og_id, - codes, - columns, - search_value - ) + data = self._get_svodka_og(og_id, codes, columns, search) total_result[og_id] = data except Exception as e: - print(f"❌ Ошибка при обработке {name} ({og_id}): {e}") + print(f"❌ Ошибка при обработке {og_id}: {e}") total_result[og_id] = None - return total_result - - # Убираем старый метод get_value, так как он теперь в базовом классе + json_result = data_to_json(total_result) + return json_result \ No newline at end of file diff --git a/python_parser/adapters/pconfig.py b/python_parser/adapters/pconfig.py index 12be990..d2a0b33 100644 --- a/python_parser/adapters/pconfig.py +++ b/python_parser/adapters/pconfig.py @@ -3,6 +3,7 @@ from functools import lru_cache import json import numpy as np import pandas as pd +import os OG_IDS = { "Комсомольский НПЗ": "KNPZ", @@ -22,8 +23,37 @@ OG_IDS = { "Красноленинский НПЗ": "KLNPZ", "Пурнефтепереработка": "PurNP", "ЯНОС": "YANOS", + "Уфанефтехим": "UNH", + "РНПК": "RNPK", + "КмсНПЗ": "KNPZ", + "АНХК": "ANHK", + "НК НПЗ": "NovKuybNPZ", + "КНПЗ": "KuybNPZ", + "СНПЗ": "CyzNPZ", + "Нижневаторское НПО": "NVNPO", + "ПурНП": "PurNP", } +SINGLE_OGS = [ + "KNPZ", + "ANHK", + "AchNPZ", + "BASH", + "UNPZ", + "UNH", + "NOV", + "NovKuybNPZ", + "KuybNPZ", + "CyzNPZ", + "TuapsNPZ", + "SNPZ", + "RNPK", + "NVNPO", + "KLNPZ", + "PurNP", + "YANOS", +] + SNPZ_IDS = { "Висбрекинг": "SNPZ.VISB", "Изомеризация": "SNPZ.IZOM", @@ -40,7 +70,18 @@ SNPZ_IDS = { def replace_id_in_path(file_path, new_id): - return file_path.replace('ID', str(new_id)) + # Заменяем 'ID' на новое значение + modified_path = file_path.replace('ID', str(new_id)) + '.xlsx' + + # Проверяем, существует ли файл + if not os.path.exists(modified_path): + # Меняем расширение на .xlsm + directory, filename = os.path.split(modified_path) + name, ext = os.path.splitext(filename) + new_filename = name + '.xlsm' + modified_path = os.path.join(directory, new_filename) + + return modified_path def get_table_name(exel): @@ -109,6 +150,25 @@ def get_id_by_name(name, dictionary): return best_match +def find_header_row(file, sheet, search_value="Итого", max_rows=50): + ''' Определения индекса заголовка в exel по ключевому слову ''' + # Читаем первые max_rows строк без заголовков + df_temp = pd.read_excel( + file, + sheet_name=sheet, + header=None, + nrows=max_rows + ) + + # Ищем строку, где хотя бы в одном столбце встречается искомое значение + for idx, row in df_temp.iterrows(): + if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any(): + print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})") + return idx # 0-based index — то, что нужно для header= + + raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.") + + def data_to_json(data, indent=2, ensure_ascii=False): """ Полностью безопасная сериализация данных в JSON. @@ -153,11 +213,18 @@ def data_to_json(data, indent=2, ensure_ascii=False): # --- рекурсия по dict и list --- elif isinstance(obj, dict): - return { - key: convert_obj(value) - for key, value in obj.items() - if not is_nan_like(key) # фильтруем NaN в ключах (недопустимы в JSON) - } + # Обрабатываем только значения, ключи оставляем как строки + converted = {} + for k, v in obj.items(): + if is_nan_like(k): + continue # ключи не могут быть null в JSON + # Превращаем ключ в строку, но не пытаемся интерпретировать как число + key_str = str(k) + converted[key_str] = convert_obj(v) # только значение проходит через convert_obj + # Если все значения 0.0 — считаем, что данных нет, т.к. возможно ожидается массив. + if converted and all(v == 0.0 for v in converted.values()): + return None + return converted elif isinstance(obj, list): return [convert_obj(item) for item in obj] @@ -175,7 +242,6 @@ def data_to_json(data, indent=2, ensure_ascii=False): try: cleaned_data = convert_obj(data) - cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii) - return cleaned_data + return json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii) except Exception as e: raise ValueError(f"Не удалось сериализовать данные в JSON: {e}") diff --git a/python_parser/app/schemas/svodka_pm.py b/python_parser/app/schemas/svodka_pm.py index 2e9d5ba..23e4ed6 100644 --- a/python_parser/app/schemas/svodka_pm.py +++ b/python_parser/app/schemas/svodka_pm.py @@ -25,7 +25,7 @@ class OGID(str, Enum): class SvodkaPMSingleOGRequest(BaseModel): - id: OGID = Field( + id: str = Field( ..., description="Идентификатор МА для запрашиваемого ОГ", example="SNPZ"