Интегрирован 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,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)}