"""Прогноз запаса: хватит по рецепту vs с учётом плана и факта.""" from __future__ import annotations from datetime import date, timedelta from typing import Any, Dict, List, Optional, Tuple from app import db from app.models import Ingredient, LoadingReport, LoadingReportComponent, Recipe from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan from app.services.sklad_metrics import ( consumed_by_component_id, list_stock_balance_items, stock_item_key, ) _CRITICAL_DAYS = 2.0 def _plan_kg_by_component(plan_date: str) -> Dict[str, float]: plan = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=plan_date) out: Dict[str, float] = {} for period in plan.get("periods") or []: for trip in period.get("trips") or []: for ing in trip.get("ingredients") or []: if ing.get("skippedToday"): continue cid = ing.get("replacementComponentId") or ing.get("componentId") if not cid: continue out[str(cid)] = out.get(str(cid), 0.0) + float(ing.get("totalKg") or 0) return {k: round(v, 2) for k, v in out.items()} def format_days_label(days: Optional[float]) -> str: if days is None: return "—" if days < 1: hours = round(days * 24) return f"~{hours} ч" d = round(days, 1) if d == int(d): return f"~{int(d)} дн" return f"~{d} дн" def _days_left(remaining_kg: float, daily_kg: float) -> Optional[float]: if daily_kg <= 0 or remaining_kg <= 0: return None d = remaining_kg / daily_kg return round(min(d, 9999.9), 1) if d < 9999.9 else 9999.9 def enrich_stock_balance_item( item: Dict[str, Any], *, plan_by_cid: Dict[str, float], consumed_period: Dict[str, float], lookback_days: int, plan_d: date, ) -> Tuple[Dict[str, Any], Optional[str]]: """Добавляет прогноз «хватит с учётом плана» к строке склада.""" cid = str(item.get("component_id") or "").strip() name = str(item.get("name") or "—") remaining = float(item.get("remaining_kg") or 0) plan_recipe = float(item.get("planned_consumption_per_day_kg") or 0) plan_today = plan_by_cid.get(cid, plan_recipe) if cid else plan_recipe period_kg = consumed_period.get(cid, 0.0) if cid else 0.0 if period_kg <= 0: period_kg = float(item.get("consumed_total_kg") or item.get("consumed_kg") or 0) avg_daily = round(period_kg / max(1, lookback_days), 2) if period_kg > 0 else 0.0 if avg_daily > 0: expected_daily = max(avg_daily, plan_today) else: expected_daily = plan_today if plan_today > 0 else plan_recipe days_recipe = item.get("days_left_plan") days_adjusted = _days_left(remaining, expected_daily) explanation_parts: List[str] = [] if plan_today < plan_recipe - 0.01 and plan_recipe > 0: explanation_parts.append( f"убрали из плана на {plan_d.strftime('%d.%m')} → расход меньше на " f"{round(plan_recipe - plan_today, 1)} кг/сут" ) if avg_daily > plan_recipe * 1.05 and plan_recipe > 0: explanation_parts.append( f"расход выше плана: ~{avg_daily} кг/сут за {lookback_days} дн" ) explanation = "" if explanation_parts: base = " · ".join(explanation_parts) if days_recipe is not None and days_adjusted is not None and days_adjusted != days_recipe: delta = round(days_adjusted - days_recipe, 1) sign = "+" if delta > 0 else "" explanation = f"{base} → запас {sign}{delta} дн" else: explanation = base row = dict(item) row.update( { "forecast_key": stock_item_key(item), "planTodayKgPerDay": round(plan_today, 2), "avgConsumptionKgPerDay": avg_daily, "expectedDailyKg": round(expected_daily, 2), "days_left_adjusted": days_adjusted, "days_left_adjusted_label": format_days_label(days_adjusted), "daysLeftAdjusted": days_adjusted, "daysLeftAdjustedLabel": format_days_label(days_adjusted), "daysLeftRecipeLabel": format_days_label(days_recipe), "explanation": explanation, "removedFromPlanToday": plan_today < plan_recipe - 0.01 and plan_recipe > 0, } ) alert: Optional[str] = None if days_adjusted is not None and days_adjusted <= _CRITICAL_DAYS: alert = f"{name} — хватит {format_days_label(days_adjusted)}" elif row["removedFromPlanToday"] and days_adjusted and days_recipe: if days_adjusted > days_recipe + 0.3: alert = ( f"{name} — запас {format_days_label(days_adjusted)} " f"(убрали из плана на сегодня)" ) return row, alert def enrich_stock_balance_items( items: List[Dict[str, Any]], *, plan_date: Optional[str] = None, lookback_days: int = 7, ) -> List[Dict[str, Any]]: today = date.today() iso = (plan_date or today.isoformat())[:10] try: plan_d = date.fromisoformat(iso) except ValueError: plan_d = today iso = today.isoformat() from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat() to_d = plan_d.isoformat() plan_by_cid = _plan_kg_by_component(iso) consumed_period = consumed_by_component_id( db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d ) enriched: List[Dict[str, Any]] = [] for item in items: row, _ = enrich_stock_balance_item( item, plan_by_cid=plan_by_cid, consumed_period=consumed_period, lookback_days=lookback_days, plan_d=plan_d, ) enriched.append(row) return enriched def build_stock_forecast( *, plan_date: Optional[str] = None, lookback_days: int = 7, Component=None, ) -> Dict[str, Any]: today = date.today() iso = (plan_date or today.isoformat())[:10] try: plan_d = date.fromisoformat(iso) except ValueError: plan_d = today iso = today.isoformat() from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat() to_d = plan_d.isoformat() items = list_stock_balance_items( db, LoadingReport, LoadingReportComponent, Ingredient, Recipe, date_from=from_d, date_to=to_d, Component=Component, ) plan_by_cid = _plan_kg_by_component(iso) consumed_period = consumed_by_component_id( db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d ) enriched: List[Dict[str, Any]] = [] alerts: List[str] = [] for item in items: row, alert = enrich_stock_balance_item( item, plan_by_cid=plan_by_cid, consumed_period=consumed_period, lookback_days=lookback_days, plan_d=plan_d, ) enriched.append(row) if alert: alerts.append(alert) return { "planDate": iso, "items": enriched, "alerts": alerts, "alertBanner": " · ".join(alerts[:5]) if alerts else "", }