Files
2026-07-17 12:57:18 +03:00

706 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Сравнение: по рецепту / на сегодня / сделали."""
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)}