Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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()