329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Временная правка норм по компонентам в плане на день."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app import db
|
|
from app.models import Component, DailyComponentNormAdjustment, Ingredient, Recipe
|
|
from app.services.daily_plan.ingredient_weights import (
|
|
ingredient_dry_matter_per_head,
|
|
ingredient_dry_matter_percent,
|
|
)
|
|
from app.services.daily_plan.skips import (
|
|
_parse_plan_date,
|
|
_serialize_skip_dates,
|
|
_skip_active_filters,
|
|
_soft_unskip_part,
|
|
_upsert_part_skip,
|
|
get_skipped_ingredient_ids,
|
|
resolve_skip_range,
|
|
)
|
|
|
|
|
|
def get_component_adjustment_map(
|
|
plan_date: Optional[str] = None,
|
|
) -> Dict[str, Dict[str, Optional[float]]]:
|
|
"""component_id -> {dry_matter, dry_matter_locked, weight_per_head, dry_matter_per_head}."""
|
|
d = _parse_plan_date(plan_date)
|
|
rows = db.session.execute(
|
|
select(
|
|
DailyComponentNormAdjustment.component_id,
|
|
DailyComponentNormAdjustment.dry_matter,
|
|
DailyComponentNormAdjustment.dry_matter_locked,
|
|
DailyComponentNormAdjustment.weight_per_head,
|
|
DailyComponentNormAdjustment.dry_matter_per_head,
|
|
).where(*_skip_active_filters(DailyComponentNormAdjustment, d))
|
|
).all()
|
|
out: Dict[str, Dict[str, Optional[float]]] = {}
|
|
for cid, dm, locked, wph, dm_ph in rows:
|
|
out[str(cid)] = {
|
|
"dry_matter": float(dm) if dm is not None else None,
|
|
"dry_matter_locked": bool(locked),
|
|
"weight_per_head": float(wph) if wph is not None else None,
|
|
"dry_matter_per_head": float(dm_ph) if dm_ph is not None else None,
|
|
}
|
|
return out
|
|
|
|
|
|
def get_adjusted_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
|
|
"""Рейсы с хотя бы одним ингредиентом с активной правкой нормы."""
|
|
d = _parse_plan_date(plan_date)
|
|
adj_map = get_component_adjustment_map(plan_date)
|
|
if not adj_map:
|
|
return set()
|
|
skip_map = get_skipped_ingredient_ids(plan_date)
|
|
rows = db.session.execute(
|
|
select(Ingredient.recipe_id, Ingredient.id, Ingredient.component_id).where(
|
|
Ingredient.is_deleted.is_(False),
|
|
Ingredient.component_id.isnot(None),
|
|
)
|
|
).all()
|
|
out: Set[str] = set()
|
|
for recipe_id, ing_id, component_id in rows:
|
|
if str(component_id) not in adj_map:
|
|
continue
|
|
if ing_id in skip_map.get(recipe_id, set()):
|
|
continue
|
|
out.add(recipe_id)
|
|
return out
|
|
|
|
|
|
def list_component_norm_adjustments(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
d = _parse_plan_date(plan_date)
|
|
rows = db.session.execute(
|
|
select(DailyComponentNormAdjustment, Component.name)
|
|
.join(Component, Component.id == DailyComponentNormAdjustment.component_id)
|
|
.where(
|
|
*_skip_active_filters(DailyComponentNormAdjustment, d),
|
|
Component.is_deleted.is_(False),
|
|
)
|
|
.order_by(Component.name.asc())
|
|
).all()
|
|
return [
|
|
{
|
|
"id": row.id,
|
|
"componentId": row.component_id,
|
|
"componentName": name or "—",
|
|
"dryMatter": row.dry_matter,
|
|
"dryMatterLocked": bool(row.dry_matter_locked),
|
|
"weightPerHead": row.weight_per_head,
|
|
"dryMatterPerHead": row.dry_matter_per_head,
|
|
**_serialize_skip_dates(row, fallback=d),
|
|
}
|
|
for row, name in rows
|
|
]
|
|
|
|
|
|
def _recipe_usages_summary(usages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Уникальные рецепты, где компонент встречается в плане на день."""
|
|
by_recipe: Dict[str, Dict[str, Any]] = {}
|
|
for usage in usages:
|
|
rid = str(usage.get("recipeId") or "")
|
|
if not rid:
|
|
continue
|
|
if rid not in by_recipe:
|
|
by_recipe[rid] = {
|
|
"recipeId": rid,
|
|
"recipeName": usage.get("recipeName") or "—",
|
|
"masterWeightPerHead": usage.get("masterWeightPerHead"),
|
|
"masterDryMatterPerHead": usage.get("masterDryMatterPerHead"),
|
|
"masterDryMatterPct": usage.get("masterDryMatterPct"),
|
|
"dryMatterLocked": bool(usage.get("dryMatterLocked")),
|
|
"tripCount": 1,
|
|
}
|
|
else:
|
|
by_recipe[rid]["tripCount"] = int(by_recipe[rid].get("tripCount") or 0) + 1
|
|
return sorted(by_recipe.values(), key=lambda row: (row.get("recipeName") or "").lower())
|
|
|
|
|
|
def _master_norm_snapshot(
|
|
component_id: str,
|
|
usages: List[Dict[str, Any]],
|
|
) -> Dict[str, Any]:
|
|
wph_vals = [float(u["masterWeightPerHead"]) for u in usages if u.get("masterWeightPerHead") is not None]
|
|
dm_vals = [float(u["masterDryMatterPerHead"]) for u in usages if u.get("masterDryMatterPerHead") is not None]
|
|
dm_pct_vals = [float(u["masterDryMatterPct"]) for u in usages if u.get("masterDryMatterPct") is not None]
|
|
return {
|
|
"masterWeightPerHeadMin": min(wph_vals) if wph_vals else None,
|
|
"masterWeightPerHeadMax": max(wph_vals) if wph_vals else None,
|
|
"masterDryMatterPerHeadMin": min(dm_vals) if dm_vals else None,
|
|
"masterDryMatterPerHeadMax": max(dm_vals) if dm_vals else None,
|
|
"masterDryMatterPctMin": min(dm_pct_vals) if dm_pct_vals else None,
|
|
"masterDryMatterPctMax": max(dm_pct_vals) if dm_pct_vals else None,
|
|
"masterWeightPerHead": round(sum(wph_vals) / len(wph_vals), 3) if wph_vals else None,
|
|
"masterDryMatterPerHead": round(sum(dm_vals) / len(dm_vals), 4) if dm_vals else None,
|
|
"masterDryMatterPct": round(sum(dm_pct_vals) / len(dm_pct_vals), 2) if dm_pct_vals else None,
|
|
}
|
|
|
|
|
|
def list_component_norms_for_plan(
|
|
plan_date: Optional[str] = None,
|
|
*,
|
|
dispenser_id: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Уникальные компоненты плана с мастер-нормами и активными правками."""
|
|
iso_date = _parse_plan_date(plan_date).isoformat()
|
|
if not dispenser_id:
|
|
return []
|
|
from app.services.daily_plan.builder import build_daily_plan
|
|
|
|
try:
|
|
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
|
|
except LookupError:
|
|
return []
|
|
|
|
skip_map = get_skipped_ingredient_ids(iso_date)
|
|
adj_map = get_component_adjustment_map(iso_date)
|
|
comp_ids: Set[str] = set()
|
|
usages: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
comp_names: Dict[str, str] = {}
|
|
comp_dm: Dict[str, float] = {}
|
|
plan_effective: Dict[str, Dict[str, Optional[float]]] = {}
|
|
|
|
for period in plan.get("periods") or []:
|
|
for trip in period.get("trips") or []:
|
|
recipe_id = trip.get("recipeId")
|
|
dry_matter_locked = bool(trip.get("dryMatterLocked"))
|
|
for ing in trip.get("ingredients") or []:
|
|
if ing.get("skippedToday"):
|
|
continue
|
|
cid = str(ing.get("componentId") or ing.get("originalComponentId") or "")
|
|
if not cid:
|
|
continue
|
|
if cid not in plan_effective:
|
|
plan_effective[cid] = {
|
|
"weightPerHead": (
|
|
float(ing["weightPerHead"])
|
|
if ing.get("weightPerHead") is not None
|
|
else None
|
|
),
|
|
"dryMatterPerHead": (
|
|
float(ing["dryMatterPerHead"])
|
|
if ing.get("dryMatterPerHead") is not None
|
|
else None
|
|
),
|
|
"dryMatterPct": (
|
|
float(ing["dryMatterPct"])
|
|
if ing.get("dryMatterPct") is not None
|
|
else None
|
|
),
|
|
}
|
|
comp_ids.add(cid)
|
|
comp_names[cid] = str(ing.get("originalName") or ing.get("name") or "—")
|
|
if ing.get("dryMatterPct") is not None:
|
|
comp_dm[cid] = float(ing["dryMatterPct"])
|
|
master_wph = ing.get("originalWeightPerHead")
|
|
master_dm = ing.get("originalDryMatterPerHead")
|
|
if master_wph is None:
|
|
master_wph = ing.get("weightPerHead")
|
|
if master_dm is None:
|
|
master_dm = ing.get("dryMatterPerHead")
|
|
master_dm_pct = ing.get("originalDryMatterPct")
|
|
if master_dm_pct is None:
|
|
master_dm_pct = ing.get("dryMatterPct")
|
|
usages[cid].append(
|
|
{
|
|
"recipeId": recipe_id,
|
|
"recipeName": trip.get("recipeName"),
|
|
"ingredientId": ing.get("id"),
|
|
"masterWeightPerHead": master_wph,
|
|
"masterDryMatterPerHead": master_dm,
|
|
"masterDryMatterPct": master_dm_pct,
|
|
"dryMatterLocked": dry_matter_locked,
|
|
}
|
|
)
|
|
|
|
if comp_ids:
|
|
comps = db.session.execute(
|
|
select(Component).where(
|
|
Component.id.in_(comp_ids),
|
|
Component.is_deleted.is_(False),
|
|
)
|
|
).scalars().all()
|
|
for comp in comps:
|
|
comp_names[comp.id] = comp.name
|
|
comp_dm[comp.id] = float(comp.dry_matter or 0)
|
|
|
|
rows: List[Dict[str, Any]] = []
|
|
for cid in sorted(comp_ids, key=lambda x: (comp_names.get(x) or x).lower()):
|
|
usage_list = usages.get(cid, [])
|
|
adj = adj_map.get(cid)
|
|
snap = _master_norm_snapshot(cid, usage_list)
|
|
eff = plan_effective.get(cid) or {}
|
|
rows.append(
|
|
{
|
|
"componentId": cid,
|
|
"componentName": comp_names.get(cid, "—"),
|
|
"dryMatterPct": comp_dm.get(cid, 0),
|
|
"usageCount": len(usage_list),
|
|
"recipes": sorted({u.get("recipeName") or "" for u in usage_list if u.get("recipeName")}),
|
|
"usages": _recipe_usages_summary(usage_list),
|
|
**snap,
|
|
"planDryMatterPct": adj.get("dry_matter") if adj else eff.get("dryMatterPct"),
|
|
"planDryMatterLocked": bool(adj.get("dry_matter_locked")) if adj else None,
|
|
"planWeightPerHead": eff.get("weightPerHead"),
|
|
"planDryMatterPerHead": eff.get("dryMatterPerHead"),
|
|
"adjustedToday": bool(adj),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def adjust_component_norm(
|
|
component_id: str,
|
|
plan_date: Optional[str] = None,
|
|
*,
|
|
dry_matter: Optional[float] = None,
|
|
dry_matter_locked: bool = False,
|
|
weight_per_head: Optional[float] = None,
|
|
dry_matter_per_head: Optional[float] = None,
|
|
duration: Optional[str] = None,
|
|
until_date: Optional[str] = None,
|
|
user: str = "system",
|
|
) -> DailyComponentNormAdjustment:
|
|
if dry_matter is None and weight_per_head is None and dry_matter_per_head is None:
|
|
raise ValueError("Укажите dryMatter (СВ%)")
|
|
start = _parse_plan_date(plan_date)
|
|
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
|
component = db.session.execute(
|
|
select(Component).where(
|
|
Component.id == component_id,
|
|
Component.is_deleted.is_(False),
|
|
)
|
|
).scalar_one_or_none()
|
|
if component is None:
|
|
raise LookupError("Компонент не найден")
|
|
|
|
if dry_matter is not None:
|
|
weight_per_head = None
|
|
dry_matter_per_head = None
|
|
|
|
return _upsert_part_skip(
|
|
DailyComponentNormAdjustment,
|
|
lookup_filters=(DailyComponentNormAdjustment.component_id == component_id,),
|
|
create_fields={
|
|
"component_id": component_id,
|
|
"plan_date": start,
|
|
"valid_until": valid_until if valid_until != start else None,
|
|
"dry_matter": float(dry_matter) if dry_matter is not None else None,
|
|
"dry_matter_locked": bool(dry_matter_locked),
|
|
"weight_per_head": float(weight_per_head) if weight_per_head is not None else None,
|
|
"dry_matter_per_head": float(dry_matter_per_head)
|
|
if dry_matter_per_head is not None
|
|
else None,
|
|
},
|
|
user=user,
|
|
)
|
|
|
|
|
|
def undo_component_norm_adjustment(
|
|
component_id: str,
|
|
plan_date: Optional[str] = None,
|
|
*,
|
|
user: str = "system",
|
|
) -> bool:
|
|
d = _parse_plan_date(plan_date)
|
|
return _soft_unskip_part(
|
|
DailyComponentNormAdjustment,
|
|
lookup_filters=(
|
|
DailyComponentNormAdjustment.component_id == component_id,
|
|
*_skip_active_filters(DailyComponentNormAdjustment, d),
|
|
),
|
|
user=user,
|
|
table_name="daily_component_norm_adjustment",
|
|
)
|
|
|
|
|
|
def master_ingredient_norms(ing: Ingredient, components_by_id: Dict[str, Component]) -> Dict[str, float]:
|
|
dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
|
|
dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=dm_pct)
|
|
return {
|
|
"weightPerHead": float(ing.weight_per_head or 0),
|
|
"dryMatterPerHead": round(dm_ph, 4),
|
|
"dryMatterPct": dm_pct,
|
|
}
|