@@ -0,0 +1,3 @@
|
||||
"""Analytics helpers ported from WESP (import submodules directly)."""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Разбор периода для analytics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def parse_iso_date(value: Optional[str]) -> Optional[date]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_date_bounds(
|
||||
date_from: Optional[str],
|
||||
date_to: Optional[str],
|
||||
) -> Tuple[datetime, datetime]:
|
||||
"""Включительно date_from … date_to (конец дня date_to)."""
|
||||
today = date.today()
|
||||
start_d = parse_iso_date(date_from) or today
|
||||
end_d = parse_iso_date(date_to) or start_d
|
||||
if end_d < start_d:
|
||||
start_d, end_d = end_d, start_d
|
||||
start_dt = datetime.combine(start_d, datetime.min.time())
|
||||
end_dt = datetime.combine(end_d, datetime.max.time())
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
def iter_dates_in_range(start_d: date, end_d: date):
|
||||
cur = start_d
|
||||
while cur <= end_d:
|
||||
yield cur
|
||||
cur += timedelta(days=1)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Итоги по кормораздатчикам для PDF/Excel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import LoadingReportComponent
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.finance_summary import _report_filters
|
||||
from app.services.analytics.prices import component_prices, price_for
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
|
||||
|
||||
def build_dispenser_summary_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
reports = db.session.execute(
|
||||
_report_filters(start_dt=start_dt, end_dt=end_dt, recipe_ids=ids)
|
||||
).scalars().all()
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
report_by_id = {r.id: r for r in reports}
|
||||
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
|
||||
farm_by_recipe, dispenser_by_recipe = build_recipe_location_maps(recipe_ids_set)
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
prices = component_prices(
|
||||
component_ids=[c.component_id for c in components if c.component_id],
|
||||
names=[c.component_name for c in components],
|
||||
)
|
||||
|
||||
agg: Dict[str, Dict[str, Any]] = defaultdict(
|
||||
lambda: {
|
||||
"dispenser": "",
|
||||
"farm": "",
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
"netRub": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
for comp in components:
|
||||
report = report_by_id.get(comp.report_id)
|
||||
if not report:
|
||||
continue
|
||||
dispenser = dispenser_by_recipe.get(report.recipe_id, "—")
|
||||
farm = farm_by_recipe.get(report.recipe_id, "—")
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
|
||||
price = price_for(
|
||||
prices=prices,
|
||||
component_id=comp.component_id,
|
||||
name=str(comp.component_name or ""),
|
||||
)
|
||||
dev_rub = (actual - target) * price
|
||||
row = agg[dispenser]
|
||||
row["dispenser"] = dispenser
|
||||
row["farm"] = farm
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
row["netRub"] += dev_rub
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in agg.values():
|
||||
if max(row["overloadRub"], row["underloadRub"]) <= 0:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"dispenser": row["dispenser"],
|
||||
"farm": row["farm"],
|
||||
"overloadRub": round(row["overloadRub"], 2),
|
||||
"underloadRub": round(row["underloadRub"], 2),
|
||||
"netRub": round(row["netRub"], 2),
|
||||
}
|
||||
)
|
||||
|
||||
out.sort(
|
||||
key=lambda r: max(r["overloadRub"], r["underloadRub"]),
|
||||
reverse=True,
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Текст шапки экспорта с применёнными фильтрами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
_NET_DOMINANT_HINTS = {
|
||||
"overload": "Преобладает перерасход — прямой убыток бюджета",
|
||||
"underload": "Преобладает недогруз — экономия сейчас, риск падения надоев",
|
||||
"balanced": "Перерасход и недогруз за период уравновешены",
|
||||
}
|
||||
|
||||
|
||||
def net_rub_display(summary: dict[str, Any]) -> float:
|
||||
"""Итог без знака минус — как на вкладке «Итоги» в интерфейсе."""
|
||||
return round(abs(float(summary.get("netRub") or 0)), 2)
|
||||
|
||||
|
||||
def net_rub_dominant_hint(summary: dict[str, Any]) -> str:
|
||||
return _NET_DOMINANT_HINTS.get(str(summary.get("dominantIssue") or "balanced"), _NET_DOMINANT_HINTS["balanced"])
|
||||
|
||||
|
||||
def _count_label(raw: Optional[str], *, all_label: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text or text.lower() == "all":
|
||||
return all_label
|
||||
parts = [p.strip() for p in text.split(",") if p.strip()]
|
||||
if not parts:
|
||||
return all_label
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return str(len(parts))
|
||||
|
||||
|
||||
def build_export_header_lines(
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
filter_farms: Optional[str] = None,
|
||||
filter_dispensers: Optional[str] = None,
|
||||
filter_recipes: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
period = f"{date_from} — {date_to}"
|
||||
farms = _count_label(filter_farms, all_label="все")
|
||||
dispensers = _count_label(filter_dispensers, all_label="все")
|
||||
recipes = _count_label(filter_recipes, all_label="все")
|
||||
|
||||
lines = [f"Отчёт о загрузках кормов за период {period}."]
|
||||
filter_bits = [f"Фермы: {farms}", f"Кормораздатчики: {dispensers}", f"Рейсы: {recipes}"]
|
||||
lines.append("Выбрано: " + ", ".join(filter_bits) + ".")
|
||||
return lines
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Разделы экспорта analytics: итоги, сравнение, отчёты."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
VALID_EXPORT_SECTIONS = frozenset({"summary", "comparison", "reports"})
|
||||
|
||||
_SECTION_FILENAMES = {
|
||||
"summary": "itogi",
|
||||
"comparison": "sravnenie",
|
||||
"reports": "podrobno",
|
||||
}
|
||||
|
||||
|
||||
def parse_export_sections(section: str | None = None) -> frozenset[str]:
|
||||
if not section or not str(section).strip() or str(section).strip().lower() == "all":
|
||||
return VALID_EXPORT_SECTIONS
|
||||
parts = {p.strip().lower() for p in str(section).split(",") if p.strip()}
|
||||
filtered = parts & VALID_EXPORT_SECTIONS
|
||||
return filtered if filtered else VALID_EXPORT_SECTIONS
|
||||
|
||||
|
||||
def export_filename_base(*, date_from: str, date_to: str, sections: frozenset[str]) -> str:
|
||||
if sections == VALID_EXPORT_SECTIONS:
|
||||
return f"otchety_{date_from}_{date_to}"
|
||||
if len(sections) == 1:
|
||||
key = next(iter(sections))
|
||||
return f"{_SECTION_FILENAMES[key]}_{date_from}_{date_to}"
|
||||
joined = "_".join(sorted(_SECTION_FILENAMES[s] for s in sections))
|
||||
return f"{joined}_{date_from}_{date_to}"
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Итоги в рублях: перерасход, недогруз, топ компонентов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import LoadingReport, LoadingReportComponent
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.prices import component_prices, price_for
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
|
||||
|
||||
def _report_filters(
|
||||
*,
|
||||
start_dt,
|
||||
end_dt,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[list[str]] = None,
|
||||
):
|
||||
q = select(LoadingReport).where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start_dt,
|
||||
LoadingReport.start_time <= end_dt,
|
||||
)
|
||||
ids = recipe_ids or parse_recipe_ids(recipe_id=recipe_id)
|
||||
if ids:
|
||||
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
|
||||
return q.order_by(LoadingReport.start_time.desc())
|
||||
|
||||
|
||||
def build_finance_summary(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
reports = db.session.execute(_report_filters(
|
||||
start_dt=start_dt, end_dt=end_dt, recipe_ids=ids
|
||||
)).scalars().all()
|
||||
if not reports:
|
||||
return {
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
"netRub": 0.0,
|
||||
"dominantIssue": "balanced",
|
||||
"topComponents": [],
|
||||
"reportCount": 0,
|
||||
}
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
comp_ids = [c.component_id for c in components if c.component_id]
|
||||
names = [c.component_name for c in components]
|
||||
prices = component_prices(component_ids=comp_ids, names=names)
|
||||
|
||||
by_component: Dict[str, Dict[str, Any]] = defaultdict(
|
||||
lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0}
|
||||
)
|
||||
overload_total = 0.0
|
||||
underload_total = 0.0
|
||||
|
||||
for comp in components:
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
price = price_for(
|
||||
prices=prices,
|
||||
component_id=comp.component_id,
|
||||
name=str(comp.component_name or ""),
|
||||
)
|
||||
dev_kg = actual - target
|
||||
dev_rub = dev_kg * price
|
||||
key = str(comp.component_id or comp.component_name or "unknown")
|
||||
row = by_component[key]
|
||||
row["name"] = str(comp.component_name or "—")
|
||||
row["componentId"] = comp.component_id
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
overload_total += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
underload_total += abs(dev_rub)
|
||||
row["netRub"] += dev_rub
|
||||
|
||||
top = sorted(
|
||||
by_component.values(),
|
||||
key=lambda x: max(x["overloadRub"], x["underloadRub"]),
|
||||
reverse=True,
|
||||
)[:3]
|
||||
top_out = [
|
||||
{
|
||||
"name": r["name"],
|
||||
"componentId": r.get("componentId"),
|
||||
"overloadRub": round(r["overloadRub"], 2),
|
||||
"underloadRub": round(r["underloadRub"], 2),
|
||||
"netRub": round(r["netRub"], 2),
|
||||
}
|
||||
for r in top
|
||||
if max(r["overloadRub"], r["underloadRub"]) > 0
|
||||
]
|
||||
|
||||
net = overload_total - underload_total
|
||||
if overload_total > underload_total:
|
||||
dominant = "overload"
|
||||
elif underload_total > overload_total:
|
||||
dominant = "underload"
|
||||
else:
|
||||
dominant = "balanced"
|
||||
return {
|
||||
"overloadRub": round(overload_total, 2),
|
||||
"underloadRub": round(underload_total, 2),
|
||||
"netRub": round(net, 2),
|
||||
"dominantIssue": dominant,
|
||||
"topComponents": top_out,
|
||||
"reportCount": len(reports),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Сравнение: по рецепту / на сегодня / сделали."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Component,
|
||||
Ingredient,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Recipe,
|
||||
UnloadingGroup,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan
|
||||
from app.services.daily_plan.skips import get_skipped_ingredient_ids
|
||||
from app.services.recipe_calculator import calculate_recipe
|
||||
|
||||
_EXEC_TOLERANCE_KG = 20.0
|
||||
_MIN_PLAN_KG = 0.05
|
||||
|
||||
|
||||
def _base_kg(recipe: Recipe, ing: Ingredient) -> float:
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
tp = float(recipe.trip_percent or 100) / 100.0
|
||||
return round(float(ing.weight_per_head or 0) * heads * tp, 2)
|
||||
|
||||
|
||||
def _find_trip_in_plan(plan: Dict[str, Any], recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
if trip.get("recipeId") == recipe_id:
|
||||
return trip
|
||||
return None
|
||||
|
||||
|
||||
def _plan_cache() -> Dict[str, Dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_plan_for_date(iso_date: str, cache: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if iso_date not in cache:
|
||||
cache[iso_date] = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=iso_date)
|
||||
return cache[iso_date]
|
||||
|
||||
|
||||
def _match_plan_ingredient(
|
||||
trip: Optional[Dict[str, Any]],
|
||||
*,
|
||||
component_id: Optional[str],
|
||||
component_name: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not trip:
|
||||
return None
|
||||
name_lo = (component_name or "").strip().lower()
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if component_id and ing.get("componentId") == component_id:
|
||||
return ing
|
||||
if ing.get("replacementComponentId") == component_id:
|
||||
return ing
|
||||
orig = str(ing.get("originalName") or ing.get("name") or "").lower()
|
||||
if name_lo and orig == name_lo:
|
||||
return ing
|
||||
return None
|
||||
|
||||
|
||||
def _build_deviation_notes(
|
||||
*,
|
||||
base_kg: float,
|
||||
plan_today_kg: float,
|
||||
actual_kg: float,
|
||||
report_date: date,
|
||||
skipped_today: bool = False,
|
||||
phase: str = "loading",
|
||||
) -> Tuple[List[Dict[str, str]], str]:
|
||||
"""notes[].kind: zootech | execution.
|
||||
|
||||
fault:
|
||||
execution — механизатор отклонился от плана на сегодня;
|
||||
excluded — зоотехник исключил компонент, загрузка совпала с планом (0);
|
||||
adjusted — зоотехник скорректировал план, загрузка совпала с новым планом;
|
||||
none — без отклонений.
|
||||
"""
|
||||
notes: List[Dict[str, str]] = []
|
||||
ds = report_date.strftime("%d.%m")
|
||||
is_unloading = phase == "unloading"
|
||||
overload_label = "перегруз при выгрузке" if is_unloading else "перегруз при загрузке"
|
||||
underload_label = "недогруз при выгрузке" if is_unloading else "недогруз при загрузке"
|
||||
loaded_despite_label = (
|
||||
"выгрузили несмотря на исключение" if is_unloading else "загрузили несмотря на исключение"
|
||||
)
|
||||
plan_ok_label = (
|
||||
"выгрузка по новому плану" if is_unloading else "загрузка по новому плану"
|
||||
)
|
||||
zootech_changed = base_kg > plan_today_kg + _EXEC_TOLERANCE_KG
|
||||
execution_ok = abs(actual_kg - plan_today_kg) <= _EXEC_TOLERANCE_KG
|
||||
fully_excluded = skipped_today and plan_today_kg <= _MIN_PLAN_KG
|
||||
|
||||
if fully_excluded:
|
||||
notes.append(
|
||||
{
|
||||
"text": f"исключено из плана на сегодня ({ds})",
|
||||
"kind": "zootech",
|
||||
}
|
||||
)
|
||||
elif zootech_changed:
|
||||
diff = round(base_kg - plan_today_kg, 1)
|
||||
if execution_ok:
|
||||
text = f"план скорректирован −{diff} кг ({ds}) — {plan_ok_label}"
|
||||
else:
|
||||
text = f"план скорректирован −{diff} кг ({ds})"
|
||||
notes.append({"text": text, "kind": "zootech"})
|
||||
|
||||
if plan_today_kg > _MIN_PLAN_KG and actual_kg > plan_today_kg + _EXEC_TOLERANCE_KG:
|
||||
diff = round(actual_kg - plan_today_kg, 1)
|
||||
notes.append(
|
||||
{
|
||||
"text": f"{overload_label} +{diff} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
elif plan_today_kg > _MIN_PLAN_KG and actual_kg < plan_today_kg - _EXEC_TOLERANCE_KG:
|
||||
diff = round(plan_today_kg - actual_kg, 1)
|
||||
notes.append(
|
||||
{
|
||||
"text": f"{underload_label} −{diff} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
elif fully_excluded and actual_kg > _MIN_PLAN_KG:
|
||||
diff = round(actual_kg, 1)
|
||||
notes.append(
|
||||
{
|
||||
"text": f"{loaded_despite_label} +{diff} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
|
||||
has_execution = any(n["kind"] == "execution" for n in notes)
|
||||
if has_execution:
|
||||
fault = "execution"
|
||||
elif fully_excluded and execution_ok:
|
||||
fault = "excluded"
|
||||
elif zootech_changed and execution_ok:
|
||||
fault = "adjusted"
|
||||
elif zootech_changed:
|
||||
fault = "adjusted"
|
||||
else:
|
||||
fault = "none"
|
||||
|
||||
return notes, fault
|
||||
|
||||
|
||||
def _plan_ing_matches_report_comp(
|
||||
plan_ing: Dict[str, Any],
|
||||
comp: LoadingReportComponent,
|
||||
) -> bool:
|
||||
cid = comp.component_id
|
||||
cname = (comp.component_name or "").strip().lower()
|
||||
pcid = plan_ing.get("componentId")
|
||||
repl = plan_ing.get("replacementComponentId")
|
||||
if cid:
|
||||
sc = str(cid)
|
||||
if pcid and sc == str(pcid):
|
||||
return True
|
||||
if repl and sc == str(repl):
|
||||
return True
|
||||
for key in ("originalName", "name"):
|
||||
plan_name = str(plan_ing.get(key) or "").strip().lower()
|
||||
if cname and plan_name and cname == plan_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _component_row(
|
||||
*,
|
||||
recipe: Recipe,
|
||||
ing: Optional[Ingredient],
|
||||
plan_ing: Optional[Dict[str, Any]],
|
||||
comp: Optional[LoadingReportComponent],
|
||||
report_date: date,
|
||||
skipped_ingredients: set,
|
||||
) -> Dict[str, Any]:
|
||||
cid = comp.component_id if comp else None
|
||||
if plan_ing and not cid:
|
||||
cid = plan_ing.get("replacementComponentId") or plan_ing.get("componentId")
|
||||
cname = str(
|
||||
(comp.component_name if comp else None)
|
||||
or (plan_ing or {}).get("name")
|
||||
or (plan_ing or {}).get("originalName")
|
||||
or (ing.name if ing else None)
|
||||
or "—"
|
||||
)
|
||||
|
||||
base_kg = _base_kg(recipe, ing) if ing else float((comp.target_weight if comp else 0) or 0)
|
||||
skipped_today = False
|
||||
if plan_ing:
|
||||
skipped_today = bool(plan_ing.get("skippedToday"))
|
||||
if skipped_today:
|
||||
plan_today_kg = 0.0
|
||||
base_kg = float(plan_ing.get("baselineTotalKg") or base_kg)
|
||||
else:
|
||||
plan_today_kg = float(plan_ing.get("totalKg") or 0)
|
||||
elif ing and ing.id in skipped_ingredients:
|
||||
skipped_today = True
|
||||
plan_today_kg = 0.0
|
||||
elif comp is not None:
|
||||
plan_today_kg = float(comp.target_weight or base_kg)
|
||||
else:
|
||||
plan_today_kg = 0.0
|
||||
|
||||
actual_kg = float(comp.actual_weight or 0) if comp is not None else 0.0
|
||||
notes, fault = _build_deviation_notes(
|
||||
base_kg=base_kg,
|
||||
plan_today_kg=plan_today_kg,
|
||||
actual_kg=actual_kg,
|
||||
report_date=report_date,
|
||||
skipped_today=skipped_today,
|
||||
)
|
||||
ingredient_id = None
|
||||
if plan_ing:
|
||||
ingredient_id = plan_ing.get("id")
|
||||
elif ing:
|
||||
ingredient_id = ing.id
|
||||
|
||||
return {
|
||||
"componentId": cid,
|
||||
"ingredientId": ingredient_id,
|
||||
"name": cname,
|
||||
"baseKg": round(base_kg, 2),
|
||||
"planTodayKg": round(plan_today_kg, 2),
|
||||
"actualKg": round(actual_kg, 2),
|
||||
"skippedToday": skipped_today,
|
||||
"loadingOrder": int(comp.loading_order or 0) if comp is not None else None,
|
||||
"notes": notes,
|
||||
"fault": fault,
|
||||
}
|
||||
|
||||
|
||||
def _match_plan_unloading_group(
|
||||
trip: Optional[Dict[str, Any]],
|
||||
*,
|
||||
group_id: Optional[str],
|
||||
group_name: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not trip:
|
||||
return None
|
||||
name_lo = (group_name or "").strip().lower()
|
||||
for grp in trip.get("unloadingGroups") or []:
|
||||
if group_id and grp.get("id") == group_id:
|
||||
return grp
|
||||
grp_name = str(grp.get("name") or "").strip().lower()
|
||||
if name_lo and grp_name == name_lo:
|
||||
return grp
|
||||
return None
|
||||
|
||||
|
||||
def _baseline_unloading_weights(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
cache: Dict[str, Dict[str, float]],
|
||||
) -> Dict[str, float]:
|
||||
rid = recipe.id
|
||||
if rid in cache:
|
||||
return cache[rid]
|
||||
|
||||
groups = list(
|
||||
db.session.execute(
|
||||
select(UnloadingGroup)
|
||||
.where(UnloadingGroup.recipe_id == rid, UnloadingGroup.is_deleted.is_(False))
|
||||
.order_by(UnloadingGroup.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
if not groups:
|
||||
cache[rid] = {}
|
||||
return cache[rid]
|
||||
|
||||
ingredients = list(
|
||||
db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == rid, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
comp_ids = [str(i.component_id) for i in ingredients if i.component_id]
|
||||
components = (
|
||||
db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(set(comp_ids)),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
if comp_ids
|
||||
else []
|
||||
)
|
||||
dm_map = {str(c.id): float(c.dry_matter or 0) for c in components}
|
||||
calc_inputs = [
|
||||
{
|
||||
"weightPerHead": float(ing.weight_per_head or 0),
|
||||
"dryMatter": float(ing.dry_matter or dm_map.get(str(ing.component_id or ""), 0) or 0),
|
||||
"component_id": ing.component_id,
|
||||
}
|
||||
for ing in ingredients
|
||||
]
|
||||
calc_groups = [
|
||||
{
|
||||
"distributionType": g.distribution_type or "percent",
|
||||
"value": float(g.value or 0),
|
||||
}
|
||||
for g in groups
|
||||
]
|
||||
result = calculate_recipe(
|
||||
calc_inputs,
|
||||
heads_count=int(recipe.heads_per_trip or 0),
|
||||
trip_percent=float(recipe.trip_percent or 100),
|
||||
unloading_groups=calc_groups,
|
||||
component_dry_matter_map=dm_map,
|
||||
)
|
||||
calc_unloading = result.get("unloadingGroups") or []
|
||||
out: Dict[str, float] = {}
|
||||
for idx, grp in enumerate(groups):
|
||||
if idx < len(calc_unloading):
|
||||
out[grp.id] = float(calc_unloading[idx].get("calculatedWeight") or 0)
|
||||
cache[rid] = out
|
||||
return out
|
||||
|
||||
|
||||
def _unloading_group_row(
|
||||
*,
|
||||
plan_grp: Optional[Dict[str, Any]],
|
||||
report_grp: Optional[UnloadingReportGroup],
|
||||
base_kg: float,
|
||||
report_date: date,
|
||||
) -> Dict[str, Any]:
|
||||
gid = plan_grp.get("id") if plan_grp else None
|
||||
|
||||
name = str(
|
||||
(report_grp.name if report_grp is not None else None)
|
||||
or (plan_grp or {}).get("name")
|
||||
or "—"
|
||||
)
|
||||
skipped_today = bool((plan_grp or {}).get("skippedToday"))
|
||||
if plan_grp is not None:
|
||||
plan_today_kg = 0.0 if skipped_today else float(plan_grp.get("weightKg") or 0)
|
||||
elif report_grp is not None:
|
||||
plan_today_kg = float(report_grp.target_weight or base_kg)
|
||||
else:
|
||||
plan_today_kg = 0.0
|
||||
|
||||
actual_kg = float(report_grp.unloaded_weight or 0) if report_grp is not None else 0.0
|
||||
notes, fault = _build_deviation_notes(
|
||||
base_kg=base_kg,
|
||||
plan_today_kg=plan_today_kg,
|
||||
actual_kg=actual_kg,
|
||||
report_date=report_date,
|
||||
skipped_today=skipped_today,
|
||||
phase="unloading",
|
||||
)
|
||||
return {
|
||||
"groupId": gid,
|
||||
"name": name,
|
||||
"baseKg": round(base_kg, 2),
|
||||
"planTodayKg": round(plan_today_kg, 2),
|
||||
"actualKg": round(actual_kg, 2),
|
||||
"skippedToday": skipped_today,
|
||||
"unloadingOrder": int(report_grp.order or 0) if report_grp is not None else None,
|
||||
"notes": notes,
|
||||
"fault": fault,
|
||||
}
|
||||
|
||||
|
||||
def _mixer_remainder_row(
|
||||
*,
|
||||
remaining_kg: float,
|
||||
report_date: date,
|
||||
) -> Dict[str, Any]:
|
||||
base_kg = 0.0
|
||||
plan_today_kg = 0.0
|
||||
actual_kg = float(remaining_kg or 0)
|
||||
notes: List[Dict[str, str]] = []
|
||||
fault = "none"
|
||||
if abs(actual_kg) > _EXEC_TOLERANCE_KG:
|
||||
ds = report_date.strftime("%d.%m")
|
||||
notes.append(
|
||||
{
|
||||
"text": f"остаток в миксере {round(actual_kg, 1):+g} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
fault = "execution"
|
||||
return {
|
||||
"name": "Остаток в миксере",
|
||||
"baseKg": round(base_kg, 2),
|
||||
"planTodayKg": round(plan_today_kg, 2),
|
||||
"actualKg": round(actual_kg, 2),
|
||||
"notes": notes,
|
||||
"fault": fault,
|
||||
}
|
||||
|
||||
|
||||
def _build_unloading_section(
|
||||
*,
|
||||
trip: Optional[Dict[str, Any]],
|
||||
unloading_report: Optional[UnloadingReport],
|
||||
report_groups: List[UnloadingReportGroup],
|
||||
baseline_by_group: Dict[str, float],
|
||||
report_date: date,
|
||||
) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
matched_plan_ids: set = set()
|
||||
groups_out: List[Dict[str, Any]] = []
|
||||
|
||||
for report_grp in report_groups:
|
||||
plan_grp = _match_plan_unloading_group(
|
||||
trip,
|
||||
group_id=None,
|
||||
group_name=str(report_grp.name or ""),
|
||||
)
|
||||
if plan_grp and plan_grp.get("id"):
|
||||
matched_plan_ids.add(plan_grp["id"])
|
||||
base_kg = float(baseline_by_group.get(plan_grp.get("id") if plan_grp else "", 0) or 0)
|
||||
if base_kg <= 0 and report_grp is not None:
|
||||
base_kg = float(report_grp.target_weight or 0)
|
||||
groups_out.append(
|
||||
_unloading_group_row(
|
||||
plan_grp=plan_grp,
|
||||
report_grp=report_grp,
|
||||
base_kg=base_kg,
|
||||
report_date=report_date,
|
||||
)
|
||||
)
|
||||
|
||||
if trip:
|
||||
report_names_lo = {str(g.name or "").strip().lower() for g in report_groups}
|
||||
for plan_grp in trip.get("unloadingGroups") or []:
|
||||
if not plan_grp.get("skippedToday"):
|
||||
continue
|
||||
pid = plan_grp.get("id")
|
||||
if pid and pid in matched_plan_ids:
|
||||
continue
|
||||
pname = str(plan_grp.get("name") or "").strip().lower()
|
||||
if pname and pname in report_names_lo:
|
||||
continue
|
||||
if pid:
|
||||
matched_plan_ids.add(pid)
|
||||
base_kg = float(baseline_by_group.get(pid or "", 0) or 0)
|
||||
groups_out.append(
|
||||
_unloading_group_row(
|
||||
plan_grp=plan_grp,
|
||||
report_grp=None,
|
||||
base_kg=base_kg,
|
||||
report_date=report_date,
|
||||
)
|
||||
)
|
||||
if not report_groups:
|
||||
for plan_grp in trip.get("unloadingGroups") or []:
|
||||
pid = plan_grp.get("id")
|
||||
if pid and pid in matched_plan_ids:
|
||||
continue
|
||||
if pid:
|
||||
matched_plan_ids.add(pid)
|
||||
base_kg = float(baseline_by_group.get(pid or "", 0) or 0)
|
||||
groups_out.append(
|
||||
_unloading_group_row(
|
||||
plan_grp=plan_grp,
|
||||
report_grp=None,
|
||||
base_kg=base_kg,
|
||||
report_date=report_date,
|
||||
)
|
||||
)
|
||||
|
||||
def _sort_key(row: Dict[str, Any]) -> tuple:
|
||||
order = row.get("unloadingOrder")
|
||||
if order is not None:
|
||||
return (order, row.get("name") or "")
|
||||
return (10_000, row.get("name") or "")
|
||||
|
||||
groups_out.sort(key=_sort_key)
|
||||
|
||||
mixer = None
|
||||
if unloading_report is not None:
|
||||
mixer = _mixer_remainder_row(
|
||||
remaining_kg=float(unloading_report.remaining_weight or 0),
|
||||
report_date=report_date,
|
||||
)
|
||||
return groups_out, mixer
|
||||
|
||||
|
||||
def build_plan_fact_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
q = select(LoadingReport).where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start_dt,
|
||||
LoadingReport.start_time <= end_dt,
|
||||
)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
if ids:
|
||||
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
|
||||
if client_id:
|
||||
q = q.where(LoadingReport.client_id == client_id)
|
||||
reports = db.session.execute(q.order_by(LoadingReport.start_time.desc())).scalars().all()
|
||||
if not reports:
|
||||
return {"items": [], "reportCount": 0}
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
comp_rows = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
).order_by(LoadingReportComponent.loading_order.asc())
|
||||
).scalars().all()
|
||||
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
|
||||
for c in comp_rows:
|
||||
comps_by_report.setdefault(c.report_id, []).append(c)
|
||||
|
||||
recipe_ids = {r.recipe_id for r in reports}
|
||||
recipes = {
|
||||
r.id: r
|
||||
for r in db.session.execute(
|
||||
select(Recipe).where(Recipe.id.in_(recipe_ids), Recipe.is_deleted.is_(False))
|
||||
).scalars().all()
|
||||
}
|
||||
ingredients_by_recipe: Dict[str, List[Ingredient]] = {}
|
||||
for rid in recipe_ids:
|
||||
ingredients_by_recipe[rid] = list(
|
||||
db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == rid, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
unloading_reports = db.session.execute(
|
||||
select(UnloadingReport).where(
|
||||
UnloadingReport.loading_report_id.in_(report_ids),
|
||||
UnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
unloading_by_loading: Dict[str, UnloadingReport] = {
|
||||
ur.loading_report_id: ur for ur in unloading_reports
|
||||
}
|
||||
unloading_report_ids = [ur.id for ur in unloading_reports]
|
||||
unloading_groups_rows: List[UnloadingReportGroup] = []
|
||||
if unloading_report_ids:
|
||||
unloading_groups_rows = list(
|
||||
db.session.execute(
|
||||
select(UnloadingReportGroup)
|
||||
.where(
|
||||
UnloadingReportGroup.report_id.in_(unloading_report_ids),
|
||||
UnloadingReportGroup.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(UnloadingReportGroup.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
groups_by_unloading: Dict[str, List[UnloadingReportGroup]] = {}
|
||||
for grp in unloading_groups_rows:
|
||||
groups_by_unloading.setdefault(grp.report_id, []).append(grp)
|
||||
|
||||
plan_cache: Dict[str, Dict[str, Any]] = {}
|
||||
skip_cache: Dict[str, Dict[str, set]] = {}
|
||||
baseline_unloading_cache: Dict[str, Dict[str, float]] = {}
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
for report in reports:
|
||||
recipe = recipes.get(report.recipe_id)
|
||||
if recipe is None:
|
||||
continue
|
||||
report_date = report.start_time.date() if report.start_time else date.today()
|
||||
iso = report_date.isoformat()
|
||||
plan = _get_plan_for_date(iso, plan_cache)
|
||||
if iso not in skip_cache:
|
||||
skip_cache[iso] = get_skipped_ingredient_ids(iso)
|
||||
skipped_ingredients = skip_cache[iso].get(report.recipe_id, set())
|
||||
trip = _find_trip_in_plan(plan, report.recipe_id)
|
||||
|
||||
ing_by_cid: Dict[str, Ingredient] = {}
|
||||
ing_by_name: Dict[str, Ingredient] = {}
|
||||
ing_by_id: Dict[str, Ingredient] = {}
|
||||
for ing in ingredients_by_recipe.get(report.recipe_id, []):
|
||||
ing_by_id[ing.id] = ing
|
||||
if ing.component_id:
|
||||
ing_by_cid[str(ing.component_id)] = ing
|
||||
ing_by_name[(ing.name or "").strip().lower()] = ing
|
||||
|
||||
report_comps = comps_by_report.get(report.id, [])
|
||||
matched_plan_ing_ids: set = set()
|
||||
components_out: List[Dict[str, Any]] = []
|
||||
|
||||
for comp in report_comps:
|
||||
cid = comp.component_id
|
||||
cname = str(comp.component_name or "—")
|
||||
ing = None
|
||||
if cid and str(cid) in ing_by_cid:
|
||||
ing = ing_by_cid[str(cid)]
|
||||
elif cname.lower() in ing_by_name:
|
||||
ing = ing_by_name[cname.lower()]
|
||||
|
||||
plan_ing = _match_plan_ingredient(trip, component_id=cid, component_name=cname)
|
||||
if plan_ing and plan_ing.get("id"):
|
||||
matched_plan_ing_ids.add(plan_ing["id"])
|
||||
|
||||
components_out.append(
|
||||
_component_row(
|
||||
recipe=recipe,
|
||||
ing=ing,
|
||||
plan_ing=plan_ing,
|
||||
comp=comp,
|
||||
report_date=report_date,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
)
|
||||
)
|
||||
|
||||
if trip:
|
||||
for plan_ing in trip.get("ingredients") or []:
|
||||
if not plan_ing.get("skippedToday"):
|
||||
continue
|
||||
pid = plan_ing.get("id")
|
||||
if pid and pid in matched_plan_ing_ids:
|
||||
continue
|
||||
if any(_plan_ing_matches_report_comp(plan_ing, c) for c in report_comps):
|
||||
continue
|
||||
|
||||
ing = ing_by_id.get(pid) if pid else None
|
||||
if ing is None:
|
||||
pcid = plan_ing.get("componentId")
|
||||
if pcid and str(pcid) in ing_by_cid:
|
||||
ing = ing_by_cid[str(pcid)]
|
||||
else:
|
||||
name_lo = str(
|
||||
plan_ing.get("originalName") or plan_ing.get("name") or ""
|
||||
).strip().lower()
|
||||
ing = ing_by_name.get(name_lo)
|
||||
|
||||
if pid:
|
||||
matched_plan_ing_ids.add(pid)
|
||||
components_out.append(
|
||||
_component_row(
|
||||
recipe=recipe,
|
||||
ing=ing,
|
||||
plan_ing=plan_ing,
|
||||
comp=None,
|
||||
report_date=report_date,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
)
|
||||
)
|
||||
|
||||
recipe_ings = ingredients_by_recipe.get(report.recipe_id, [])
|
||||
ing_order = {ing.id: idx for idx, ing in enumerate(recipe_ings)}
|
||||
|
||||
def _sort_key(row: Dict[str, Any]) -> tuple:
|
||||
iid = row.get("ingredientId")
|
||||
if iid and iid in ing_order:
|
||||
return (ing_order[iid], 0)
|
||||
lor = row.get("loadingOrder")
|
||||
if lor is not None:
|
||||
return (10_000, lor)
|
||||
return (20_000, row.get("name") or "")
|
||||
|
||||
components_out.sort(key=_sort_key)
|
||||
|
||||
unloading_report = unloading_by_loading.get(report.id)
|
||||
report_unloading_groups = (
|
||||
groups_by_unloading.get(unloading_report.id, []) if unloading_report else []
|
||||
)
|
||||
baseline_by_group = _baseline_unloading_weights(
|
||||
recipe,
|
||||
cache=baseline_unloading_cache,
|
||||
)
|
||||
unloading_out, mixer_out = _build_unloading_section(
|
||||
trip=trip,
|
||||
unloading_report=unloading_report,
|
||||
report_groups=report_unloading_groups,
|
||||
baseline_by_group=baseline_by_group,
|
||||
report_date=report_date,
|
||||
)
|
||||
|
||||
items.append(
|
||||
{
|
||||
"loadingReportId": report.id,
|
||||
"recipeId": report.recipe_id,
|
||||
"recipeName": report.recipe_name,
|
||||
"date": iso,
|
||||
"startTime": report.start_time.isoformat() if report.start_time else None,
|
||||
"clientId": report.client_id,
|
||||
"components": components_out,
|
||||
"unloadingGroups": unloading_out,
|
||||
"mixerRemainder": mixer_out,
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": items, "reportCount": len(items)}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Цены компонентов для analytics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component
|
||||
|
||||
|
||||
def component_prices(
|
||||
*,
|
||||
component_ids: Optional[List[str]] = None,
|
||||
names: Optional[List[str]] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""component_id или имя -> price руб/кг."""
|
||||
prices: Dict[str, float] = {}
|
||||
ids = [x for x in (component_ids or []) if x]
|
||||
if ids:
|
||||
rows = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(ids),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
prices[row.id] = float(row.price or 0)
|
||||
for name in names or []:
|
||||
if not name or name in prices:
|
||||
continue
|
||||
row = db.session.execute(
|
||||
select(Component)
|
||||
.where(Component.name == name, Component.is_deleted.is_(False))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
prices[name] = float(row.price or 0)
|
||||
return prices
|
||||
|
||||
|
||||
def price_for(*, prices: Dict[str, float], component_id: Optional[str], name: str) -> float:
|
||||
if component_id and component_id in prices:
|
||||
return prices[component_id]
|
||||
return prices.get(name, 0.0)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Фильтрация отчётов analytics по recipe_id / recipe_ids."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def parse_recipe_ids(
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> Optional[List[str]]:
|
||||
if recipe_id and str(recipe_id).strip():
|
||||
return [str(recipe_id).strip()]
|
||||
if not recipe_ids:
|
||||
return None
|
||||
ids = [part.strip() for part in str(recipe_ids).split(",") if part.strip()]
|
||||
return ids or None
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Ферма и кормораздатчик по recipe_id для analytics export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import FeedDispenser, FeedingPeriod, PeriodRecipe
|
||||
|
||||
|
||||
def build_recipe_location_maps(
|
||||
recipe_ids: Iterable[str],
|
||||
) -> tuple[Dict[str, str], Dict[str, str]]:
|
||||
"""recipe_id -> farm, recipe_id -> dispenser_name."""
|
||||
ids = {str(rid) for rid in recipe_ids if rid}
|
||||
farm_by_recipe: Dict[str, str] = {}
|
||||
dispenser_by_recipe: Dict[str, str] = {}
|
||||
if not ids:
|
||||
return farm_by_recipe, dispenser_by_recipe
|
||||
|
||||
rows = db.session.execute(
|
||||
select(PeriodRecipe.recipe_id, FeedDispenser.farm, FeedDispenser.name)
|
||||
.join(FeedingPeriod, FeedingPeriod.id == PeriodRecipe.period_id)
|
||||
.join(FeedDispenser, FeedDispenser.id == FeedingPeriod.dispenser_id)
|
||||
.where(
|
||||
PeriodRecipe.recipe_id.in_(ids),
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
).all()
|
||||
|
||||
for recipe_id, farm, disp_name in rows:
|
||||
rid = str(recipe_id)
|
||||
if rid not in farm_by_recipe and farm:
|
||||
farm_by_recipe[rid] = str(farm).strip()
|
||||
if rid not in dispenser_by_recipe and disp_name:
|
||||
dispenser_by_recipe[rid] = str(disp_name).strip()
|
||||
return farm_by_recipe, dispenser_by_recipe
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Детальные строки отчётов загрузки для экспорта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
ComponentLoadingTime,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
|
||||
UNLOADING_FEED_COMPONENT_LABEL = "Выгрузка полного миксера"
|
||||
_UNLOADING_NAME_PREFIX = "выгрузка:"
|
||||
|
||||
|
||||
def _parse_unloading_group_name(raw: str) -> str:
|
||||
text = (raw or "—").strip()
|
||||
if text.lower().startswith(_UNLOADING_NAME_PREFIX):
|
||||
parsed = text[len(_UNLOADING_NAME_PREFIX) :].strip()
|
||||
return parsed or "—"
|
||||
return text or "—"
|
||||
|
||||
|
||||
def _overload_kg(actual: float, target: float) -> float:
|
||||
"""Перерасход только при факте выше плана; недогруз не дублируем в эту колонку."""
|
||||
return round(max(0.0, actual - target), 2)
|
||||
|
||||
|
||||
def _dedupe_detail_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Убрать полные дубликаты строк (одинаковый рейс, время, компонент, план и факт)."""
|
||||
seen: set[tuple[Any, ...]] = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
key = (
|
||||
row.get("reportId"),
|
||||
row.get("kind"),
|
||||
row.get("feedComponent"),
|
||||
row.get("animalGroup"),
|
||||
row.get("dateTime"),
|
||||
row.get("recipeName"),
|
||||
row.get("targetKg"),
|
||||
row.get("actualKg"),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def build_reports_detail_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
q = select(LoadingReport).where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start_dt,
|
||||
LoadingReport.start_time <= end_dt,
|
||||
)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
if ids:
|
||||
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
|
||||
reports = db.session.execute(q.order_by(LoadingReport.start_time.desc())).scalars().all()
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
|
||||
farm_by_recipe, dispenser_by_recipe = build_recipe_location_maps(recipe_ids_set)
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
).order_by(LoadingReportComponent.loading_order.asc())
|
||||
).scalars().all()
|
||||
loading_times = db.session.execute(
|
||||
select(ComponentLoadingTime).where(
|
||||
ComponentLoadingTime.report_id.in_(report_ids),
|
||||
ComponentLoadingTime.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
unloading_rows = db.session.execute(
|
||||
select(UnloadingReport).where(
|
||||
UnloadingReport.loading_report_id.in_(report_ids),
|
||||
UnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
|
||||
for c in components:
|
||||
comps_by_report.setdefault(c.report_id, []).append(c)
|
||||
|
||||
load_sec_by_report_name: Dict[str, Dict[str, float]] = {}
|
||||
for t in loading_times:
|
||||
if not t.report_id or not t.component_name:
|
||||
continue
|
||||
load_sec_by_report_name.setdefault(t.report_id, {})[
|
||||
str(t.component_name)
|
||||
] = float(t.loading_duration or 0)
|
||||
|
||||
unloading_by_loading = {u.loading_report_id: u for u in unloading_rows}
|
||||
unloading_ids = [u.id for u in unloading_rows]
|
||||
unloading_groups: List[UnloadingReportGroup] = []
|
||||
if unloading_ids:
|
||||
unloading_groups = list(
|
||||
db.session.execute(
|
||||
select(UnloadingReportGroup).where(
|
||||
UnloadingReportGroup.report_id.in_(unloading_ids),
|
||||
UnloadingReportGroup.is_deleted.is_(False),
|
||||
).order_by(UnloadingReportGroup.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
groups_by_unloading: Dict[str, List[UnloadingReportGroup]] = {}
|
||||
for g in unloading_groups:
|
||||
groups_by_unloading.setdefault(g.report_id, []).append(g)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for report in reports:
|
||||
when = report.start_time.strftime("%d.%m.%Y %H:%M") if report.start_time else ""
|
||||
date_day = report.start_time.strftime("%d.%m.%Y") if report.start_time else ""
|
||||
farm = farm_by_recipe.get(report.recipe_id, "—")
|
||||
dispenser = dispenser_by_recipe.get(report.recipe_id, "—")
|
||||
mix_plan = int(report.target_mixing_time or 0)
|
||||
mix_fact = int(report.actual_mixing_time or 0)
|
||||
load_secs = load_sec_by_report_name.get(report.id, {})
|
||||
|
||||
for comp in comps_by_report.get(report.id, []):
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
dev = round(actual - target, 2)
|
||||
cname = str(comp.component_name or "—")
|
||||
rows.append(
|
||||
{
|
||||
"kind": "component",
|
||||
"reportId": report.id,
|
||||
"dateDay": date_day,
|
||||
"farm": farm,
|
||||
"dispenserName": dispenser,
|
||||
"recipeName": report.recipe_name,
|
||||
"dateTime": when,
|
||||
"feedComponent": cname,
|
||||
"animalGroup": "",
|
||||
"name": cname,
|
||||
"targetKg": round(target, 2),
|
||||
"actualKg": round(actual, 2),
|
||||
"deviationKg": dev,
|
||||
"overloadKg": _overload_kg(actual, target),
|
||||
"loadingSec": round(load_secs.get(cname, 0), 1),
|
||||
"mixPlanSec": mix_plan,
|
||||
"mixFactSec": mix_fact,
|
||||
}
|
||||
)
|
||||
|
||||
unloading = unloading_by_loading.get(report.id)
|
||||
if unloading:
|
||||
for group in groups_by_unloading.get(unloading.id, []):
|
||||
target = float(group.target_weight or 0)
|
||||
actual = float(group.unloaded_weight or 0)
|
||||
group_name = _parse_unloading_group_name(str(group.name or ""))
|
||||
rows.append(
|
||||
{
|
||||
"kind": "unloading",
|
||||
"reportId": report.id,
|
||||
"dateDay": date_day,
|
||||
"farm": farm,
|
||||
"dispenserName": dispenser,
|
||||
"recipeName": report.recipe_name,
|
||||
"dateTime": when,
|
||||
"feedComponent": UNLOADING_FEED_COMPONENT_LABEL,
|
||||
"animalGroup": group_name,
|
||||
"name": f"выгрузка: {group_name}",
|
||||
"targetKg": round(target, 2),
|
||||
"actualKg": round(actual, 2),
|
||||
"deviationKg": round(actual - target, 2),
|
||||
"overloadKg": _overload_kg(actual, target),
|
||||
"loadingSec": None,
|
||||
"mixPlanSec": mix_plan,
|
||||
"mixFactSec": mix_fact,
|
||||
}
|
||||
)
|
||||
|
||||
rows = _dedupe_detail_rows(rows)
|
||||
rows.sort(key=lambda r: (r.get("dateDay") or "", r.get("recipeName") or "", r.get("name") or ""))
|
||||
return rows
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Прогноз запаса: хватит по рецепту vs с учётом плана и факта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app import db
|
||||
from app.models import Ingredient, LoadingReport, LoadingReportComponent, Recipe
|
||||
from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan
|
||||
from app.services.sklad_metrics import (
|
||||
consumed_by_component_id,
|
||||
list_stock_balance_items,
|
||||
stock_item_key,
|
||||
)
|
||||
|
||||
_CRITICAL_DAYS = 2.0
|
||||
|
||||
|
||||
def _plan_kg_by_component(plan_date: str) -> Dict[str, float]:
|
||||
plan = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=plan_date)
|
||||
out: Dict[str, float] = {}
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
cid = ing.get("replacementComponentId") or ing.get("componentId")
|
||||
if not cid:
|
||||
continue
|
||||
out[str(cid)] = out.get(str(cid), 0.0) + float(ing.get("totalKg") or 0)
|
||||
return {k: round(v, 2) for k, v in out.items()}
|
||||
|
||||
|
||||
def format_days_label(days: Optional[float]) -> str:
|
||||
if days is None:
|
||||
return "—"
|
||||
if days < 1:
|
||||
hours = round(days * 24)
|
||||
return f"~{hours} ч"
|
||||
d = round(days, 1)
|
||||
if d == int(d):
|
||||
return f"~{int(d)} дн"
|
||||
return f"~{d} дн"
|
||||
|
||||
|
||||
def _days_left(remaining_kg: float, daily_kg: float) -> Optional[float]:
|
||||
if daily_kg <= 0 or remaining_kg <= 0:
|
||||
return None
|
||||
d = remaining_kg / daily_kg
|
||||
return round(min(d, 9999.9), 1) if d < 9999.9 else 9999.9
|
||||
|
||||
|
||||
def enrich_stock_balance_item(
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
plan_by_cid: Dict[str, float],
|
||||
consumed_period: Dict[str, float],
|
||||
lookback_days: int,
|
||||
plan_d: date,
|
||||
) -> Tuple[Dict[str, Any], Optional[str]]:
|
||||
"""Добавляет прогноз «хватит с учётом плана» к строке склада."""
|
||||
cid = str(item.get("component_id") or "").strip()
|
||||
name = str(item.get("name") or "—")
|
||||
remaining = float(item.get("remaining_kg") or 0)
|
||||
plan_recipe = float(item.get("planned_consumption_per_day_kg") or 0)
|
||||
plan_today = plan_by_cid.get(cid, plan_recipe) if cid else plan_recipe
|
||||
period_kg = consumed_period.get(cid, 0.0) if cid else 0.0
|
||||
if period_kg <= 0:
|
||||
period_kg = float(item.get("consumed_total_kg") or item.get("consumed_kg") or 0)
|
||||
avg_daily = round(period_kg / max(1, lookback_days), 2) if period_kg > 0 else 0.0
|
||||
|
||||
if avg_daily > 0:
|
||||
expected_daily = max(avg_daily, plan_today)
|
||||
else:
|
||||
expected_daily = plan_today if plan_today > 0 else plan_recipe
|
||||
|
||||
days_recipe = item.get("days_left_plan")
|
||||
days_adjusted = _days_left(remaining, expected_daily)
|
||||
|
||||
explanation_parts: List[str] = []
|
||||
if plan_today < plan_recipe - 0.01 and plan_recipe > 0:
|
||||
explanation_parts.append(
|
||||
f"убрали из плана на {plan_d.strftime('%d.%m')} → расход меньше на "
|
||||
f"{round(plan_recipe - plan_today, 1)} кг/сут"
|
||||
)
|
||||
if avg_daily > plan_recipe * 1.05 and plan_recipe > 0:
|
||||
explanation_parts.append(
|
||||
f"расход выше плана: ~{avg_daily} кг/сут за {lookback_days} дн"
|
||||
)
|
||||
|
||||
explanation = ""
|
||||
if explanation_parts:
|
||||
base = " · ".join(explanation_parts)
|
||||
if days_recipe is not None and days_adjusted is not None and days_adjusted != days_recipe:
|
||||
delta = round(days_adjusted - days_recipe, 1)
|
||||
sign = "+" if delta > 0 else ""
|
||||
explanation = f"{base} → запас {sign}{delta} дн"
|
||||
else:
|
||||
explanation = base
|
||||
|
||||
row = dict(item)
|
||||
row.update(
|
||||
{
|
||||
"forecast_key": stock_item_key(item),
|
||||
"planTodayKgPerDay": round(plan_today, 2),
|
||||
"avgConsumptionKgPerDay": avg_daily,
|
||||
"expectedDailyKg": round(expected_daily, 2),
|
||||
"days_left_adjusted": days_adjusted,
|
||||
"days_left_adjusted_label": format_days_label(days_adjusted),
|
||||
"daysLeftAdjusted": days_adjusted,
|
||||
"daysLeftAdjustedLabel": format_days_label(days_adjusted),
|
||||
"daysLeftRecipeLabel": format_days_label(days_recipe),
|
||||
"explanation": explanation,
|
||||
"removedFromPlanToday": plan_today < plan_recipe - 0.01 and plan_recipe > 0,
|
||||
}
|
||||
)
|
||||
|
||||
alert: Optional[str] = None
|
||||
if days_adjusted is not None and days_adjusted <= _CRITICAL_DAYS:
|
||||
alert = f"{name} — хватит {format_days_label(days_adjusted)}"
|
||||
elif row["removedFromPlanToday"] and days_adjusted and days_recipe:
|
||||
if days_adjusted > days_recipe + 0.3:
|
||||
alert = (
|
||||
f"{name} — запас {format_days_label(days_adjusted)} "
|
||||
f"(убрали из плана на сегодня)"
|
||||
)
|
||||
return row, alert
|
||||
|
||||
|
||||
def enrich_stock_balance_items(
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
plan_date: Optional[str] = None,
|
||||
lookback_days: int = 7,
|
||||
) -> List[Dict[str, Any]]:
|
||||
today = date.today()
|
||||
iso = (plan_date or today.isoformat())[:10]
|
||||
try:
|
||||
plan_d = date.fromisoformat(iso)
|
||||
except ValueError:
|
||||
plan_d = today
|
||||
iso = today.isoformat()
|
||||
|
||||
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
|
||||
to_d = plan_d.isoformat()
|
||||
plan_by_cid = _plan_kg_by_component(iso)
|
||||
consumed_period = consumed_by_component_id(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d
|
||||
)
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
row, _ = enrich_stock_balance_item(
|
||||
item,
|
||||
plan_by_cid=plan_by_cid,
|
||||
consumed_period=consumed_period,
|
||||
lookback_days=lookback_days,
|
||||
plan_d=plan_d,
|
||||
)
|
||||
enriched.append(row)
|
||||
return enriched
|
||||
|
||||
|
||||
def build_stock_forecast(
|
||||
*,
|
||||
plan_date: Optional[str] = None,
|
||||
lookback_days: int = 7,
|
||||
Component=None,
|
||||
) -> Dict[str, Any]:
|
||||
today = date.today()
|
||||
iso = (plan_date or today.isoformat())[:10]
|
||||
try:
|
||||
plan_d = date.fromisoformat(iso)
|
||||
except ValueError:
|
||||
plan_d = today
|
||||
iso = today.isoformat()
|
||||
|
||||
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
|
||||
to_d = plan_d.isoformat()
|
||||
|
||||
items = list_stock_balance_items(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
date_from=from_d,
|
||||
date_to=to_d,
|
||||
Component=Component,
|
||||
)
|
||||
plan_by_cid = _plan_kg_by_component(iso)
|
||||
consumed_period = consumed_by_component_id(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d
|
||||
)
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
alerts: List[str] = []
|
||||
|
||||
for item in items:
|
||||
row, alert = enrich_stock_balance_item(
|
||||
item,
|
||||
plan_by_cid=plan_by_cid,
|
||||
consumed_period=consumed_period,
|
||||
lookback_days=lookback_days,
|
||||
plan_d=plan_d,
|
||||
)
|
||||
enriched.append(row)
|
||||
if alert:
|
||||
alerts.append(alert)
|
||||
|
||||
return {
|
||||
"planDate": iso,
|
||||
"items": enriched,
|
||||
"alerts": alerts,
|
||||
"alertBanner": " · ".join(alerts[:5]) if alerts else "",
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Строки листа «Итоги» для Excel (экономист)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import LoadingReport, LoadingReportComponent
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.finance_summary import _report_filters
|
||||
from app.services.analytics.prices import component_prices, price_for
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
|
||||
|
||||
def build_summary_table_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
reports = db.session.execute(
|
||||
_report_filters(start_dt=start_dt, end_dt=end_dt, recipe_ids=ids)
|
||||
).scalars().all()
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
report_by_id = {r.id: r for r in reports}
|
||||
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
|
||||
farm_by_recipe, _ = build_recipe_location_maps(recipe_ids_set)
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
prices = component_prices(
|
||||
component_ids=[c.component_id for c in components if c.component_id],
|
||||
names=[c.component_name for c in components],
|
||||
)
|
||||
|
||||
agg: Dict[tuple[str, str], Dict[str, Any]] = defaultdict(
|
||||
lambda: {
|
||||
"farm": "",
|
||||
"component": "",
|
||||
"planKg": 0.0,
|
||||
"factKg": 0.0,
|
||||
"pricePerKg": 0.0,
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
for comp in components:
|
||||
report = report_by_id.get(comp.report_id)
|
||||
if not report:
|
||||
continue
|
||||
farm = farm_by_recipe.get(report.recipe_id, "—")
|
||||
cname = str(comp.component_name or "—")
|
||||
key = (farm, cname)
|
||||
row = agg[key]
|
||||
row["farm"] = farm
|
||||
row["component"] = cname
|
||||
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
|
||||
price = price_for(
|
||||
prices=prices,
|
||||
component_id=comp.component_id,
|
||||
name=cname,
|
||||
)
|
||||
row["planKg"] += target
|
||||
row["factKg"] += actual
|
||||
row["pricePerKg"] = price
|
||||
|
||||
dev_kg = actual - target
|
||||
dev_rub = dev_kg * price
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in agg.values():
|
||||
if row["planKg"] <= 0 and row["factKg"] <= 0:
|
||||
continue
|
||||
plan = round(row["planKg"], 2)
|
||||
fact = round(row["factKg"], 2)
|
||||
out.append(
|
||||
{
|
||||
"farm": row["farm"],
|
||||
"component": row["component"],
|
||||
"planKg": plan,
|
||||
"factKg": fact,
|
||||
"deviationKg": round(fact - plan, 2),
|
||||
"pricePerKg": round(row["pricePerKg"], 2),
|
||||
"overloadRub": round(row["overloadRub"], 2),
|
||||
"underloadRub": round(row["underloadRub"], 2),
|
||||
}
|
||||
)
|
||||
|
||||
out.sort(key=lambda r: (r["farm"], r["component"]))
|
||||
return out
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user