Интегрирован 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
+622
View File
@@ -0,0 +1,622 @@
"""Сборка плана на день из периодов, рейсов и ингредиентов."""
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,
)