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