"""Excel: итоги + сравнение + подробно.""" from __future__ import annotations import io from typing import Any, Optional from openpyxl import Workbook from openpyxl.formatting.rule import FormulaRule from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter from openpyxl.worksheet.worksheet import Worksheet from app.services.analytics.export_context import ( build_export_header_lines, net_rub_display, net_rub_dominant_hint, ) from app.services.analytics.export_sections import parse_export_sections from app.services.analytics.finance_summary import build_finance_summary from app.services.analytics.plan_fact_layers import build_plan_fact_rows from app.services.analytics.reports_detail import build_reports_detail_rows from app.services.analytics.summary_export_rows import build_summary_table_rows _OVERLOAD_FILL = PatternFill(start_color="FFEBEE", end_color="FFEBEE", fill_type="solid") _UNDERLOAD_FILL = PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid") _HEADER_FONT = Font(bold=True) def _apply_row_tint(ws: Worksheet, row: int, cols: int, *, overload_rub: float, underload_rub: float) -> None: fill = None if overload_rub > 0 and underload_rub <= 0: fill = _OVERLOAD_FILL elif underload_rub > 0 and overload_rub <= 0: fill = _UNDERLOAD_FILL elif overload_rub > 0 and underload_rub > 0: fill = _OVERLOAD_FILL if overload_rub >= underload_rub else _UNDERLOAD_FILL if not fill: return for col in range(1, cols + 1): ws.cell(row=row, column=col).fill = fill def _cell_width_estimate(value: Any) -> float: if value is None: return 0.0 total = 0.0 for ch in str(value): total += 1.35 if ord(ch) > 127 else 1.0 return total def _auto_fit_columns( ws: Worksheet, *, min_width: float = 10, max_width: float = 64, padding: float = 3.0, ) -> None: if ws.max_row < 1: return max_col = ws.max_column or 1 for col_idx in range(1, max_col + 1): max_len = 0.0 for row in ws.iter_rows(min_col=col_idx, max_col=col_idx, min_row=1, max_row=ws.max_row): max_len = max(max_len, _cell_width_estimate(row[0].value)) if max_len <= 0: continue letter = get_column_letter(col_idx) ws.column_dimensions[letter].width = min(max(max_len + padding, min_width), max_width) def _style_table_header_row(ws: Worksheet, row: int, cols: int) -> None: for col in range(1, cols + 1): cell = ws.cell(row=row, column=col) cell.font = _HEADER_FONT cell.alignment = Alignment(wrap_text=True, vertical="center") # Процентная ширина столбцов листа «Подробно» (сумма = 100). # Текстовые колонки шире для переноса; числовые — фиксированный минимум. _DETAIL_SHEET_COL_PCT = [ 7, # Дата 14, # Ферма 10, # Кормораздатчик 14, # Рейс 10, # Время 6, # Тип 12, # Компонент 9, # Группа 8, # План 8, # Факт 8, # Отклонение 8, # Перерасход 6, # Загрузка 6, # Смешивание план 6, # Смешивание факт ] _DETAIL_SHEET_WRAP_COLS = frozenset({2, 3, 4, 5, 7, 8}) _DETAIL_SHEET_BASE_WIDTH = 118.0 def _detail_sheet_col_widths() -> list[float]: scale = _DETAIL_SHEET_BASE_WIDTH / 100.0 return [pct * scale for pct in _DETAIL_SHEET_COL_PCT] def _apply_detail_sheet_layout(ws: Worksheet, *, header_row: int, last_row: int, col_count: int) -> None: wrap_align = Alignment(wrap_text=True, vertical="top") num_align = Alignment(vertical="top") for col_idx, width in enumerate(_detail_sheet_col_widths(), start=1): if col_idx > col_count: break ws.column_dimensions[get_column_letter(col_idx)].width = width for row_idx in range(header_row, last_row + 1): for col_idx in range(1, col_count + 1): cell = ws.cell(row=row_idx, column=col_idx) cell.alignment = wrap_align if col_idx in _DETAIL_SHEET_WRAP_COLS else num_align def _write_export_header( ws: Worksheet, *, date_from: str, date_to: str, filter_farms: Optional[str], filter_dispensers: Optional[str], filter_recipes: Optional[str], ) -> None: for line in build_export_header_lines( date_from=date_from, date_to=date_to, filter_farms=filter_farms, filter_dispensers=filter_dispensers, filter_recipes=filter_recipes, ): ws.append([line]) def _fill_summary_sheet( ws: Worksheet, *, summary: dict[str, Any], table_rows: list[dict[str, Any]], date_from: str, date_to: str, filter_farms: Optional[str], filter_dispensers: Optional[str], filter_recipes: Optional[str], ) -> None: ws.title = "Итоги" _write_export_header( ws, date_from=date_from, date_to=date_to, filter_farms=filter_farms, filter_dispensers=filter_dispensers, filter_recipes=filter_recipes, ) ws.append([]) ws.append(["Перерасход, ₽", summary.get("overloadRub", 0)]) ws.append(["Недогруз, ₽", summary.get("underloadRub", 0)]) ws.append(["Итого по деньгам, ₽", net_rub_display(summary)]) ws.append([net_rub_dominant_hint(summary)]) ws.append([]) headers = [ "Ферма", "Компонент", "План, кг", "Факт, кг", "Отклонение, кг", "Цена за кг, ₽", "Перерасход, ₽", "Недогруз, ₽", ] ws.append(headers) header_row = ws.max_row _style_table_header_row(ws, header_row, len(headers)) for row in table_rows: ws.append( [ row.get("farm"), row.get("component"), row.get("planKg"), row.get("factKg"), row.get("deviationKg"), row.get("pricePerKg"), row.get("overloadRub"), row.get("underloadRub"), ] ) _apply_row_tint( ws, ws.max_row, len(headers), overload_rub=float(row.get("overloadRub") or 0), underload_rub=float(row.get("underloadRub") or 0), ) def _fill_comparison_sheet(ws: Worksheet, plan_fact: dict[str, Any]) -> None: ws.title = "Сравнение" headers = [ "Рейс", "Дата", "Компонент", "По рецепту (База), кг", "На сегодня (План), кг", "Сделали (Факт), кг", "Разница База/Факт, кг", "Разница План/Факт, кг", "Статус", ] ws.append(headers) header_row = ws.max_row _style_table_header_row(ws, header_row, len(headers)) status_col = len(headers) status_col_letter = get_column_letter(status_col) for item in plan_fact.get("items") or []: for comp in item.get("components") or []: base_kg = float(comp.get("baseKg") or 0) plan_kg = float(comp.get("planTodayKg") or 0) fact_kg = float(comp.get("actualKg") or 0) fault = comp.get("fault") or "none" status = { "execution": "Ошибка при загрузке", "excluded": "Исключено из плана", "adjusted": "План скорректирован", }.get(fault, "") ws.append( [ item.get("recipeName"), item.get("date"), comp.get("name"), round(base_kg, 2), round(plan_kg, 2), round(fact_kg, 2), round(fact_kg - base_kg, 2), round(fact_kg - plan_kg, 2), status, ] ) row_num = ws.max_row if fault == "execution": for col in range(1, len(headers) + 1): ws.cell(row=row_num, column=col).fill = _OVERLOAD_FILL elif fault in ("adjusted", "excluded"): for col in range(1, len(headers) + 1): ws.cell(row=row_num, column=col).fill = _UNDERLOAD_FILL if ws.max_row > header_row: ws.conditional_formatting.add( f"{status_col_letter}{header_row + 1}:{status_col_letter}{ws.max_row}", FormulaRule( formula=[f'{status_col_letter}{header_row + 1}="Ошибка при загрузке"'], fill=_OVERLOAD_FILL, ), ) _auto_fit_columns(ws, min_width=12, max_width=70, padding=3.5) def _fill_detail_sheet(ws: Worksheet, detail_rows: list[dict[str, Any]]) -> None: ws.title = "Подробно" headers = [ "Дата", "Ферма", "Кормораздатчик", "Рейс", "Время", "Тип", "Компонент", "Группа", "План, кг", "Факт, кг", "Отклонение, кг", "Перерасход, кг", "Загрузка, с", "Смешивание план, с", "Смешивание факт, с", ] ws.append(headers) _style_table_header_row(ws, 1, len(headers)) for row in detail_rows: dev = float(row.get("deviationKg") or 0) target = float(row.get("targetKg") or 0) actual = float(row.get("actualKg") or 0) overload_kg = max(0.0, round(actual - target, 2)) is_unloading = row.get("kind") == "unloading" values = [ row.get("dateDay"), row.get("farm"), row.get("dispenserName"), row.get("recipeName"), row.get("dateTime"), "Выгрузка" if is_unloading else "Загрузка", row.get("feedComponent") if is_unloading else (row.get("feedComponent") or ""), row.get("animalGroup") if is_unloading else "", round(target, 2), round(actual, 2), round(dev, 2), overload_kg if overload_kg > 0 else None, row.get("loadingSec"), row.get("mixPlanSec"), row.get("mixFactSec"), ] row_num = ws.max_row + 1 for col_idx, value in enumerate(values, start=1): ws.cell(row=row_num, column=col_idx, value=value) if dev > 0.05: _apply_row_tint(ws, row_num, len(headers), overload_rub=1, underload_rub=0) elif dev < -0.05: _apply_row_tint(ws, row_num, len(headers), overload_rub=0, underload_rub=1) _apply_detail_sheet_layout( ws, header_row=1, last_row=ws.max_row, col_count=len(headers), ) def build_finance_summary_xlsx( *, date_from: str, date_to: str, recipe_id: str | None = None, recipe_ids: str | None = None, section: str | None = None, filter_farms: str | None = None, filter_dispensers: str | None = None, filter_recipes: str | None = None, ) -> bytes: sections = parse_export_sections(section) summary = None table_rows: list[dict[str, Any]] = [] plan_fact = None detail_rows = None if "summary" in sections: summary = build_finance_summary( date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids ) table_rows = build_summary_table_rows( date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids ) if "comparison" in sections: plan_fact = build_plan_fact_rows( date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids ) if "reports" in sections: detail_rows = build_reports_detail_rows( date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids ) wb = Workbook() wb.remove(wb.active) if "summary" in sections: ws = wb.create_sheet("Итоги") _fill_summary_sheet( ws, summary=summary or {}, table_rows=table_rows, date_from=date_from, date_to=date_to, filter_farms=filter_farms, filter_dispensers=filter_dispensers, filter_recipes=filter_recipes, ) if "comparison" in sections: ws = wb.create_sheet("Сравнение") _fill_comparison_sheet(ws, plan_fact or {}) if "reports" in sections: ws = wb.create_sheet("Подробно") _fill_detail_sheet(ws, detail_rows or []) if not wb.sheetnames: ws = wb.create_sheet("Итоги") _fill_summary_sheet( ws, summary={}, table_rows=[], date_from=date_from, date_to=date_to, filter_farms=filter_farms, filter_dispensers=filter_dispensers, filter_recipes=filter_recipes, ) for ws_cur in wb.worksheets: if ws_cur.title == "Подробно": continue _auto_fit_columns(ws_cur) buf = io.BytesIO() wb.save(buf) return buf.getvalue()