426 lines
14 KiB
Python
426 lines
14 KiB
Python
"""PDF: итоги + сравнение + подробно (для директора)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from typing import Any, Iterable, List, Optional
|
|
from xml.sax.saxutils import escape
|
|
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4, landscape
|
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
from reportlab.lib.units import mm
|
|
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
|
|
|
from app.services.analytics.dispenser_summary_export_rows import build_dispenser_summary_rows
|
|
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.feed_accounting.pdf_feed_accounting import (
|
|
_register_cyrillic_font,
|
|
_register_cyrillic_font_bold,
|
|
)
|
|
|
|
_PAGE_MARGIN = 5 * mm
|
|
_LANDSCAPE_WIDTH = landscape(A4)[0]
|
|
_USABLE_WIDTH = _LANDSCAPE_WIDTH - 2 * _PAGE_MARGIN
|
|
|
|
|
|
def _widths_from_percentages(percentages: list[float]) -> list[float]:
|
|
total = float(sum(percentages)) or 100.0
|
|
scale = _USABLE_WIDTH / total
|
|
return [pct * scale for pct in percentages]
|
|
|
|
|
|
def _format_rub(amount: float) -> str:
|
|
"""Сумма в рублях без символа ₽ — DejaVu Sans его не всегда отрисовывает."""
|
|
return f"{abs(float(amount or 0)):,.0f} руб.".replace(",", " ")
|
|
|
|
|
|
def _format_rub_plain(amount: float) -> str:
|
|
"""Число для ячеек таблицы без суффикса валюты."""
|
|
return f"{abs(float(amount or 0)):,.0f}".replace(",", " ")
|
|
|
|
|
|
def _para(text: Any, style: ParagraphStyle) -> Paragraph:
|
|
return Paragraph(escape(str(text if text is not None else "")), style)
|
|
|
|
|
|
def _styles():
|
|
font = _register_cyrillic_font()
|
|
bold_font = _register_cyrillic_font_bold()
|
|
styles = getSampleStyleSheet()
|
|
for key in ("Title", "Normal", "Heading2", "Heading3"):
|
|
styles[key].fontName = font
|
|
styles.add(
|
|
ParagraphStyle(
|
|
name="KpiValue",
|
|
parent=styles["Normal"],
|
|
fontName=font,
|
|
fontSize=16,
|
|
leading=20,
|
|
spaceAfter=2,
|
|
)
|
|
)
|
|
styles.add(
|
|
ParagraphStyle(
|
|
name="KpiLabel",
|
|
parent=styles["Normal"],
|
|
fontName=font,
|
|
fontSize=8,
|
|
textColor=colors.grey,
|
|
spaceAfter=8,
|
|
)
|
|
)
|
|
styles.add(
|
|
ParagraphStyle(
|
|
name="PdfTableCell",
|
|
fontName=font,
|
|
fontSize=6.5,
|
|
leading=8,
|
|
wordWrap="LTR",
|
|
)
|
|
)
|
|
styles.add(
|
|
ParagraphStyle(
|
|
name="PdfTableHeaderCell",
|
|
fontName=bold_font,
|
|
fontSize=7,
|
|
leading=8.5,
|
|
wordWrap="LTR",
|
|
)
|
|
)
|
|
return styles, font, bold_font
|
|
|
|
|
|
def _table(
|
|
data: List[List[Any]],
|
|
col_widths,
|
|
font: str,
|
|
styles,
|
|
*,
|
|
header_rows: int = 1,
|
|
bold_font: str | None = None,
|
|
wrap_cols: Iterable[int] = (),
|
|
numeric_cols: Iterable[int] = (),
|
|
):
|
|
wrap_set = frozenset(wrap_cols)
|
|
numeric_set = frozenset(numeric_cols)
|
|
cell_style: ParagraphStyle = styles["PdfTableCell"]
|
|
header_style: ParagraphStyle = styles["PdfTableHeaderCell"]
|
|
|
|
processed: List[List[Any]] = []
|
|
for row_idx, row in enumerate(data):
|
|
is_header = row_idx < header_rows
|
|
new_row: List[Any] = []
|
|
for col_idx, val in enumerate(row):
|
|
if col_idx in wrap_set:
|
|
new_row.append(_para(val, header_style if is_header else cell_style))
|
|
elif val is None:
|
|
new_row.append("")
|
|
else:
|
|
new_row.append(str(val))
|
|
processed.append(new_row)
|
|
|
|
tbl = Table(processed, colWidths=col_widths, repeatRows=header_rows, splitByRow=1)
|
|
header_font = bold_font or font
|
|
style_rules: List[Any] = [
|
|
("FONTNAME", (0, 0), (-1, -1), font),
|
|
("FONTSIZE", (0, header_rows), (-1, -1), 7),
|
|
("BACKGROUND", (0, 0), (-1, header_rows - 1), colors.Color(0.82, 0.82, 0.82)),
|
|
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("FONTNAME", (0, 0), (-1, header_rows - 1), header_font),
|
|
("FONTSIZE", (0, 0), (-1, header_rows - 1), 7.5),
|
|
("TEXTCOLOR", (0, 0), (-1, header_rows - 1), colors.black),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
|
("TOPPADDING", (0, 0), (-1, -1), 3),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
|
]
|
|
for col_idx in numeric_set:
|
|
style_rules.append(("ALIGN", (col_idx, header_rows), (col_idx, -1), "RIGHT"))
|
|
tbl.setStyle(TableStyle(style_rules))
|
|
return tbl
|
|
|
|
|
|
def _kpi_block(
|
|
story: List[Any],
|
|
styles,
|
|
font: str,
|
|
bold_font: str,
|
|
summary: dict[str, Any],
|
|
*,
|
|
date_from: str,
|
|
date_to: str,
|
|
recipe_id: str | None,
|
|
recipe_ids: str | None,
|
|
) -> None:
|
|
story.append(Paragraph("Итоги", styles["Heading2"]))
|
|
kpis = [
|
|
("Перерасход", _format_rub(summary.get("overloadRub", 0))),
|
|
("Недогруз", _format_rub(summary.get("underloadRub", 0))),
|
|
("Итого", _format_rub(net_rub_display(summary))),
|
|
]
|
|
kpi_data = [[label, value] for label, value in kpis]
|
|
tbl = Table(kpi_data, colWidths=[45 * mm, 55 * mm])
|
|
tbl.setStyle(
|
|
TableStyle(
|
|
[
|
|
("FONTNAME", (0, 0), (-1, -1), font),
|
|
("FONTSIZE", (0, 0), (0, -1), 8),
|
|
("FONTSIZE", (1, 0), (1, -1), 14),
|
|
("TEXTCOLOR", (0, 0), (0, -1), colors.grey),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]
|
|
)
|
|
)
|
|
story.append(tbl)
|
|
story.append(Paragraph(net_rub_dominant_hint(summary), styles["KpiLabel"]))
|
|
story.append(Spacer(1, 6))
|
|
|
|
top = summary.get("topComponents") or []
|
|
if top:
|
|
story.append(Paragraph("Где больше всего потеряли", styles["Heading3"]))
|
|
top_data = [["Компонент", "Перерасход", "Недогруз", "Итого"]]
|
|
for row in top[:8]:
|
|
top_data.append(
|
|
[
|
|
str(row.get("name") or ""),
|
|
_format_rub_plain(row.get("overloadRub", 0)),
|
|
_format_rub_plain(row.get("underloadRub", 0)),
|
|
_format_rub_plain(row.get("netRub", 0)),
|
|
]
|
|
)
|
|
story.append(
|
|
_table(
|
|
top_data,
|
|
[70 * mm, 35 * mm, 35 * mm, 35 * mm],
|
|
font,
|
|
styles,
|
|
bold_font=bold_font,
|
|
wrap_cols=(0,),
|
|
numeric_cols=(1, 2, 3),
|
|
)
|
|
)
|
|
|
|
dispensers = build_dispenser_summary_rows(
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
recipe_id=recipe_id,
|
|
recipe_ids=recipe_ids,
|
|
)
|
|
if dispensers:
|
|
story.append(Spacer(1, 8))
|
|
story.append(Paragraph("Кормораздатчики: итоговые отклонения", styles["Heading3"]))
|
|
disp_data = [["Кормораздатчик", "Ферма", "Перерасход", "Недогруз", "Итого"]]
|
|
for row in dispensers:
|
|
disp_data.append(
|
|
[
|
|
str(row.get("dispenser") or "—"),
|
|
str(row.get("farm") or "—"),
|
|
_format_rub_plain(row.get("overloadRub", 0)),
|
|
_format_rub_plain(row.get("underloadRub", 0)),
|
|
_format_rub_plain(row.get("netRub", 0)),
|
|
]
|
|
)
|
|
story.append(
|
|
_table(
|
|
disp_data,
|
|
[52 * mm, 48 * mm, 32 * mm, 32 * mm, 32 * mm],
|
|
font,
|
|
styles,
|
|
bold_font=bold_font,
|
|
wrap_cols=(0, 1),
|
|
numeric_cols=(2, 3, 4),
|
|
)
|
|
)
|
|
|
|
|
|
# Ширины «Сравнение» — укладываются в usable width, длинный текст переносится.
|
|
_COMPARISON_COL_WIDTHS = [
|
|
34 * mm, # Рейс
|
|
16 * mm, # Дата
|
|
38 * mm, # Компонент
|
|
16 * mm, # База
|
|
16 * mm, # План
|
|
16 * mm, # Факт
|
|
18 * mm, # Δ База
|
|
18 * mm, # Δ План
|
|
]
|
|
|
|
# «Подробно»: проценты от рабочей ширины — текст переносится, числа не сжимаются.
|
|
_DETAIL_COL_PCT = [5, 15, 16, 14, 10, 11, 11, 9, 9]
|
|
_DETAIL_COL_WIDTHS = _widths_from_percentages(_DETAIL_COL_PCT)
|
|
|
|
|
|
def build_analytics_pdf(
|
|
*,
|
|
date_from: str,
|
|
date_to: str,
|
|
recipe_id: str | None = None,
|
|
recipe_ids: str | None = None,
|
|
section: str | None = None,
|
|
filter_farms: Optional[str] = None,
|
|
filter_dispensers: Optional[str] = None,
|
|
filter_recipes: Optional[str] = None,
|
|
) -> bytes:
|
|
sections = parse_export_sections(section)
|
|
summary = None
|
|
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
|
|
)
|
|
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
|
|
)
|
|
|
|
buf = io.BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buf,
|
|
pagesize=landscape(A4),
|
|
leftMargin=_PAGE_MARGIN,
|
|
rightMargin=_PAGE_MARGIN,
|
|
topMargin=_PAGE_MARGIN,
|
|
bottomMargin=_PAGE_MARGIN,
|
|
)
|
|
styles, font, bold_font = _styles()
|
|
story: List[Any] = []
|
|
section_list = [s for s in ("summary", "comparison", "reports") if s in sections]
|
|
|
|
story.append(Paragraph("Отчёты о загрузках", styles["Title"]))
|
|
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,
|
|
):
|
|
story.append(Paragraph(line, styles["Normal"]))
|
|
story.append(Spacer(1, 8))
|
|
|
|
for idx, key in enumerate(section_list):
|
|
if idx > 0:
|
|
story.append(PageBreak())
|
|
|
|
if key == "summary":
|
|
_kpi_block(
|
|
story,
|
|
styles,
|
|
font,
|
|
bold_font,
|
|
summary or {},
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
recipe_id=recipe_id,
|
|
recipe_ids=recipe_ids,
|
|
)
|
|
|
|
elif key == "comparison":
|
|
story.append(Paragraph("Сравнение", styles["Heading2"]))
|
|
pf_data = [
|
|
[
|
|
"Рейс",
|
|
"Дата",
|
|
"Компонент",
|
|
"База, кг",
|
|
"План, кг",
|
|
"Факт, кг",
|
|
"Δ База, кг",
|
|
"Δ План, кг",
|
|
]
|
|
]
|
|
for item in (plan_fact or {}).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)
|
|
pf_data.append(
|
|
[
|
|
str(item.get("recipeName") or ""),
|
|
str(item.get("date") or ""),
|
|
str(comp.get("name") or ""),
|
|
f"{base_kg:.1f}",
|
|
f"{plan_kg:.1f}",
|
|
f"{fact_kg:.1f}",
|
|
f"{fact_kg - base_kg:.1f}",
|
|
f"{fact_kg - plan_kg:.1f}",
|
|
]
|
|
)
|
|
if len(pf_data) == 1:
|
|
pf_data.append(["—", "", "", "", "", "", "", ""])
|
|
story.append(
|
|
_table(
|
|
pf_data,
|
|
_COMPARISON_COL_WIDTHS,
|
|
font,
|
|
styles,
|
|
bold_font=bold_font,
|
|
wrap_cols=(0, 2),
|
|
numeric_cols=(3, 4, 5, 6, 7),
|
|
)
|
|
)
|
|
|
|
elif key == "reports":
|
|
story.append(Paragraph("Подробно", styles["Heading2"]))
|
|
detail_data = [
|
|
[
|
|
"Дата",
|
|
"Ферма",
|
|
"Рейс",
|
|
"Компонент",
|
|
"Группа",
|
|
"План, кг",
|
|
"Факт, кг",
|
|
"Откл., кг",
|
|
"Перерасх., кг",
|
|
]
|
|
]
|
|
for row in detail_rows or []:
|
|
target = float(row.get("targetKg") or 0)
|
|
actual = float(row.get("actualKg") or 0)
|
|
overload_kg = max(0.0, round(actual - target, 2))
|
|
detail_data.append(
|
|
[
|
|
str(row.get("dateDay") or ""),
|
|
str(row.get("farm") or ""),
|
|
str(row.get("recipeName") or ""),
|
|
str(row.get("feedComponent") or ""),
|
|
str(row.get("animalGroup") or ""),
|
|
f"{target:.1f}",
|
|
f"{actual:.1f}",
|
|
f"{actual - target:.1f}",
|
|
f"{overload_kg:.1f}" if overload_kg > 0 else "",
|
|
]
|
|
)
|
|
if len(detail_data) == 1:
|
|
detail_data.append(["—", "", "", "", "", "", "", "", ""])
|
|
story.append(
|
|
_table(
|
|
detail_data,
|
|
_DETAIL_COL_WIDTHS,
|
|
font,
|
|
styles,
|
|
bold_font=bold_font,
|
|
wrap_cols=(1, 2, 3, 4),
|
|
numeric_cols=(5, 6, 7, 8),
|
|
)
|
|
)
|
|
|
|
doc.build(story)
|
|
return buf.getvalue()
|