"""Временная правка норм по компонентам в плане на день.""" 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, }