Files
site/WESP_REL/app/services/analytics/pdf_export.py
T
2026-07-17 12:57:18 +03:00

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()