623 lines
22 KiB
Python
623 lines
22 KiB
Python
"""Сборка плана на день из периодов, рейсов и ингредиентов."""
|
|
|
|
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,
|
|
)
|