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