@@ -0,0 +1,402 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user