From 9c152ebe94c9aec722c2e8b9143761d7ee1bdc50 Mon Sep 17 00:00:00 2001 From: Maksim Date: Tue, 2 Sep 2025 10:11:15 +0300 Subject: [PATCH 1/8] upd gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8722ece..94cf779 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ 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__/ +nin_python_parser *.py[cod] *$py.class From 8ed61a3c0b01d98f956f4b5684bd6999bb50495c Mon Sep 17 00:00:00 2001 From: Maksim Date: Tue, 2 Sep 2025 10:59:42 +0300 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=D0=B2=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D1=85=20=D0=BD=D0=B5=20=D1=82=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB=D1=81=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D1=83=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20=D0=B3=D0=B5=D1=82=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_parser/core/services.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python_parser/core/services.py b/python_parser/core/services.py index 16e7da0..f518f39 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -106,14 +106,14 @@ class ReportService: # Получаем параметры запроса get_params = request.get_params or {} - # Определяем имя геттера (по умолчанию используем первый доступный) - getter_name = get_params.pop("getter", None) + # Определяем имя геттера из параметра mode + getter_name = get_params.pop("mode", None) if not getter_name: - # Если геттер не указан, берем первый доступный + # Если режим не указан, берем первый доступный available_getters = list(parser.getters.keys()) if available_getters: getter_name = available_getters[0] - print(f"⚠️ Геттер не указан, используем первый доступный: {getter_name}") + print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}") else: return DataResult( success=False, From e3077252a8e1add97b09781500d48d6a5a15d0c7 Mon Sep 17 00:00:00 2001 From: Maksim Date: Tue, 2 Sep 2025 11:28:07 +0300 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B4=D0=B5=D0=B2=20=D0=B4=D0=BE=D0=BA=D0=B5=D1=80=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BE=D1=83=D0=B7,=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=A1=D0=90=20(=D0=BD=D0=BE=20=D1=82=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docker-compose.dev.yml | 13 ++- .../__pycache__/pconfig.cpython-313.pyc | Bin 8206 -> 8206 bytes .../monitoring_fuel.cpython-313.pyc | Bin 11198 -> 11198 bytes .../__pycache__/svodka_ca.cpython-313.pyc | Bin 16493 -> 17230 bytes .../__pycache__/svodka_pm.cpython-313.pyc | Bin 13658 -> 13658 bytes python_parser/adapters/parsers/svodka_ca.py | 92 +++++++++++++++--- .../__pycache__/__init__.cpython-313.pyc | Bin 615 -> 615 bytes .../monitoring_fuel.cpython-313.pyc | Bin 1690 -> 1690 bytes .../core/__pycache__/ports.cpython-313.pyc | Bin 5953 -> 5953 bytes python_parser/core/services.py | 43 +++++--- 11 files changed, 124 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 94cf779..0e31b10 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ python_parser/app/schemas/test_schemas/test_adapters/__pycache__/ python_parser/app/schemas/test_schemas/test_app/__pycache__/ nin_python_parser +*.pyc *.py[cod] *$py.class diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4327337..0bb12fa 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,7 +14,7 @@ services: restart: unless-stopped fastapi: - build: ./python_parser + image: python:3.11-slim container_name: svodka_fastapi_dev ports: - "8000:8000" @@ -24,9 +24,20 @@ services: - MINIO_SECRET_KEY=minioadmin - MINIO_SECURE=false - MINIO_BUCKET=svodka-data + volumes: + # Монтируем исходный код для автоматической перезагрузки + - ./python_parser:/app + # Монтируем requirements.txt для установки зависимостей + - ./python_parser/requirements.txt:/app/requirements.txt + working_dir: /app depends_on: - minio restart: unless-stopped + command: > + bash -c " + pip install --no-cache-dir -r requirements.txt && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + " streamlit: image: python:3.11-slim diff --git a/python_parser/adapters/__pycache__/pconfig.cpython-313.pyc b/python_parser/adapters/__pycache__/pconfig.cpython-313.pyc index 34fa65daaf59b3d45658af0d60abbe9c1e495e8d..04c2debfca9f66d3b422f35a36a05254739069a5 100644 GIT binary patch delta 22 ccmeBk=yTxv%*)Hg00b^Gwq?waSjeXU07#_uU&yBb08SW)t}nhDNe1{W~SO{JAL<$ola9n$=uq$vwHc#c`WFpqCTzYp`>)Pw)&veGDK`9>W7ZZ1M1mkkrVen>hOheCpQ}^kJ zq0PS_AWa(Uxq4dX5EnI4odF{nPPC_T^UobVbNEhfdCFBWnw88eNqOgAlP}9RyOZ9wac@V`+i}<1nJn;(cBX74 zcWhOurnU?1NxOT@bK71&#UbA+JZVJM!U;3VbADqgq-}h;1rF4_`3^?tWfVZP_NfAT zim#@lnHi=R5b_~eAD|aYYR$}3%cniHjJ^KF@}g3C4VWZpI!~1#}rT(yVaA;h1@#&0l(;ofs`5J zv2mN!+?YrdDX8p=hW3aoElDxkK-k@o%8Rj>B!^UeM3PlqETm?%vpvfR1XvduaAy#f zgou;_9IzpsEO3Z+EGg1#m4dts;Afal2Z1%}GL|1379W_Zu%~<%h zqxFR0lzGC0vhz;2oNoEaUU*_XJg;U7_Jj_NXD{t-BVv9JwlMY4W43 zWb{zVUNUa4Oxi0`uF6!o@1Cb-Z1Dr!FdWFBF^E2+*v%SN1l?#hXp9lanEWy z*LSw>g8R0$dQ_JxEPBb9vN=XG9`GpNdan9xHT5P!#w-(Vpt0Pwcnkd~cQr>KmULF$ zO}vrb%473tn9sG$<%kzXZI-PSkGw-5e58m$%^e6w;u4WUK`kx{sidD;{CEkSvJiH_ zz^pP{MNcwzCa%ufXX=2RD!)q-LlRMWNgfQ#5?R3(4{_W36pyRhja80FgVF)g4u^CB z*uNirZgIYl?{X8J`@1%5B_ThU&<4es>IKdia+tROi?uLvIeePUer8RHf7!_o`*DBWIG%pCMgSpZMcZ_Bn= z)6W3)K7eL&khZ#f_;ng{&BMp(c~>d^HSxZy7u!To>77w_>(ZBANoy)v@!wRg@blW; z8xof)))|YTV3KCQVg?0Di6D%m@77B5yz{r^uzx#g1C6B&Yc;QTYtGifLfD@jk(Gc> zz$yUb3SpHUku?BTauHbv*Z|N%*3Cw48%=uM5y&JG1ndB44}hE^I|2OwEiMv_1^|Nq zC>&%LAPm^e;4XMFL=7N!~w&AeSrOR*w?Ak;lwAt zn?@7(%biC=dbF{z;8iwJjdDmNL_A0!!&u&Z)p!g?X;;&Dy;fNsdk!m)Og$deJ~8PK z!PDL5^rf`|@;Mz?;K?SXU<*nJ`I5f2pwp^apoD1~gUrBgB`T`|Eo>6-P{O)U*5OL} zT=RP8CfEVKQcbI*gUbks64ek^!ZHccKbz~YpSl(|yHpc=mBFBD+o;vTrBAPi%1FF3 zg0u9<;?pKwxu;tJa&$5{R=@)>`e7vz>^z8 delta 1632 zcmZ9MYitx%6oBXM?#%A?3HzWeecIApW{J`^EzfPcEu}o%mRGm7v^oymX}hpHyPVk; zY65MdkR}=>9!bOoAs$=}7r1p;q>nl~mN@4Q~}oz3Bz(6YIEHejqCUv<2K@5yd<8Q*qv zOD_q6F2!)K({GCj7%B2q48_~*v|SK}27{b^4T4Z8)Cn=1?eIrqVjHuB!Wu!gLopwl zVk#Zi3{%Rghmx8hiy-n`K7!$w+{op?bE`rXR{(_z_h z@-6sG+{9kT_0qaFf7!hIS6{_^&%K)ZPgCbomwK-5_;Sa!fx9)`^Sj>N^TaOrSKlx7 zE|xalDQ&!%xLexvv#SYjNR3#z*6rveV+(RUx}Eh7?lHp!*VdP9W@$zRLsN|Dbo_{_ zjHyt~6?r^9-T@C-fC>(SE&{>bSh<#FwH$N8k|cP`7CnJGp1{SKyPnOtNI{{Ct;49N zpS9zHC+ajs>n8A#=N8+ApL?S&Q&KY-O^@R__$W#YKncd&5z90=j8Z#C<}Pkc?R z6@T&px7XoskOKTfvj!8M+T3aXv}-m*b7@jj;~JQfrcWev4Z?i!E%A*}BeZ5fW~K<* zgmxS@^HtbMuo1R!n2xb@QjxtdOp^lOW)$uCn3go9@kBDItFV_;_mR{IS|+KEX{Jp( zYTDIg(i9WA80Ur$;9O1Bu05pLPnso4!cg)qDUdz}qa=BaBqFH#gl3Ayq?*wn#+Ufn z|KBhL1J(GT<|gaI^R)qXKKFg?QF|GA{>O9Pcs^#yjykYosDYo4 z-~_(Y5Mtltt~NYiER1IxFV@b`wt_)5Ii+m}NQ3Dfn91aaGJ{qe2*lVgcsVfaUyc~| zldTU43YG`gvkSO07-T0g6Rc;qa~K?9WkV#T>#>w&TRxZ$6y*r}4SVG#w-wVmu7UU4 zSLM#i8=Tcmq~AxdK*4$YMFgP!i|JY9Q{`62CR%(xrKHTbr3oU5keQC zo3M@0L)cE(L9p_^n~&r&=%WS76j(WXA>adoutItX;kOPl3%d-m2=q9zeXTwl7odNE@0K@p%mQ(BihPE!Y^E%CHDFdurS%vQI;a^WLs42~K z^1p}vpnMXoiY&>8Q%82b!VTe&*DSD}ucGrE*1rH>V=}y3gc`1cKZfhszTBg*ZsYHD zq;sznrJbXAz0=1A@rTY&jLo5qpI2X@9w{jW%Lzm>;Te2?j{Jn_NmCpa{=&RkCKh($yR)5}PkMo(jBMs&a^B z9~(;o?=JON-L?HUN3V}QrIn>3bau}bTh}A%Q>n}hT*HsL4|6lmyUW=_EZ^3+a!O>C OXAb@&P!Hw&sQ&=9te<-T 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 1fecb749a7dc66e495129a49bc9695348ec8a99c..66f3854c898febc98cfa2b00d6522ec7ca7c5baa 100644 GIT binary patch delta 20 acmcbWbt{YeGcPX}0}!~+-nNn3-xL5$^#+3g delta 20 acmcbWbt{YeGcPX}0}#BrwRI!6zbODx-v?v> diff --git a/python_parser/adapters/parsers/svodka_ca.py b/python_parser/adapters/parsers/svodka_ca.py index 4c3be9b..8ef0c53 100644 --- a/python_parser/adapters/parsers/svodka_ca.py +++ b/python_parser/adapters/parsers/svodka_ca.py @@ -17,7 +17,7 @@ class SvodkaCAParser(ParserPort): # Используем схемы Pydantic как единый источник правды register_getter_from_schema( parser_instance=self, - getter_name="get_data", + getter_name="get_ca_data", method=self._get_data_wrapper, schema_class=SvodkaCARequest, description="Получение данных по режимам и таблицам" @@ -31,16 +31,74 @@ class SvodkaCAParser(ParserPort): modes = validated_params["modes"] tables = validated_params["tables"] - # TODO: Переделать под новую архитектуру - data_dict = {} + # Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки) + if hasattr(self, 'data_dict') and self.data_dict is not None: + # Данные из парсинга + data_source = self.data_dict + elif hasattr(self, 'df') and self.df is not None and not self.df.empty: + # Данные из загрузки - преобразуем DataFrame обратно в словарь + data_source = self._df_to_data_dict() + else: + return {} + + # Фильтруем данные по запрошенным режимам и таблицам + result_data = {} for mode in modes: - data_dict[mode] = self.get_data(self.df, mode, tables) - return self.data_dict_to_json(data_dict) + if mode in data_source: + result_data[mode] = {} + for table_name, table_data in data_source[mode].items(): + if table_name in tables: + result_data[mode][table_name] = table_data + + return result_data + + def _df_to_data_dict(self): + """Преобразование DataFrame обратно в словарь данных""" + if not hasattr(self, 'df') or self.df is None or self.df.empty: + return {} + + data_dict = {} + + # Группируем данные по режимам и таблицам + for _, row in self.df.iterrows(): + mode = row.get('mode') + table = row.get('table') + data = row.get('data') + + if mode and table and data is not None: + if mode not in data_dict: + data_dict[mode] = {} + data_dict[mode][table] = data + + return data_dict def parse(self, file_path: str, params: dict) -> pd.DataFrame: """Парсинг файла и возврат DataFrame""" - # Сохраняем DataFrame для использования в геттерах - self.df = self.parse_svodka_ca(file_path, params) + # Парсим данные и сохраняем словарь для использования в геттерах + self.data_dict = self.parse_svodka_ca(file_path, params) + + # Преобразуем словарь в DataFrame для совместимости с services.py + # Создаем простой DataFrame с информацией о загруженных данных + if self.data_dict: + # Создаем DataFrame с информацией о режимах и таблицах + data_rows = [] + for mode, tables in self.data_dict.items(): + for table_name, table_data in tables.items(): + if table_data: + data_rows.append({ + 'mode': mode, + 'table': table_name, + 'rows_count': len(table_data), + 'data': table_data + }) + + if data_rows: + df = pd.DataFrame(data_rows) + self.df = df + return df + + # Если данных нет, возвращаем пустой DataFrame + self.df = pd.DataFrame() return self.df def parse_svodka_ca(self, file_path: str, params: dict) -> dict: @@ -147,12 +205,22 @@ class SvodkaCAParser(ParserPort): # Сортируем по индексу (id) и по столбцу 'table' combined_df = combined_df.sort_values(by=['id', 'table'], axis=0) - # Устанавливаем id как индекс - # combined_df.set_index('id', inplace=True) - - return combined_df + # Преобразуем DataFrame в словарь по режимам и таблицам + # Для сводки СА у нас есть только один режим - 'fact' (по умолчанию) + # Но нужно определить режим из данных или параметров + mode = params.get('mode', 'fact') # По умолчанию 'fact' + + data_dict = {mode: {}} + + # Группируем данные по таблицам + for table_name, group_df in combined_df.groupby('table'): + # Удаляем колонку 'table' из результата + table_data = group_df.drop('table', axis=1) + data_dict[mode][table_name] = table_data.to_dict('records') + + return data_dict else: - return None + return {} def extract_all_tables(self, file_path, sheet_name=0): """Извлечение всех таблиц из Excel файла""" diff --git a/python_parser/app/schemas/__pycache__/__init__.cpython-313.pyc b/python_parser/app/schemas/__pycache__/__init__.cpython-313.pyc index 1aec30edcf94720f2f76ab9e5a817bd6e53fb8a6..ba4174ea6daf5dfd258ac27e73ffa56d87f0bae2 100644 GIT binary patch delta 22 ccmaFP@|=bDGcPX}0}!~(*p?x_kvED708DBICIA2c delta 22 ccmaFP@|=bDGcPX}0}zy4U6aANkvED708e%XdjJ3c diff --git a/python_parser/app/schemas/__pycache__/monitoring_fuel.cpython-313.pyc b/python_parser/app/schemas/__pycache__/monitoring_fuel.cpython-313.pyc index cd2c9092a66e970c99f878544856253897801de3..665d31487c15a77b0c3a0b05a984377a2e7f4eeb 100644 GIT binary patch delta 22 ccmbQmJBye1GcPX}0}!~(*p?Bvk++u(07AS54*&oF delta 22 ccmbQmJBye1GcPX}0}$M~v?fD;BX2Jo07&!(wEzGB diff --git a/python_parser/core/__pycache__/ports.cpython-313.pyc b/python_parser/core/__pycache__/ports.cpython-313.pyc index 6bf9520b22aff7db6c5e669ad6f6df92ecf92e7a..07bb3675611242055bac838c54bdd635951bf74e 100644 GIT binary patch delta 20 acmX@8cTkV}GcPX}0}!~+-nNn3OdJ3}e+8ld delta 20 acmX@8cTkV}GcPX}0}vQo+`5t5OdJ3~9R 0 else 'fact' else: - return DataResult( - success=False, - message="Парсер не имеет доступных геттеров" - ) + default_mode = 'fact' + + # Устанавливаем режим в параметры, если он не указан + if 'mode' not in get_params: + get_params['mode'] = default_mode + + # Определяем имя геттера + if request.report_type == 'svodka_ca': + # Для svodka_ca используем геттер get_ca_data + getter_name = 'get_ca_data' + else: + # Для других парсеров определяем из параметра mode + getter_name = get_params.pop("mode", None) + if not getter_name: + # Если режим не указан, берем первый доступный + available_getters = list(parser.getters.keys()) + if available_getters: + getter_name = available_getters[0] + print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}") + else: + return DataResult( + success=False, + message="Парсер не имеет доступных геттеров" + ) # Получаем значение через указанный геттер try: From eb6d23bba8c4331ba19f0b0ed2953fe99467ea94 Mon Sep 17 00:00:00 2001 From: Maksim Date: Tue, 2 Sep 2025 11:40:27 +0300 Subject: [PATCH 4/8] =?UTF-8?q?=D0=AD=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20=D1=82=D0=BE=D0=BF=D0=BB=D0=B8=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/parsers/monitoring_fuel.py | 103 ++++++++++++++++-- python_parser/app/main.py | 4 +- python_parser/core/services.py | 14 +++ 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/python_parser/adapters/parsers/monitoring_fuel.py b/python_parser/adapters/parsers/monitoring_fuel.py index 7f41328..528e883 100644 --- a/python_parser/adapters/parsers/monitoring_fuel.py +++ b/python_parser/adapters/parsers/monitoring_fuel.py @@ -39,9 +39,31 @@ class MonitoringFuelParser(ParserPort): columns = validated_params["columns"] - # TODO: Переделать под новую архитектуру - df_means, _ = self.aggregate_by_columns(self.df, columns) - return df_means.to_dict(orient='index') + # Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки) + if hasattr(self, 'data_dict') and self.data_dict is not None: + # Данные из парсинга + data_source = self.data_dict + elif hasattr(self, 'df') and self.df is not None and not self.df.empty: + # Данные из загрузки - преобразуем DataFrame обратно в словарь + data_source = self._df_to_data_dict() + else: + return {} + + # Агрегируем данные по колонкам + df_means, _ = self.aggregate_by_columns(data_source, columns) + + # Преобразуем в JSON-совместимый формат + result = {} + for idx, row in df_means.iterrows(): + result[str(idx)] = {} + for col in columns: + value = row.get(col) + if pd.isna(value) or value == float('inf') or value == float('-inf'): + result[str(idx)][col] = None + else: + result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value + + return result def _get_month_by_code(self, params: dict): """Получение данных за конкретный месяц""" @@ -50,14 +72,73 @@ class MonitoringFuelParser(ParserPort): month = validated_params["month"] - # TODO: Переделать под новую архитектуру - df_month = self.get_month(self.df, month) - return df_month.to_dict(orient='index') + # Проверяем, есть ли данные в data_dict (из парсинга) или в df (из загрузки) + if hasattr(self, 'data_dict') and self.data_dict is not None: + # Данные из парсинга + data_source = self.data_dict + elif hasattr(self, 'df') and self.df is not None and not self.df.empty: + # Данные из загрузки - преобразуем DataFrame обратно в словарь + data_source = self._df_to_data_dict() + else: + return {} + + # Получаем данные за конкретный месяц + df_month = self.get_month(data_source, month) + + # Преобразуем в JSON-совместимый формат + result = {} + for idx, row in df_month.iterrows(): + result[str(idx)] = {} + for col in df_month.columns: + value = row[col] + if pd.isna(value) or value == float('inf') or value == float('-inf'): + result[str(idx)][col] = None + else: + result[str(idx)][col] = float(value) if isinstance(value, (int, float)) else value + + return result + + def _df_to_data_dict(self): + """Преобразование DataFrame обратно в словарь данных""" + if not hasattr(self, 'df') or self.df is None or self.df.empty: + return {} + + data_dict = {} + + # Группируем данные по месяцам + for _, row in self.df.iterrows(): + month = row.get('month') + data = row.get('data') + + if month and data is not None: + data_dict[month] = data + + return data_dict def parse(self, file_path: str, params: dict) -> pd.DataFrame: """Парсинг файла и возврат DataFrame""" - # Сохраняем DataFrame для использования в геттерах - self.df = self.parse_monitoring_fuel_files(file_path, params) + # Парсим данные и сохраняем словарь для использования в геттерах + self.data_dict = self.parse_monitoring_fuel_files(file_path, params) + + # Преобразуем словарь в DataFrame для совместимости с services.py + if self.data_dict: + # Создаем DataFrame с информацией о месяцах и данных + data_rows = [] + for month, df_data in self.data_dict.items(): + if df_data is not None and not df_data.empty: + data_rows.append({ + 'month': month, + 'rows_count': len(df_data), + 'data': df_data + }) + + if data_rows: + df = pd.DataFrame(data_rows) + self.df = df + return df + + # Если данных нет, возвращаем пустой DataFrame + self.df = pd.DataFrame() return self.df def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]: @@ -148,7 +229,11 @@ class MonitoringFuelParser(ParserPort): 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 + # Временно используем name как id + df_full['id'] = df_full['name'] + else: + # Если нет колонки name, создаем id из индекса + df_full['id'] = df_full.index # Устанавливаем id как индекс df_full.set_index('id', inplace=True) diff --git a/python_parser/app/main.py b/python_parser/app/main.py index 578d06a..302eaed 100644 --- a/python_parser/app/main.py +++ b/python_parser/app/main.py @@ -804,7 +804,7 @@ async def get_monitoring_fuel_total_by_columns( try: # Создаем запрос request_dict = request_data.model_dump() - request_dict['mode'] = 'total' + request_dict['mode'] = 'total_by_columns' request = DataRequest( report_type='monitoring_fuel', get_params=request_dict @@ -849,7 +849,7 @@ async def get_monitoring_fuel_month_by_code( try: # Создаем запрос request_dict = request_data.model_dump() - request_dict['mode'] = 'month' + request_dict['mode'] = 'month_by_code' request = DataRequest( report_type='monitoring_fuel', get_params=request_dict diff --git a/python_parser/core/services.py b/python_parser/core/services.py index d66d1bc..e2d1146 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -124,6 +124,20 @@ class ReportService: if request.report_type == 'svodka_ca': # Для svodka_ca используем геттер get_ca_data getter_name = 'get_ca_data' + elif request.report_type == 'monitoring_fuel': + # Для monitoring_fuel определяем геттер из параметра mode + getter_name = get_params.pop("mode", None) + if not getter_name: + # Если режим не указан, берем первый доступный + available_getters = list(parser.getters.keys()) + if available_getters: + getter_name = available_getters[0] + print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}") + else: + return DataResult( + success=False, + message="Парсер не имеет доступных геттеров" + ) else: # Для других парсеров определяем из параметра mode getter_name = get_params.pop("mode", None) From 15d13870f35ddc1c0d80e5394e7941c8d0e5c43c Mon Sep 17 00:00:00 2001 From: Maksim Date: Tue, 2 Sep 2025 22:37:26 +0300 Subject: [PATCH 5/8] =?UTF-8?q?=D1=81=D1=80=20=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_parser/adapters/parsers/svodka_pm.py | 21 +++++++++++++++++---- python_parser/app/main.py | 4 ++-- python_parser/core/services.py | 14 ++++++++++++++ python_parser/test_app.py | 20 ++++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 python_parser/test_app.py diff --git a/python_parser/adapters/parsers/svodka_pm.py b/python_parser/adapters/parsers/svodka_pm.py index df473ca..3901a08 100644 --- a/python_parser/adapters/parsers/svodka_pm.py +++ b/python_parser/adapters/parsers/svodka_pm.py @@ -160,13 +160,23 @@ class SvodkaPMParser(ParserPort): if last_good_name: new_columns.append(last_good_name) else: - # Если нет хорошего имени, пропускаем - continue + # Если нет хорошего имени, используем имя по умолчанию + 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 @@ -256,8 +266,9 @@ class SvodkaPMParser(ParserPort): ''' Служебная функция получения данных по одному ОГ ''' result = {} - fact_df = pm_dict['facts'][id] - plan_df = pm_dict['plans'][id] + # Безопасно получаем данные, проверяя их наличие + 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: @@ -266,6 +277,7 @@ class SvodkaPMParser(ParserPort): 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) @@ -274,6 +286,7 @@ class SvodkaPMParser(ParserPort): 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) diff --git a/python_parser/app/main.py b/python_parser/app/main.py index 302eaed..d3151bf 100644 --- a/python_parser/app/main.py +++ b/python_parser/app/main.py @@ -323,7 +323,7 @@ async def get_svodka_pm_single_og( try: # Создаем запрос request_dict = request_data.model_dump() - request_dict['mode'] = 'single' + request_dict['mode'] = 'single_og' request = DataRequest( report_type='svodka_pm', get_params=request_dict @@ -377,7 +377,7 @@ async def get_svodka_pm_total_ogs( try: # Создаем запрос request_dict = request_data.model_dump() - request_dict['mode'] = 'total' + request_dict['mode'] = 'total_ogs' request = DataRequest( report_type='svodka_pm', get_params=request_dict diff --git a/python_parser/core/services.py b/python_parser/core/services.py index e2d1146..bb29f53 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -138,6 +138,20 @@ class ReportService: success=False, message="Парсер не имеет доступных геттеров" ) + elif request.report_type == 'svodka_pm': + # Для svodka_pm определяем геттер из параметра mode + getter_name = get_params.pop("mode", None) + if not getter_name: + # Если режим не указан, берем первый доступный + available_getters = list(parser.getters.keys()) + if available_getters: + getter_name = available_getters[0] + print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}") + else: + return DataResult( + success=False, + message="Парсер не имеет доступных геттеров" + ) else: # Для других парсеров определяем из параметра mode getter_name = get_params.pop("mode", None) diff --git a/python_parser/test_app.py b/python_parser/test_app.py new file mode 100644 index 0000000..3431ec7 --- /dev/null +++ b/python_parser/test_app.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +""" +Простой тест для проверки работы FastAPI +""" +from fastapi import FastAPI + +app = FastAPI(title="Test API") + +@app.get("/") +async def root(): + return {"message": "Test API is working"} + +@app.get("/health") +async def health(): + return {"status": "ok"} + +if __name__ == "__main__": + import uvicorn + print("Starting test server...") + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file From 631e58dad7bdd8a313d106448675b19b1129709c Mon Sep 17 00:00:00 2001 From: Maksim Date: Wed, 3 Sep 2025 11:35:04 +0300 Subject: [PATCH 6/8] =?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 zcmYjRT}%{L6ux(M?(F|AKMO3oAj1L+t1kWk3TQ1VScPcSVQC`hy27BVyR++^1)*sI zNn_fiO$*+d5MN4Dlaj`!4>WE1=tC2$FIY{*QPakxZTdC=+mt@_+(8;|GT(gX+;h&H zd+wR@^~sOAe5+os3&HB1`}V?IaMl;Zj@iZ?vu7_%QHRo?M3qLRNr@?&l`YCvC9X6p z+m!9iZt7Hagb;NpI|1EF3!rC6h#;j^Y3snst`KVDL19tvEBf89=zU5%c=?r1r6Ytc zVp?<_$M*cmU5>@D;mj;v#$&?b@8Uj;$5?}OL1;(pW2yaEJ&Fm(4B<$3Ofc+J&1X`> zs$tU$6lCXUI-@4@slo+A($p)-^oTA%Kv_0=4|WbEbd~BuM^l&d_}fF>s(z`E%d^{# z!_g|R*$j_f2{4V8-GN8$s{8J$x&E6i^W&fIdEnl*ByMA$I6uT;R_h9o2-r2VPFD@C zUOeR@0;tb=I&eF?;c4#*!lpqWp>!VdQVX;O6jZjfncNkX(u#G9d?sCpiH2BECklpr zIHRUCb%e%PkvAZPxk)+8d&(u+3VbI!F0@AaV_Xs++O0qd#gb?Yy;R>5)dx0-T*iwov~j>3J#&H zJs~CRt%4%Jl{>Q1Ce$yn-zs+P)x?CY9@U~jaU+5qrAiUf$e?{gqlgLOLoGrZKR7HE zD?}sv$)XZ`h${^tKVpb!T}wgt*mCDCZXA{E54GS4HWrH3T4}!Ywn8`n;VWo$nIN=J zxXzA;gY04G40f=V@Kx+s{5~AV*vbB=;zG817~7%J33PG?yIfN>GC&3--L@U+LMb{7 z!N;K4@&oqXBRx{SMfdHujUkQRojhl96uHB#qRg!T&Q%puwH& zmr!Lj^ko7P9kA=dw)L;Mp*D6alAT?yE+h7O)bC_oIgQ&`N)E6Omz9%Ta1|Ock{*{UjI}&hAtpb%;V|7>;xSx;A%3XFu2KSYf@9 zc=iC;&_f)oes}gv46FGRx4a-? z!1p{7pY5GF__!i6-}0!Uah0IrrWxDp;C$JGvc{i^8XrgEE8NaRqSyDrgM2~uy#Dl* zf>GcgUR!aY^3JuD01E6_TMdBO+KLYacK`DNG}WsJd*!DUp}7_|am{bqK>AmQPC(xT zdUr{;J4$-@ia(T=^kMOiT-ql|cXoRLf2=L(^N4pzLtinvyTt`~uehu)X1mvf0byK8 zX>EkvYRF7wA(Y`7;I-+2LerST5J#Z=bR0J56~60vIUkZ-Zxp>MC1KVW7z)Xatvd3) zUN2);msLf>!`+gFT=Jrx)94iJ=i$*$1FTKAqLm_vl>J@QOl0#cCS(-zDz-kTXnNoC zG+gs(=Z((!%+m3p`{C0|j^MICyyOV8FQf6PO>y*bm&@psPXsyvU=VFAo1dgZoJ;wXHj>gQ?>hP#hjtEpDANdsRt~({)`(gk zKKul<(L(-LuOHH}H=7$7%c%S4yP)I0k&(f-pt>R)Ef#=}sKi_|5-KCOUf{Hu9!z8iY z&9Ju-ezztJ?4YS3&?)7eQAxl!>nqfx#EN<}{_7>yNk`Q-nyG>vGivDUsRMKY2 z+ngQuF3CyQC6AN6SnDj#ZxJm60UMs@Vy%@{leojsNNQN1$m8wUkhk6sUZr+6l7v~6 z+RE5Luaj-BG`Bm+Lt}(+;ZP${&7IEtcB*B$kek=Rpc+B*G$9F_YHf4nb!JD^me)?$ z*=H3C2kiNE!N*FlV(%=^zY~)38f!WTi8}6(n&wI);8nS`h%{4?Z(^3Na>*nrcyuUA@ z>DFi@9qEepcBi-NhUh-s*qhk*4{<3_rIcbYQM_#3yg#Bv(rHSokX4N=Db|-tAJI#@ zqWiki$*zJOU5@nh&{$6-9qZbAq$|IXr8ga;G`4cp8+Whjk;^ z*Nw2FEH~IMXn+^?I>9+&e_DQ@iRN`I_fl&$Wtc6`(lfUzYJNoAlo^ zwDpG5HMnKkL_FnF?qwPGvN8Lld*hIO&;q=9@}Mc}4NQ5Jj92-{TR-&B;D&+hf`x;d zp1XHgW2-Vn^Z)Fr95!aXWZ4;4wx2rbF$}KzbF1RTX-;(Khq|`lFOfek0XV3wrgcN|}H~;5ABF_RzLIMym z%>|Lg01dCtfXHD05z`zHG3V2mgNVfL3Cg9QejJd*+A)N=o3L@bDB#C!cww7O%)8*l zT{WY!~Dh-4{}1|-XnG$OeNh+?OR zjC8>RNWXz(9g=&2A{v;ui6k*q=j1&Yu$Ks12PeC`)nSD9Ooryu~h*dD9(>jDAU z%agW#(z*OU0s!X!z$^e{JqxBhbsu`_I1HSzeC+^$`TtGgUG!A|n5n98RD_7|XVgna4FXGCNXyJ>|+q8tbIvRn2RBmgQ<^5K^dpmi|t2H{jyIi?z17BBDB zstU&x zfaK~F#cH2RncCK50y;eQ19R!X9W^$FFMSKhG4iRyJ)9nCpK?@X995&y^F8N!&LywN zZ+m{>dAs86iYykV8?t-Q$}80Ao|8RO?qJ3p{K&m%Xw%?>S=n=X^U2L4nT%SgglxcnDQPUrW)VhJ2B1qqZXPrRSF3Wg{XlJ2o4%MA=Oy03TWDi%>jSdL65ws3dDs#Gj;5V zTk>KRfbe$Z#wvohh>uF>l8 zj166aCaF*3(W9FpsZ=Zx)s693f`g=PErcqMJ!s}QKq9iy1^9ZdnM3=zt;`TM7@AyN z!>hs|V^7-Z2OfuoHVWhzfxv)%#2ilGu-QN@r8v1o@w72Dh}x#IynE>}f%FetND zuU#l-k9sf9<2xon8_*;L13kzpd|sCZ(aSjbr$)aGL^q|A@m?(rQI&|s4(nD8N-&>N{PcYI^q~F$cGmx(u$z7251QTshPHtf zm*1?gkcyhoO=ortZXRwq-U>lcQFFHSOe-#jTE`9Z-#7XYUtbaEGib4tZb4%Uk%ZYN z3)~OX!&H}gXtF;=k0bkGBXd)DDMDKOC$9w#3MWZ~BM z=YgvR;W!(su3q$G+zovN{HDnioH$RXJ;<^0RwcI!`)75fsTsMIXvkjZWXgr{`)inC zU=(EZ9FiA-JYfj8=!Cw6>mMP(6QVC8c@>CKJok{0%l$YHGu^25@4eeG(Vyhs=0~Jh zXHEyXR|%ja{V7iQP4eGKVdrS=9)GhYIsR>2NEL^Oh?|d<)U#nF5g5VBEd_bc!2a> zrYg(Z)(C_=P5vZqxM6QR*7CVnCfTNg3ncf2mM;hl)9VFX$b9kndch(2rmMV?XGEJO zFkEQi!&uv!;Y;B!kTc!H{-}hd2AJ((D;AI1xr=zBw1a)J_!? 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" From 1fcb44193da0d09cd7d57d1a9ecb662ff58d0f45 Mon Sep 17 00:00:00 2001 From: Maksim Date: Wed, 3 Sep 2025 13:38:36 +0300 Subject: [PATCH 7/8] =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=9F=D0=9C=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=B3?= =?UTF-8?q?=D0=B5=D1=82=D1=82=D0=B5=D1=80=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_parser/adapters/parsers/svodka_pm.py | 297 ++++++++++++++------ 1 file changed, 208 insertions(+), 89 deletions(-) diff --git a/python_parser/adapters/parsers/svodka_pm.py b/python_parser/adapters/parsers/svodka_pm.py index 9f8d5cd..bf83bf2 100644 --- a/python_parser/adapters/parsers/svodka_pm.py +++ b/python_parser/adapters/parsers/svodka_pm.py @@ -3,6 +3,9 @@ import pandas as pd import os import json +import zipfile +import tempfile +import shutil from typing import Dict, Any, List, Optional from core.ports import ParserPort from adapters.pconfig import SINGLE_OGS, replace_id_in_path, find_header_row, data_to_json @@ -35,25 +38,99 @@ class SvodkaPMParser(ParserPort): description="Получение данных по всем ОГ из сводки ПМ" ) - def parse(self, file_path: str, params: dict) -> pd.DataFrame: - """Парсинг файла сводки ПМ и возврат DataFrame""" + def parse(self, file_path: str, params: dict) -> Dict[str, pd.DataFrame]: + """Парсинг ZIP архива со сводками ПМ и возврат словаря с DataFrame""" # Проверяем расширение файла - if not file_path.lower().endswith(('.xlsx', '.xlsm', '.xls')): - raise ValueError(f"Неподдерживаемый формат файла: {file_path}") + if not file_path.lower().endswith('.zip'): + raise ValueError(f"Ожидается ZIP архив: {file_path}") - # Определяем тип файла по имени файла - filename = os.path.basename(file_path).lower() + # Создаем временную директорию для разархивирования + temp_dir = tempfile.mkdtemp() - 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) + try: + # Разархивируем файл + with zipfile.ZipFile(file_path, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + print(f"📦 Архив разархивирован в: {temp_dir}") + + # Посмотрим, что находится в архиве + print(f"🔍 Содержимое архива:") + for root, dirs, files in os.walk(temp_dir): + level = root.replace(temp_dir, '').count(os.sep) + indent = ' ' * 2 * level + print(f"{indent}{os.path.basename(root)}/") + subindent = ' ' * 2 * (level + 1) + for file in files: + print(f"{subindent}{file}") + + # Создаем словари для хранения данных как в оригинале + df_pm_facts = {} # Словарь с данными факта, ключ - ID ОГ + df_pm_plans = {} # Словарь с данными плана, ключ - ID ОГ + + # Ищем файлы в архиве (адаптируемся к реальной структуре) + fact_files = [] + plan_files = [] + + for root, dirs, files in os.walk(temp_dir): + for file in files: + if file.lower().endswith(('.xlsx', '.xlsm')): + full_path = os.path.join(root, file) + if 'fact' in file.lower() or 'факт' in file.lower(): + fact_files.append(full_path) + elif 'plan' in file.lower() or 'план' in file.lower(): + plan_files.append(full_path) + + print(f"📊 Найдено файлов факта: {len(fact_files)}") + print(f"📊 Найдено файлов плана: {len(plan_files)}") + + # Обрабатываем найденные файлы + for fact_file in fact_files: + # Извлекаем ID ОГ из имени файла + filename = os.path.basename(fact_file) + # Ищем паттерн типа svodka_fact_pm_SNPZ.xlsm + if 'svodka_fact_pm_' in filename: + og_id = filename.replace('svodka_fact_pm_', '').replace('.xlsx', '').replace('.xlsm', '') + if og_id in SINGLE_OGS: + print(f'📊 Загрузка факта: {fact_file} (ОГ: {og_id})') + df_pm_facts[og_id] = self._parse_svodka_pm(fact_file, 'Сводка Нефтепереработка') + print(f"✅ Факт загружен для {og_id}") + + for plan_file in plan_files: + # Извлекаем ID ОГ из имени файла + filename = os.path.basename(plan_file) + # Ищем паттерн типа svodka_plan_pm_SNPZ.xlsm + if 'svodka_plan_pm_' in filename: + og_id = filename.replace('svodka_plan_pm_', '').replace('.xlsx', '').replace('.xlsm', '') + if og_id in SINGLE_OGS: + print(f'📊 Загрузка плана: {plan_file} (ОГ: {og_id})') + df_pm_plans[og_id] = self._parse_svodka_pm(plan_file, 'Сводка Нефтепереработка') + print(f"✅ План загружен для {og_id}") + + # Инициализируем None для ОГ, для которых файлы не найдены + for og_id in SINGLE_OGS: + if og_id == 'BASH': + continue + if og_id not in df_pm_facts: + df_pm_facts[og_id] = None + if og_id not in df_pm_plans: + df_pm_plans[og_id] = None + + + + # Возвращаем словарь с данными (как в оригинале) + result = { + 'df_pm_facts': df_pm_facts, + 'df_pm_plans': df_pm_plans + } + + print(f"🎯 Обработано ОГ: {len([k for k, v in df_pm_facts.items() if v is not None])} факт, {len([k for k, v in df_pm_plans.items() if v is not None])} план") + + return result + + finally: + # Удаляем временную директорию + shutil.rmtree(temp_dir, ignore_errors=True) + print(f"🗑️ Временная директория удалена: {temp_dir}") def _parse_svodka_pm(self, file_path: str, sheet_name: str, header_num: Optional[int] = None) -> pd.DataFrame: """Парсинг отчетов одного ОГ для БП, ПП и факта""" @@ -147,96 +224,135 @@ class SvodkaPMParser(ParserPort): return df_final - def _get_svodka_value(self, df_svodka: pd.DataFrame, id: str, code: int, search_value: Optional[str] = None): + def _get_svodka_value(self, df_svodka: pd.DataFrame, og_id: str, code: int, search_value: Optional[str] = None): """Служебная функция для простой выборке по сводке""" - row_index = id - - print(f"🔍 DEBUG: Ищем код '{code}' в строке '{row_index}'") + print(f"🔍 DEBUG: Ищем код '{code}' для ОГ '{og_id}' в DataFrame с {len(df_svodka)} строками") print(f"🔍 DEBUG: Первая строка данных: {df_svodka.iloc[0].tolist()}") print(f"🔍 DEBUG: Доступные индексы: {list(df_svodka.index)}") + print(f"🔍 DEBUG: Доступные столбцы: {list(df_svodka.columns)}") - # Ищем столбцы, где в первой строке есть значение 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)}" - ) - - 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}' не найден в первой строке") + # Проверяем, есть ли код в индексе + if code not in df_svodka.index: + print(f"⚠️ Код '{code}' не найден в индексе") return 0 + + # Получаем позицию строки с кодом + code_row_loc = df_svodka.index.get_loc(code) + print(f"🔍 DEBUG: Код '{code}' в позиции {code_row_loc}") + + # Определяем позиции для поиска + if search_value is None: + # Ищем все позиции кроме "Итого" и None (первый столбец с заголовком) + target_positions = [] + for i, col_name in enumerate(df_svodka.iloc[0]): + if col_name != 'Итого' and col_name is not None: + target_positions.append(i) else: - if row_index in df_svodka.index: - # Получаем позицию строки - row_loc = df_svodka.index.get_loc(row_index) - print(f"🔍 DEBUG: Найдена строка '{row_index}' в позиции {row_loc}") + # Ищем позиции в первой строке, где есть нужное название + target_positions = [] + for i, col_name in enumerate(df_svodka.iloc[0]): + if col_name == search_value: + target_positions.append(i) + + print(f"🔍 DEBUG: Найдены позиции для '{search_value}': {target_positions[:5]}...") + print(f"🔍 DEBUG: Позиции в первой строке: {target_positions[:5]}...") - # Извлекаем значения по позициям столбцов - values = df_svodka.iloc[row_loc, col_positions] - print(f"🔍 DEBUG: Извлеченные значения: {values.tolist()}") + print(f"🔍 DEBUG: Ищем столбцы с названием '{search_value}'") + print(f"🔍 DEBUG: Целевые позиции: {target_positions[:10]}...") - # Преобразуем в числовой формат - numeric_values = pd.to_numeric(values, errors='coerce') - print(f"🔍 DEBUG: Числовые значения: {numeric_values.tolist()}") + if not target_positions: + print(f"⚠️ Позиции '{search_value}' не найдены") + return 0 - # Агрегация данных (NaN игнорируются) - if search_value is None: - return numeric_values + # Извлекаем значения из найденных позиций + values = [] + for pos in target_positions: + # Берем значение из пересечения строки с кодом и позиции столбца + value = df_svodka.iloc[code_row_loc, pos] + + # Если это Series, берем первое значение + if isinstance(value, pd.Series): + if len(value) > 0: + # Берем первое не-NaN значение + first_valid = value.dropna().iloc[0] if not value.dropna().empty else 0 + values.append(first_valid) else: - return numeric_values.iloc[0] + values.append(0) else: - print(f"⚠️ Строка '{row_index}' не найдена в индексе") - return None + values.append(value) + + + + # Преобразуем в числовой формат + numeric_values = pd.to_numeric(values, errors='coerce') + print(f"🔍 DEBUG: Числовые значения (первые 5): {numeric_values.tolist()[:5]}") + + # Попробуем альтернативное преобразование + try: + # Если pandas не может преобразовать, попробуем вручную + manual_values = [] + for v in values: + if pd.isna(v) or v is None: + manual_values.append(0) + else: + try: + # Пробуем преобразовать в float + manual_values.append(float(str(v).replace(',', '.'))) + except (ValueError, TypeError): + manual_values.append(0) + + print(f"🔍 DEBUG: Ручное преобразование (первые 5): {manual_values[:5]}") + numeric_values = pd.Series(manual_values) + except Exception as e: + print(f"⚠️ Ошибка при ручном преобразовании: {e}") + # Используем исходные значения + numeric_values = pd.Series([0 if pd.isna(v) or v is None else v for v in values]) + + # Агрегация данных (NaN игнорируются) + if search_value is None: + # Возвращаем массив всех значений (игнорируя NaN) + if len(numeric_values) > 0: + # Фильтруем NaN значения и возвращаем как список + valid_values = numeric_values.dropna() + if len(valid_values) > 0: + return valid_values.tolist() + else: + return [] + else: + return [] + else: + # Возвращаем массив всех значений (игнорируя NaN) + if len(numeric_values) > 0: + # Фильтруем NaN значения и возвращаем как список + valid_values = numeric_values.dropna() + if len(valid_values) > 0: + return valid_values.tolist() + else: + return [] + else: + return [] def _get_svodka_og(self, og_id: str, codes: List[int], columns: List[str], search_value: Optional[str] = None): """Служебная функция получения данных по одному ОГ""" result = {} - # Пути к файлам - exel_fact = 'data/pm_fact/svodka_fact_pm_ID' - exel_plan = 'data/pm_plan/svodka_plan_pm_ID' + # Получаем данные из сохраненных словарей (через self.df) + if not hasattr(self, 'df') or self.df is None: + print("❌ Данные не загружены. Сначала загрузите ZIP архив.") + return {col: {str(code): None for code in codes} for col in columns} - current_fact = replace_id_in_path(exel_fact, og_id) - current_plan = replace_id_in_path(exel_plan, og_id) + # Извлекаем словари из сохраненных данных + df_pm_facts = self.df.get('df_pm_facts', {}) + df_pm_plans = self.df.get('df_pm_plans', {}) - # Загружаем данные - fact_df = None - plan_df = None + # Получаем данные для конкретного ОГ + fact_df = df_pm_facts.get(og_id) + plan_df = df_pm_plans.get(og_id) - 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 + print(f"🔍 ===== НАЧАЛО ОБРАБОТКИ ОГ {og_id} =====") + print(f"🔍 Коды: {codes}") + print(f"🔍 Столбцы: {columns}") + print(f"🔍 Получены данные для {og_id}: факт={'✅' if fact_df is not None else '❌'}, план={'✅' if plan_df is not None else '❌'}") # Определяем, какие столбцы из какого датафрейма брать for col in columns: @@ -246,16 +362,19 @@ class SvodkaPMParser(ParserPort): if plan_df is None: print(f"❌ Невозможно обработать '{col}': нет данных плана для {og_id}") else: + print(f"🔍 DEBUG: ===== ОБРАБАТЫВАЕМ '{col}' ИЗ ДАННЫХ ПЛАНА =====") for code in codes: - val = self._get_svodka_value(plan_df, og_id, code, search_value) + print(f"🔍 DEBUG: --- Код {code} для {col} ---") + val = self._get_svodka_value(plan_df, og_id, code, col) col_result[str(code)] = val + print(f"🔍 DEBUG: ===== ЗАВЕРШИЛИ ОБРАБОТКУ '{col}' =====") elif col in ['ТБ', 'СЭБ', 'НЭБ']: if fact_df is None: print(f"❌ Невозможно обработать '{col}': нет данных факта для {og_id}") else: for code in codes: - val = self._get_svodka_value(fact_df, og_id, code, search_value) + val = self._get_svodka_value(fact_df, og_id, code, col) col_result[str(code)] = val else: print(f"⚠️ Неизвестный столбец: '{col}'. Пропускаем.") From 0a328f9781ae9f86e914e9a1bd076818732bb5d4 Mon Sep 17 00:00:00 2001 From: Maksim Date: Wed, 3 Sep 2025 14:22:28 +0300 Subject: [PATCH 8/8] =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20CA?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python_parser/adapters/parsers/svodka_ca.py | 221 ++++++++++---------- python_parser/core/services.py | 3 + 2 files changed, 111 insertions(+), 113 deletions(-) diff --git a/python_parser/adapters/parsers/svodka_ca.py b/python_parser/adapters/parsers/svodka_ca.py index 8ef0c53..c536473 100644 --- a/python_parser/adapters/parsers/svodka_ca.py +++ b/python_parser/adapters/parsers/svodka_ca.py @@ -25,20 +25,28 @@ class SvodkaCAParser(ParserPort): def _get_data_wrapper(self, params: dict): """Получение данных по режимам и таблицам""" + print(f"🔍 DEBUG: _get_data_wrapper вызван с параметрами: {params}") + # Валидируем параметры с помощью схемы Pydantic validated_params = validate_params_with_schema(params, SvodkaCARequest) modes = validated_params["modes"] tables = validated_params["tables"] + print(f"🔍 DEBUG: Запрошенные режимы: {modes}") + print(f"🔍 DEBUG: Запрошенные таблицы: {tables}") + # Проверяем, есть ли данные в 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 с режимами: {list(data_source.keys())}") 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 с режимами: {list(data_source.keys())}") else: + print(f"🔍 DEBUG: Нет данных! data_dict={getattr(self, 'data_dict', 'None')}, df={getattr(self, 'df', 'None')}") return {} # Фильтруем данные по запрошенным режимам и таблицам @@ -46,10 +54,19 @@ class SvodkaCAParser(ParserPort): for mode in modes: if mode in data_source: result_data[mode] = {} + available_tables = list(data_source[mode].keys()) + print(f"🔍 DEBUG: Режим '{mode}' содержит таблицы: {available_tables}") for table_name, table_data in data_source[mode].items(): - if table_name in tables: - result_data[mode][table_name] = table_data + # Ищем таблицы по частичному совпадению + for requested_table in tables: + if requested_table in table_name: + result_data[mode][table_name] = table_data + print(f"🔍 DEBUG: Добавлена таблица '{table_name}' (совпадение с '{requested_table}') с {len(table_data)} записями") + break # Найдено совпадение, переходим к следующей таблице + else: + print(f"🔍 DEBUG: Режим '{mode}' не найден в data_source") + print(f"🔍 DEBUG: Итоговый результат содержит режимы: {list(result_data.keys())}") return result_data def _df_to_data_dict(self): @@ -74,6 +91,8 @@ class SvodkaCAParser(ParserPort): def parse(self, file_path: str, params: dict) -> pd.DataFrame: """Парсинг файла и возврат DataFrame""" + print(f"🔍 DEBUG: SvodkaCAParser.parse вызван с файлом: {file_path}") + # Парсим данные и сохраняем словарь для использования в геттерах self.data_dict = self.parse_svodka_ca(file_path, params) @@ -95,132 +114,108 @@ class SvodkaCAParser(ParserPort): 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_svodka_ca(self, file_path: str, params: dict) -> dict: - """Парсинг сводки СА""" - # Получаем параметры из params - sheet_name = params.get('sheet_name', 0) # По умолчанию первый лист - inclusion_list = params.get('inclusion_list', {'ТиП', 'Топливо', 'Потери'}) + """Парсинг сводки СА - работает с тремя листами: План, Факт, Норматив""" + print(f"🔍 DEBUG: Начинаем парсинг сводки СА из файла: {file_path}") - # === Извлечение и фильтрация === - tables = self.extract_all_tables(file_path, sheet_name) + # === Точка входа. Нужно выгрузить три таблицы: План, Факт и Норматив === + + # Выгружаем План + inclusion_list_plan = { + "ТиП, %", + "Топливо итого, тонн", + "Топливо итого, %", + "Топливо на технологию, тонн", + "Топливо на технологию, %", + "Топливо на энергетику, тонн", + "Топливо на энергетику, %", + "Потери итого, тонн", + "Потери итого, %", + "в т.ч. Идентифицированные безвозвратные потери, тонн**", + "в т.ч. Идентифицированные безвозвратные потери, %**", + "в т.ч. Неидентифицированные потери, тонн**", + "в т.ч. Неидентифицированные потери, %**" + } - # Фильтруем таблицы: оставляем только те, где первая строка содержит нужные заголовки - 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) + df_ca_plan = self.parse_sheet(file_path, 'План', inclusion_list_plan) + print(f"🔍 DEBUG: Объединённый и отсортированный План: {df_ca_plan.shape if df_ca_plan is not None else 'None'}") - tables = filtered_tables + # Выгружаем Факт + inclusion_list_fact = { + "ТиП, %", + "Топливо итого, тонн", + "Топливо итого, %", + "Топливо на технологию, тонн", + "Топливо на технологию, %", + "Топливо на энергетику, тонн", + "Топливо на энергетику, %", + "Потери итого, тонн", + "Потери итого, %", + "в т.ч. Идентифицированные безвозвратные потери, тонн", + "в т.ч. Идентифицированные безвозвратные потери, %", + "в т.ч. Неидентифицированные потери, тонн", + "в т.ч. Неидентифицированные потери, %" + } - # === Итоговый список таблиц датафреймов === - result_list = [] + df_ca_fact = self.parse_sheet(file_path, 'Факт', inclusion_list_fact) + print(f"🔍 DEBUG: Объединённый и отсортированный Факт: {df_ca_fact.shape if df_ca_fact is not None else 'None'}") - for table in tables: - if table.empty: - continue + # Выгружаем Норматив + inclusion_list_normativ = { + "Топливо итого, тонн", + "Топливо итого, %", + "Топливо на технологию, тонн", + "Топливо на технологию, %", + "Топливо на энергетику, тонн", + "Топливо на энергетику, %", + "Потери итого, тонн", + "Потери итого, %", + "в т.ч. Идентифицированные безвозвратные потери, тонн**", + "в т.ч. Идентифицированные безвозвратные потери, %**", + "в т.ч. Неидентифицированные потери, тонн**", + "в т.ч. Неидентифицированные потери, %**" + } - # Получаем первую строку (до удаления) - first_row_values = table.iloc[0].astype(str).str.strip().tolist() + df_ca_normativ = self.parse_sheet(file_path, 'Норматив', inclusion_list_normativ) + print(f"🔍 DEBUG: Объединённый и отсортированный Норматив: {df_ca_normativ.shape if df_ca_normativ is not None else 'None'}") - # Находим, какой элемент из 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) - - # Преобразуем DataFrame в словарь по режимам и таблицам - # Для сводки СА у нас есть только один режим - 'fact' (по умолчанию) - # Но нужно определить режим из данных или параметров - mode = params.get('mode', 'fact') # По умолчанию 'fact' - - data_dict = {mode: {}} - - # Группируем данные по таблицам - for table_name, group_df in combined_df.groupby('table'): - # Удаляем колонку 'table' из результата + # Преобразуем DataFrame в словарь по режимам и таблицам + data_dict = {} + + # Обрабатываем План + if df_ca_plan is not None and not df_ca_plan.empty: + data_dict['plan'] = {} + for table_name, group_df in df_ca_plan.groupby('table'): table_data = group_df.drop('table', axis=1) - data_dict[mode][table_name] = table_data.to_dict('records') - - return data_dict - else: - return {} + data_dict['plan'][table_name] = table_data.to_dict('records') + + # Обрабатываем Факт + if df_ca_fact is not None and not df_ca_fact.empty: + data_dict['fact'] = {} + for table_name, group_df in df_ca_fact.groupby('table'): + table_data = group_df.drop('table', axis=1) + data_dict['fact'][table_name] = table_data.to_dict('records') + + # Обрабатываем Норматив + if df_ca_normativ is not None and not df_ca_normativ.empty: + data_dict['normativ'] = {} + for table_name, group_df in df_ca_normativ.groupby('table'): + table_data = group_df.drop('table', axis=1) + data_dict['normativ'][table_name] = table_data.to_dict('records') + + print(f"🔍 DEBUG: Итоговый data_dict содержит режимы: {list(data_dict.keys())}") + for mode, tables in data_dict.items(): + print(f"🔍 DEBUG: Режим '{mode}' содержит таблицы: {list(tables.keys())}") + + return data_dict def extract_all_tables(self, file_path, sheet_name=0): """Извлечение всех таблиц из Excel файла""" diff --git a/python_parser/core/services.py b/python_parser/core/services.py index bb29f53..90012ae 100644 --- a/python_parser/core/services.py +++ b/python_parser/core/services.py @@ -102,6 +102,9 @@ class ReportService: # Устанавливаем DataFrame в парсер для использования в геттерах parser.df = df + print(f"🔍 DEBUG: ReportService.get_data - установлен df в парсер {request.report_type}") + print(f"🔍 DEBUG: DataFrame shape: {df.shape if df is not None else 'None'}") + print(f"🔍 DEBUG: DataFrame columns: {list(df.columns) if df is not None and not df.empty else 'Empty'}") # Получаем параметры запроса get_params = request.get_params or {}