"""Сборка плана на день из периодов, рейсов и ингредиентов.""" from __future__ import annotations from datetime import date, datetime from typing import Any, Dict, List, Optional from sqlalchemy import exists, select from app import db from app.models import ( Component, FeedDispenser, FeedingPeriod, Ingredient, PeriodRecipe, Recipe, UnloadingGroup, ) from app.services.daily_plan.adjustments import ( get_component_adjustment_map, list_component_norm_adjustments, ) from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights from app.services.daily_plan.replacements import ( get_ingredient_replacement_map, list_ingredient_replacements, ) from app.services.daily_plan.skips import ( get_skipped_ingredient_ids, get_skipped_recipe_ids, get_skipped_unloading_group_ids, list_ingredient_skips, list_skips, list_unloading_group_skips, ) from app.services.period_recipes_query import recipes_for_period_ordered from app.services.recipe_calculator import calculate_ingredients, calculate_recipe ALL_DISPENSERS_ID = "__all_dispensers__" ALL_MILLS_ID = "__all_mills__" def _parse_plan_date(value: Optional[str]) -> str: if value: try: return date.fromisoformat(str(value).strip()[:10]).isoformat() except ValueError: pass return date.today().isoformat() def _ingredient_display_name(ing: Ingredient, comp_names: Dict[str, str]) -> str: raw = (ing.name or "").strip() if raw: return raw if ing.component_id and ing.component_id in comp_names: return comp_names[ing.component_id] return "—" def _distribution_label(dist_type: Optional[str], value: float) -> str: if (dist_type or "percent") == "heads": rounded = int(value) if value == int(value) else value return f"{rounded} гол." rounded = int(value) if value == int(value) else value return f"{rounded}%" def _load_components_by_id(comp_ids: List[str]) -> Dict[str, Component]: if not comp_ids: return {} comps = db.session.execute( select(Component).where( Component.id.in_(set(comp_ids)), Component.is_deleted.is_(False), ) ).scalars().all() return {c.id: c for c in comps} def _serialize_trip( recipe: Recipe, *, order: int, skipped_ingredient_ids: Optional[set[str]] = None, skipped_group_ids: Optional[set[str]] = None, replacement_by_ingredient: Optional[Dict[str, str]] = None, component_adjustment_map: Optional[Dict[str, Dict[str, Optional[float]]]] = None, ) -> Dict[str, Any]: ingredients = db.session.execute( select(Ingredient) .where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False)) .order_by(Ingredient.order.asc()) ).scalars().all() comp_ids = [i.component_id for i in ingredients if i.component_id] repl_ids = list((replacement_by_ingredient or {}).values()) comp_ids.extend(repl_ids) components_by_id = _load_components_by_id(comp_ids) comp_names = {cid: c.name for cid, c in components_by_id.items()} heads = int(recipe.heads_per_trip or 0) skip_ings = skipped_ingredient_ids or set() skip_groups = skipped_group_ids or set() repl_map = replacement_by_ingredient or {} adj_map = component_adjustment_map or {} dm_map = {cid: float(c.dry_matter or 0) for cid, c in components_by_id.items()} prepared: List[Dict[str, Any]] = [] calc_inputs: List[Dict[str, Any]] = [] calc_index_by_ing: Dict[str, int] = {} for ing in ingredients: is_skipped = ing.id in skip_ings replacement_id = repl_map.get(ing.id) if not is_skipped else None component_adj = adj_map.get(str(ing.component_id)) if ing.component_id else None weights = resolve_plan_ingredient_weights( ing, recipe, heads=heads, components_by_id=components_by_id, replacement_component_id=replacement_id, component_adjustment=component_adj, ) original_name = _ingredient_display_name(ing, comp_names) replaced_today = bool(replacement_id) adjusted_today = bool(weights.get("adjustedToday")) display_name = original_name replacement_name = None if replaced_today: replacement_name = comp_names.get(replacement_id, "—") display_name = f"{original_name} → {replacement_name}" trip_kg = 0.0 if not is_skipped: calc_index_by_ing[ing.id] = len(calc_inputs) calc_inputs.append( { "weightPerHead": weights["weightPerHead"], "dryMatter": weights["dryMatterPct"], "component_id": replacement_id or ing.component_id, } ) prepared.append( { "ing": ing, "is_skipped": is_skipped, "weights": weights, "original_name": original_name, "display_name": display_name, "replacement_id": replacement_id, "replacement_name": replacement_name, "replaced_today": replaced_today, "adjusted_today": adjusted_today, "trip_kg": trip_kg, } ) groups = db.session.execute( select(UnloadingGroup) .where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False)) .order_by(UnloadingGroup.order.asc()) ).scalars().all() active_groups = [g for g in groups if g.id not in skip_groups] calc_groups_payload = [ { "distributionType": g.distribution_type or "percent", "value": float(g.value or 0), } for g in active_groups ] calc_result = calculate_recipe( calc_inputs, heads_count=heads, trip_percent=float(recipe.trip_percent or 100), unloading_groups=calc_groups_payload, component_dry_matter_map=dm_map, ) calc_ingredients = calc_result.get("ingredients") or [] calc_unloading = calc_result.get("unloadingGroups") or [] total_weight = float(calc_result.get("totals", {}).get("totalTripWeight") or 0) unloading_total = float(calc_result.get("unloadingTotals", {}).get("totalWeightKg") or 0) baseline_inputs = [ { "weightPerHead": item["weights"]["weightPerHead"], "dryMatter": item["weights"]["dryMatterPct"], "component_id": item["replacement_id"] or item["ing"].component_id, } for item in prepared ] baseline_ingredients = calculate_ingredients( baseline_inputs, heads_count=heads, trip_percent=float(recipe.trip_percent or 100), component_dry_matter_map=dm_map, ) group_weight_by_id = { g.id: float(calc_unloading[idx].get("calculatedWeight") or 0) for idx, g in enumerate(active_groups) if idx < len(calc_unloading) } ing_rows = [] for idx, item in enumerate(prepared): ing = item["ing"] weights = item["weights"] if not item["is_skipped"]: calc_idx = calc_index_by_ing.get(ing.id) if calc_idx is not None and calc_idx < len(calc_ingredients): item["trip_kg"] = float(calc_ingredients[calc_idx].get("tripWeight") or 0) row: Dict[str, Any] = { "id": ing.id, "name": item["display_name"], "originalName": item["original_name"], "weightPerHead": weights["weightPerHead"], "totalKg": item["trip_kg"], "dryMatterPct": weights["dryMatterPct"], "dryMatterPerHead": weights["dryMatterPerHead"], "componentId": ing.component_id, "originalComponentId": ing.component_id, "replacementComponentId": item["replacement_id"], "replacementName": item["replacement_name"], "skippedToday": item["is_skipped"], "replacedToday": item["replaced_today"], "adjustedToday": item["adjusted_today"], } if item["replaced_today"] or item["adjusted_today"]: row.update( { "originalWeightPerHead": weights.get("originalWeightPerHead"), "originalDryMatterPerHead": weights.get("originalDryMatterPerHead"), "originalDryMatterPct": weights.get("originalDryMatterPct"), } ) if item["replaced_today"]: row["recalculationMode"] = weights.get("recalculationMode") if item["is_skipped"] and idx < len(baseline_ingredients): baseline = baseline_ingredients[idx] row["baselineWeightPerHead"] = float(baseline.get("weightPerHead") or 0) row["baselineTotalKg"] = float(baseline.get("tripWeight") or 0) ing_rows.append(row) return { "order": order, "recipeId": recipe.id, "recipeName": recipe.name, "headsPerTrip": heads, "mixingTimeSec": int(recipe.mixing_time or 0), "tripPercent": float(recipe.trip_percent or 100), "dryMatterLocked": bool(recipe.dry_matter_locked), "totalWeightKg": round(total_weight, 2), "unloadingTotalKg": round(unloading_total, 2), "ingredients": ing_rows, "unloadingGroups": [ { "id": g.id, "name": g.name, "weightKg": 0.0 if g.id in skip_groups else float(group_weight_by_id.get(g.id, 0)), "distributionType": g.distribution_type, "distributionLabel": _distribution_label( g.distribution_type, float(g.value or 0) ), "value": float(g.value or 0), "skippedToday": g.id in skip_groups, } for g in groups ], } def _periods_for_dispenser( dispenser: FeedDispenser, *, skipped_ids: set[str], skipped_ingredients: Dict[str, set[str]], skipped_groups: Dict[str, set[str]], replacement_map: Dict[str, Dict[str, str]], component_adjustment_map: Dict[str, Dict[str, Optional[float]]], ) -> List[Dict[str, Any]]: periods = db.session.execute( select(FeedingPeriod) .where( FeedingPeriod.dispenser_id == dispenser.id, FeedingPeriod.is_deleted.is_(False), FeedingPeriod.is_active.is_(True), ) .order_by(FeedingPeriod.created_at.asc()) ).unique().scalars().all() payload = [] for period in periods: recipes = [ r for r in recipes_for_period_ordered(period.id) if r.id not in skipped_ids ] trips = [ _serialize_trip( r, order=idx + 1, skipped_ingredient_ids=skipped_ingredients.get(r.id, set()), skipped_group_ids=skipped_groups.get(r.id, set()), replacement_by_ingredient=replacement_map.get(r.id, {}), component_adjustment_map=component_adjustment_map, ) for idx, r in enumerate(recipes) ] payload.append( { "id": period.id, "name": period.name, "trips": trips, } ) return payload def _mill_trips_fallback( dispenser_id: str, *, skipped_ids: set[str], skipped_ingredients: Dict[str, set[str]], skipped_groups: Dict[str, set[str]], replacement_map: Dict[str, Dict[str, str]], component_adjustment_map: Dict[str, Dict[str, Optional[float]]], ) -> List[Dict[str, Any]]: recipes = db.session.execute( select(Recipe) .where( Recipe.is_deleted.is_(False), ~exists( select(1).where( PeriodRecipe.recipe_id == Recipe.id, PeriodRecipe.is_deleted.is_(False), ) ), ) .order_by(Recipe.updated_at.desc()) ).scalars().all() recipes = [r for r in recipes if r.id not in skipped_ids] trips = [ _serialize_trip( r, order=idx + 1, skipped_ingredient_ids=skipped_ingredients.get(r.id, set()), skipped_group_ids=skipped_groups.get(r.id, set()), replacement_by_ingredient=replacement_map.get(r.id, {}), ) for idx, r in enumerate(recipes) ] if not trips: return [] return [{"id": None, "name": "Рейсы", "trips": trips}] def _aggregate_ingredient_totals(periods: List[Dict[str, Any]]) -> Dict[str, Any]: totals: Dict[str, float] = {} grand_total = 0.0 for period in periods: for trip in period.get("trips") or []: for ing in trip.get("ingredients") or []: if ing.get("skippedToday"): continue if ing.get("replacedToday") and ing.get("replacementName"): name = str(ing.get("replacementName") or "—") else: name = str(ing.get("originalName") or ing.get("name") or "—") kg = float(ing.get("totalKg") or 0) totals[name] = totals.get(name, 0.0) + kg grand_total += kg rows = [ {"name": name, "totalKg": round(kg, 2)} for name, kg in sorted(totals.items(), key=lambda x: x[0].lower()) ] return { "rows": rows, "grandTotalKg": round(grand_total, 2), } def _periods_for_dispenser_named( dispenser: FeedDispenser, *, prefix_name: bool = False, skipped_ids: set[str], skipped_ingredients: Dict[str, set[str]], skipped_groups: Dict[str, set[str]], replacement_map: Dict[str, Dict[str, str]], component_adjustment_map: Dict[str, Dict[str, Optional[float]]], ) -> List[Dict[str, Any]]: if dispenser.type == "mill": periods = _mill_trips_fallback( dispenser.id, skipped_ids=skipped_ids, skipped_ingredients=skipped_ingredients, skipped_groups=skipped_groups, replacement_map=replacement_map, component_adjustment_map=component_adjustment_map, ) else: periods = _periods_for_dispenser( dispenser, skipped_ids=skipped_ids, skipped_ingredients=skipped_ingredients, skipped_groups=skipped_groups, replacement_map=replacement_map, component_adjustment_map=component_adjustment_map, ) if not prefix_name: return periods prefixed: List[Dict[str, Any]] = [] for period in periods: item = dict(period) item["name"] = f"{dispenser.name} · {period.get('name') or 'Период'}" item["dispenserId"] = dispenser.id prefixed.append(item) return prefixed def _plan_extras( periods: List[Dict[str, Any]], *, iso_date: str, ) -> Dict[str, Any]: totals = _aggregate_ingredient_totals(periods) return { "ingredientTotals": totals["rows"], "ingredientGrandTotalKg": totals["grandTotalKg"], "ingredientReplacements": list_ingredient_replacements(iso_date), "componentNormAdjustments": list_component_norm_adjustments(iso_date), } def _plan_payload_base(plan_date: Optional[str]) -> tuple[str, set[str], List, List, List]: iso_date = _parse_plan_date(plan_date) skipped_ids = get_skipped_recipe_ids(iso_date) skipped_trips = list_skips(iso_date) skipped_ingredients = list_ingredient_skips(iso_date) skipped_groups = list_unloading_group_skips(iso_date) return iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups def _part_skip_maps(plan_date: Optional[str]) -> tuple[Dict[str, set[str]], Dict[str, set[str]]]: iso_date = _parse_plan_date(plan_date) return get_skipped_ingredient_ids(iso_date), get_skipped_unloading_group_ids(iso_date) def _build_all_dispensers_plan(plan_date: Optional[str]) -> Dict[str, Any]: iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base( plan_date ) skip_ing_map, skip_grp_map = _part_skip_maps(plan_date) replacement_map = get_ingredient_replacement_map(iso_date) component_adjustment_map = get_component_adjustment_map(iso_date) dispensers = db.session.execute( select(FeedDispenser) .where( FeedDispenser.is_deleted.is_(False), FeedDispenser.type == "dispenser", ) .order_by(FeedDispenser.name.asc()) ).scalars().all() periods: List[Dict[str, Any]] = [] for dispenser in dispensers: periods.extend( _periods_for_dispenser_named( dispenser, prefix_name=True, skipped_ids=skipped_ids, skipped_ingredients=skip_ing_map, skipped_groups=skip_grp_map, replacement_map=replacement_map, component_adjustment_map=component_adjustment_map, ) ) return { "date": iso_date, "generatedAt": datetime.now().isoformat(timespec="seconds"), "dispenserId": ALL_DISPENSERS_ID, "dispenserName": "Все кормораздатчики", "farm": "", "dispenserType": "dispenser", "periods": periods, **_plan_extras(periods, iso_date=iso_date), "skippedTrips": skipped_trips, "skippedIngredients": skipped_ingredients, "skippedUnloadingGroups": skipped_groups, } def _build_all_mills_plan(plan_date: Optional[str]) -> Dict[str, Any]: iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base( plan_date ) skip_ing_map, skip_grp_map = _part_skip_maps(plan_date) replacement_map = get_ingredient_replacement_map(iso_date) component_adjustment_map = get_component_adjustment_map(iso_date) mills = db.session.execute( select(FeedDispenser) .where( FeedDispenser.is_deleted.is_(False), FeedDispenser.type == "mill", ) .order_by(FeedDispenser.name.asc()) ).scalars().all() periods = _mill_trips_fallback( ALL_MILLS_ID, skipped_ids=skipped_ids, skipped_ingredients=skip_ing_map, skipped_groups=skip_grp_map, replacement_map=replacement_map, component_adjustment_map=component_adjustment_map, ) if mills: mill_names = ", ".join(m.name for m in mills) farm = mills[0].farm if len(mills) == 1 else "" else: mill_names = "" farm = "" return { "date": iso_date, "generatedAt": datetime.now().isoformat(timespec="seconds"), "dispenserId": ALL_MILLS_ID, "dispenserName": "Все кормоцеха", "farm": farm, "dispenserType": "mill", "scopeNote": mill_names, "periods": periods, **_plan_extras(periods, iso_date=iso_date), "skippedTrips": skipped_trips, "skippedIngredients": skipped_ingredients, "skippedUnloadingGroups": skipped_groups, } def build_daily_plan( *, dispenser_id: str, plan_date: Optional[str] = None, ) -> Dict[str, Any]: """План на дату: текущая конфигурация периодов кормораздатчика.""" if dispenser_id == ALL_DISPENSERS_ID: return _build_all_dispensers_plan(plan_date) if dispenser_id == ALL_MILLS_ID: return _build_all_mills_plan(plan_date) dispenser = db.session.execute( select(FeedDispenser).where( FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False), ) ).scalar_one_or_none() if dispenser is None: raise LookupError("Кормораздатчик не найден") iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base( plan_date ) skip_ing_map, skip_grp_map = _part_skip_maps(plan_date) replacement_map = get_ingredient_replacement_map(iso_date) component_adjustment_map = get_component_adjustment_map(iso_date) periods = _periods_for_dispenser_named( dispenser, skipped_ids=skipped_ids, skipped_ingredients=skip_ing_map, skipped_groups=skip_grp_map, replacement_map=replacement_map, component_adjustment_map=component_adjustment_map, ) return { "date": iso_date, "generatedAt": datetime.now().isoformat(timespec="seconds"), "dispenserId": dispenser.id, "dispenserName": dispenser.name, "farm": dispenser.farm, "dispenserType": dispenser.type, "periods": periods, **_plan_extras(periods, iso_date=iso_date), "skippedTrips": skipped_trips, "skippedIngredients": skipped_ingredients, "skippedUnloadingGroups": skipped_groups, } def recipe_total_weights_by_id( *, dispenser_id: str, plan_date: Optional[str] = None, ) -> Dict[str, float]: """totalWeightKg по recipeId из плана на день (список рейсов на терминале).""" plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=plan_date) weights: Dict[str, float] = {} for period in plan.get("periods") or []: for trip in period.get("trips") or []: recipe_id = trip.get("recipeId") if recipe_id: weights[str(recipe_id)] = float(trip.get("totalWeightKg") or 0) return weights def trip_overlay_for_recipe(recipe: Recipe, plan_date: str) -> Dict[str, Any]: """Рейс из плана на день для одного recipe — те же веса, что в build_daily_plan.""" skipped_ing_by_recipe = get_skipped_ingredient_ids(plan_date) skipped_grp_by_recipe = get_skipped_unloading_group_ids(plan_date) replacement_map = get_ingredient_replacement_map(plan_date) component_adjustment_map = get_component_adjustment_map(plan_date) return _serialize_trip( recipe, order=1, skipped_ingredient_ids=skipped_ing_by_recipe.get(recipe.id, set()), skipped_group_ids=skipped_grp_by_recipe.get(recipe.id, set()), replacement_by_ingredient=replacement_map.get(recipe.id, {}), component_adjustment_map=component_adjustment_map, )