Files
site/apps/api/app/modules/zootech/analytics/xlsx_summary.py
T
2026-07-17 12:57:18 +03:00

403 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()