"""Сравнение: по рецепту / на сегодня / сделали.""" 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)}