@@ -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)}
|
||||
Reference in New Issue
Block a user