Files
site/WESP_REL/app/services/daily_plan/adjustments.py
T
2026-07-17 12:57:18 +03:00

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,
}