@@ -0,0 +1,217 @@
|
||||
"""Прогноз запаса: хватит по рецепту 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 "",
|
||||
}
|
||||
Reference in New Issue
Block a user