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

218 lines
7.3 KiB
Python

"""Прогноз запаса: хватит по рецепту 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 "",
}