Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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()