275 lines
13 KiB
Python
275 lines
13 KiB
Python
import pandas as pd
|
||
import re
|
||
import zipfile
|
||
from typing import Dict, Tuple
|
||
from core.ports import ParserPort
|
||
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
|
||
from app.schemas.monitoring_fuel import MonitoringFuelTotalRequest, MonitoringFuelMonthRequest, MonitoringFuelSeriesRequest
|
||
from adapters.pconfig import data_to_json, find_header_row
|
||
|
||
|
||
class MonitoringFuelParser(ParserPort):
|
||
"""Парсер для мониторинга топлива"""
|
||
|
||
name = "Мониторинг топлива"
|
||
|
||
def _register_default_getters(self):
|
||
"""Регистрация геттеров по умолчанию"""
|
||
# Используем схемы Pydantic как единый источник правды
|
||
register_getter_from_schema(
|
||
parser_instance=self,
|
||
getter_name="total_by_columns",
|
||
method=self._get_total_by_columns,
|
||
schema_class=MonitoringFuelTotalRequest,
|
||
description="Агрегация данных по колонкам"
|
||
)
|
||
|
||
register_getter_from_schema(
|
||
parser_instance=self,
|
||
getter_name="month_by_code",
|
||
method=self._get_month_by_code,
|
||
schema_class=MonitoringFuelMonthRequest,
|
||
description="Получение данных за конкретный месяц"
|
||
)
|
||
|
||
register_getter_from_schema(
|
||
parser_instance=self,
|
||
getter_name="series_by_id_and_columns",
|
||
method=self._get_series_by_id_and_columns,
|
||
schema_class=MonitoringFuelSeriesRequest,
|
||
description="Получение временных рядов по ID и колонкам"
|
||
)
|
||
|
||
def _get_total_by_columns(self, params: dict):
|
||
"""Агрегация данных по колонкам"""
|
||
# Валидируем параметры с помощью схемы Pydantic
|
||
validated_params = validate_params_with_schema(params, MonitoringFuelTotalRequest)
|
||
|
||
columns = validated_params["columns"]
|
||
|
||
# TODO: Переделать под новую архитектуру
|
||
df_means, _ = self.aggregate_by_columns(self.df, columns)
|
||
return df_means.to_dict(orient='index')
|
||
|
||
def _get_month_by_code(self, params: dict):
|
||
"""Получение данных за конкретный месяц"""
|
||
# Валидируем параметры с помощью схемы Pydantic
|
||
validated_params = validate_params_with_schema(params, MonitoringFuelMonthRequest)
|
||
|
||
month = validated_params["month"]
|
||
|
||
# TODO: Переделать под новую архитектуру
|
||
df_month = self.get_month(self.df, month)
|
||
return df_month.to_dict(orient='index')
|
||
|
||
def parse(self, file_path: str, params: dict) -> pd.DataFrame:
|
||
"""Парсинг файла и возврат DataFrame"""
|
||
# Сохраняем DataFrame для использования в геттерах
|
||
self.df = self.parse_monitoring_fuel_files(file_path, params)
|
||
return self.df
|
||
|
||
def parse_monitoring_fuel_files(self, zip_path: str, params: dict) -> Dict[str, pd.DataFrame]:
|
||
"""Парсинг ZIP архива с файлами мониторинга топлива"""
|
||
df_monitorings = {} # ЭТО СЛОВАРЬ ДАТАФРЕЙМОВ, ГДЕ КЛЮЧ - НОМЕР МЕСЯЦА, ЗНАЧЕНИЕ - ДАТАФРЕЙМ
|
||
|
||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||
|
||
file_list = zip_ref.namelist()
|
||
for month in range(1, 13):
|
||
|
||
mm = f"{month:02d}"
|
||
file_temp = f'monitoring_SNPZ_{mm}.xlsm'
|
||
candidates = [f for f in file_list if file_temp in f]
|
||
|
||
if len(candidates) == 1:
|
||
file = candidates[0]
|
||
|
||
print(f'Загрузка {file}')
|
||
with zip_ref.open(file) as excel_file:
|
||
try:
|
||
df = self.parse_single(excel_file, 'Мониторинг потребления')
|
||
df_monitorings[mm] = df
|
||
|
||
print(f"✅ Данные за месяц {mm} загружены")
|
||
|
||
except Exception as e:
|
||
print(f"Ошибка при загрузке файла {file_temp}: {e}")
|
||
|
||
else:
|
||
print(f"⚠️ Файл не найден: {file_temp}")
|
||
|
||
return df_monitorings
|
||
|
||
|
||
|
||
def parse_single(self, file, sheet, header_num=None):
|
||
''' Собственно парсер отчетов одного объекта'''
|
||
# Автоопределение header_num, если не передан
|
||
if header_num is None:
|
||
header_num = find_header_row(file, sheet, search_value="Установка")
|
||
# Читаем весь лист, начиная с найденной строки как заголовок
|
||
df_full = pd.read_excel(
|
||
file,
|
||
sheet_name=sheet,
|
||
header=header_num,
|
||
usecols=None,
|
||
index_col=None,
|
||
engine='openpyxl'
|
||
)
|
||
|
||
# === Удаление полностью пустых столбцов ===
|
||
df_clean = df_full.replace(r'^\s*$', pd.NA, regex=True) # заменяем пустые строки на NA
|
||
df_clean = df_clean.dropna(axis=1, how='all') # удаляем столбцы, где все значения — NA
|
||
df_full = df_full.loc[:, df_clean.columns] # оставляем только непустые столбцы
|
||
|
||
# === Переименовываем нужные столбцы по позициям ===
|
||
if len(df_full.columns) < 2:
|
||
raise ValueError("DataFrame должен содержать как минимум 2 столбца.")
|
||
|
||
new_columns = df_full.columns.tolist()
|
||
|
||
new_columns[0] = 'name'
|
||
new_columns[1] = 'normativ'
|
||
new_columns[-2] = 'total'
|
||
new_columns[-1] = 'total_1'
|
||
|
||
df_full.columns = new_columns
|
||
|
||
# Проверяем, что колонка 'name' существует
|
||
if 'name' in df_full.columns:
|
||
# Применяем функцию get_id_by_name к каждой строке в колонке 'name'
|
||
# df_full['id'] = df_full['name'].apply(get_object_by_name) # This line was removed as per new_code
|
||
pass # Placeholder for new_code
|
||
|
||
# Устанавливаем id как индекс
|
||
df_full.set_index('id', inplace=True)
|
||
print(f"Окончательное количество столбцов: {len(df_full.columns)}")
|
||
return df_full
|
||
|
||
def aggregate_by_columns(self, df_dict: Dict[str, pd.DataFrame], columns: list) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
|
||
''' Служебная функция. Агрегация данных по среднему по определенным колонкам. '''
|
||
all_data = {} # Для хранения полных данных (месяцы) по каждой колонке
|
||
means = {} # Для хранения средних
|
||
|
||
for col in columns:
|
||
totals = [] # Список Series (по месяцам) для текущей колонки
|
||
|
||
for file_key, df in df_dict.items():
|
||
if col not in df.columns:
|
||
print(f"Колонка '{col}' не найдена в {file_key}, пропускаем.")
|
||
continue
|
||
|
||
# Берём колонку, оставляем id как индекс
|
||
series = df[col].copy()
|
||
|
||
# Определяем имя месяца: извлекаем номер из ключа (например, '03' из '03')
|
||
# Если ключ уже '03', '04' и т.п. — используем как есть
|
||
match = re.search(r'\d{2}', str(file_key))
|
||
month = match.group() if match else file_key
|
||
series.name = month
|
||
totals.append(series)
|
||
|
||
if not totals:
|
||
raise ValueError(f"Ни один DataFrame не содержит колонку '{col}'")
|
||
|
||
# Объединяем все Series в один DataFrame (id × месяцы)
|
||
df_combined = pd.concat(totals, axis=1)
|
||
all_data[col] = df_combined # Сохраняем
|
||
|
||
# Считаем среднее по строкам (по месяцам), игнорируя NaN
|
||
mean_series = df_combined.mean(axis=1)
|
||
mean_series = mean_series.dropna() # Убираем id, где нет данных вообще
|
||
means[col] = mean_series
|
||
|
||
# Собираем все средние в один DataFrame
|
||
df_means = pd.DataFrame(means)
|
||
|
||
return df_means, all_data
|
||
|
||
def get_month(self, df_monitorings, month_key):
|
||
''' Служебная функция. Выгрузить только конкретный месяц '''
|
||
if month_key not in df_monitorings:
|
||
raise KeyError(f"Месяц '{month_key}' не найден в df_monitorings. Доступные: {list(df_monitorings.keys())}")
|
||
|
||
df = df_monitorings[month_key]
|
||
|
||
# Создаём копию, чтобы не изменять оригинальный словарь
|
||
result_df = df.copy()
|
||
|
||
# Удаляем колонку 'name', если она существует
|
||
if 'name' in result_df.columns:
|
||
result_df = result_df.drop(columns=['name'])
|
||
|
||
return result_df
|
||
|
||
def aggregate_total_by_id(self, df_dict: Dict[str, pd.DataFrame], column: str):
|
||
"""Агрегация данных по ID"""
|
||
totals = []
|
||
|
||
for file, df in df_dict.items():
|
||
if column not in df.columns:
|
||
print(f"Колонка '{column}' не найдена в {file}, пропускаем.")
|
||
continue
|
||
|
||
# Берём колонку и сохраняем как Series с именем месяца
|
||
series = df[column].copy()
|
||
series.name = re.sub(r'\D', '', file) # Имя Series будет использовано как имя колонки в concat
|
||
totals.append(series)
|
||
|
||
if not totals:
|
||
raise ValueError(f"Ни один DataFrame не содержит колонку '{column}'")
|
||
|
||
df_combined = pd.concat(totals, axis=1)
|
||
|
||
# Считаем среднее по строкам (по месяцам)
|
||
total = df_combined.mean(axis=1)
|
||
total = total.dropna()
|
||
|
||
total.name = 'mean'
|
||
|
||
return total, df_combined
|
||
|
||
def _get_series_by_id_and_columns(self, params: dict):
|
||
"""Получение временных рядов по ID и колонкам"""
|
||
# Валидируем параметры с помощью схемы Pydantic
|
||
validated_params = validate_params_with_schema(params, MonitoringFuelSeriesRequest)
|
||
|
||
columns = validated_params["columns"]
|
||
|
||
# Проверяем, что все колонки существуют хотя бы в одном месяце
|
||
valid_columns = set()
|
||
for month in self.df.values():
|
||
valid_columns.update(month.columns)
|
||
|
||
for col in columns:
|
||
if col not in valid_columns:
|
||
raise ValueError(f"Колонка '{col}' не найдена ни в одном месяце")
|
||
|
||
# Подготавливаем результат: словарь id → {col: [значения по месяцам]}
|
||
result = {}
|
||
|
||
# Обрабатываем месяцы от 01 до 12
|
||
for month_key in [f"{i:02d}" for i in range(1, 13)]:
|
||
if month_key not in self.df:
|
||
print(f"Месяц '{month_key}' не найден в df_monitorings, пропускаем.")
|
||
continue
|
||
|
||
df = self.df[month_key]
|
||
|
||
for col in columns:
|
||
if col not in df.columns:
|
||
continue # Пропускаем, если в этом месяце нет колонки
|
||
|
||
for idx, value in df[col].items():
|
||
if pd.isna(value):
|
||
continue # Можно пропустить NaN, или оставить как null
|
||
|
||
if idx not in result:
|
||
result[idx] = {c: [] for c in columns}
|
||
|
||
result[idx][col].append(value)
|
||
|
||
# Преобразуем ключи id в строки (для JSON-совместимости)
|
||
result_str_keys = {str(k): v for k, v in result.items()}
|
||
return result_str_keys
|