"""Warehouse (sklad) stock + consumption aggregation for WESP consumption UI.""" from __future__ import annotations from collections import defaultdict from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import select from app.core.database import session_scope from app.modules.zootech.catalog_models import ZootechPeriodRecipe, ZootechSkladStock from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe from app.modules.zootech.report_models import ZootechLoadingReport from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time def _consumption_by_component( enterprise_id: str, window: tuple[datetime, datetime] | None, ) -> dict[str, float]: totals: dict[str, float] = defaultdict(float) with session_scope() as db: rows = list( db.scalars( select(ZootechLoadingReport).where( ZootechLoadingReport.enterprise_id == enterprise_id, ZootechLoadingReport.is_deleted.is_(False), ) ) ) for row in rows: payload = _parse_payload(row) start_time = _parse_report_time(payload.get("start_time")) if window: if start_time is None or not (window[0] <= start_time < window[1]): continue components = payload.get("components") if not isinstance(components, list): continue for comp in components: if not isinstance(comp, dict): continue actual = float(comp.get("actual_weight") or 0) if actual <= 0: continue key = str(comp.get("component_id") or (comp.get("component_name") or "")).strip() if key: totals[key] += actual return dict(totals) def _consumption_by_day( enterprise_id: str, component_id: str, component_name: str, window: tuple[datetime, datetime], ) -> dict[str, float]: by_day: dict[str, float] = defaultdict(float) name_key = (component_name or "").strip().lower() with session_scope() as db: rows = list( db.scalars( select(ZootechLoadingReport).where( ZootechLoadingReport.enterprise_id == enterprise_id, ZootechLoadingReport.is_deleted.is_(False), ) ) ) for row in rows: payload = _parse_payload(row) start_time = _parse_report_time(payload.get("start_time")) if start_time is None or not (window[0] <= start_time < window[1]): continue day = start_time.date().isoformat() components = payload.get("components") if not isinstance(components, list): continue for comp in components: if not isinstance(comp, dict): continue cid = str(comp.get("component_id") or "").strip() cname = (comp.get("component_name") or "").strip().lower() if cid != component_id and (not name_key or cname != name_key): continue actual = float(comp.get("actual_weight") or 0) if actual > 0: by_day[day] += actual return dict(by_day) def _planned_daily_kg(enterprise_id: str) -> dict[str, float]: totals: dict[str, float] = defaultdict(float) with session_scope() as db: period_recipes = list( db.scalars( select(ZootechPeriodRecipe).where( ZootechPeriodRecipe.enterprise_id == enterprise_id, ZootechPeriodRecipe.is_deleted.is_(False), ) ) ) recipe_ids = {row.recipe_id for row in period_recipes} if not recipe_ids: return {} recipes = { row.id: row for row in db.scalars( select(ZootechRecipe).where( ZootechRecipe.enterprise_id == enterprise_id, ZootechRecipe.id.in_(recipe_ids), ZootechRecipe.is_deleted.is_(False), ) ) } ingredients = list( db.scalars( select(ZootechIngredient).where( ZootechIngredient.enterprise_id == enterprise_id, ZootechIngredient.recipe_id.in_(recipe_ids), ZootechIngredient.is_deleted.is_(False), ) ) ) by_recipe: dict[str, list[ZootechIngredient]] = defaultdict(list) for ing in ingredients: by_recipe[ing.recipe_id].append(ing) for pr in period_recipes: recipe = recipes.get(pr.recipe_id) if not recipe: continue heads = float(recipe.heads_per_trip or 1) trip_pct = float(recipe.trip_percent or 100) / 100.0 for ing in by_recipe.get(pr.recipe_id, []): if not ing.component_id: continue wph = float(ing.weight_per_head or 0) if wph > 0: totals[ing.component_id] += wph * heads * trip_pct return dict(totals) def _component_lookup(enterprise_id: str) -> dict[str, str]: with session_scope() as db: rows = list( db.scalars( select(ZootechComponent).where( ZootechComponent.enterprise_id == enterprise_id, ZootechComponent.is_deleted.is_(False), ) ) ) return {row.id: row.name for row in rows} def _resolve_consumed( component_id: str, component_name: str, consumption: dict[str, float], ) -> float: if component_id in consumption: return consumption[component_id] name_key = (component_name or "").strip() if name_key in consumption: return consumption[name_key] return consumption.get(name_key.lower(), 0.0) def _days_left(remaining: float, planned_per_day: float) -> float | None: if planned_per_day <= 0: return None if remaining <= 0: return 0.0 return round(remaining / planned_per_day, 2) def _serialize_item( *, component_id: str, name: str, total_kg: float, inflow_kg: float, consumed: float, planned_per_day: float, has_stock: bool, ) -> dict[str, Any]: remaining = max(0.0, total_kg + inflow_kg - consumed) if has_stock else 0.0 days_left = _days_left(remaining if has_stock else total_kg, planned_per_day) return { "component_id": component_id, "name": name, "total_kg": round(total_kg, 2), "inflow_kg": round(inflow_kg, 2), "remaining_kg": round(remaining, 2), "consumed_kg": round(consumed, 2), "consumed_total_kg": round(consumed, 2), "planned_consumption_per_day_kg": round(planned_per_day, 2) if planned_per_day > 0 else 0, "days_left_plan": days_left, "days_left_adjusted": days_left, "has_stock": has_stock, "forecast_key": component_id, "explanation": "", } def list_sklad_items( enterprise_id: str, *, date_from: str | None = None, date_to: str | None = None, ) -> dict[str, Any]: window: tuple[datetime, datetime] | None = None if date_from and date_to: start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC) end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) window = start, end consumption = _consumption_by_component(enterprise_id, window) planned = _planned_daily_kg(enterprise_id) components = _component_lookup(enterprise_id) with session_scope() as db: stock_rows = [ { "component_id": row.component_id, "total_kg": float(row.total_kg or 0), "inflow_kg": float(row.inflow_kg or 0), "sort_order": int(row.sort_order or 0), } for row in db.scalars( select(ZootechSkladStock) .where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.is_deleted.is_(False), ) .order_by(ZootechSkladStock.sort_order, ZootechSkladStock.component_id) ) ] items: list[dict[str, Any]] = [] stock_ids: set[str] = set() for row in stock_rows: stock_ids.add(row["component_id"]) comp = components.get(row["component_id"]) name = comp if comp else row["component_id"] consumed = _resolve_consumed(row["component_id"], name, consumption) planned_per_day = planned.get(row["component_id"], 0.0) items.append( _serialize_item( component_id=row["component_id"], name=name, total_kg=row["total_kg"], inflow_kg=row["inflow_kg"], consumed=consumed, planned_per_day=planned_per_day, has_stock=True, ) ) for key, consumed in consumption.items(): if consumed <= 0: continue comp_name = components.get(key) if comp_name and key in stock_ids: continue if comp_name: items.append( _serialize_item( component_id=key, name=comp_name, total_kg=0.0, inflow_kg=0.0, consumed=consumed, planned_per_day=planned.get(key, 0.0), has_stock=False, ) ) continue if key in stock_ids: continue items.append( _serialize_item( component_id=key, name=key, total_kg=0.0, inflow_kg=0.0, consumed=consumed, planned_per_day=0.0, has_stock=False, ) ) return {"items": items} def add_sklad_item(enterprise_id: str, component_id: str) -> dict[str, Any]: components = _component_lookup(enterprise_id) if component_id not in components: return {"success": False, "error": "Компонент не найден"} with session_scope() as db: existing = db.scalar( select(ZootechSkladStock).where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.component_id == component_id, ) ) if existing and not existing.is_deleted: return {"success": False, "error": "Компонент уже на складе"} max_order = db.scalar( select(ZootechSkladStock.sort_order) .where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.is_deleted.is_(False), ) .order_by(ZootechSkladStock.sort_order.desc()) .limit(1) ) next_order = int(max_order or -1) + 1 if existing: existing.is_deleted = False existing.total_kg = 0.0 existing.inflow_kg = 0.0 existing.sort_order = next_order existing.version = int(existing.version or 0) + 1 else: db.add( ZootechSkladStock( enterprise_id=enterprise_id, component_id=component_id, total_kg=0.0, inflow_kg=0.0, sort_order=next_order, version=1, content_hash="", ) ) return {"success": True} def update_sklad_item( enterprise_id: str, component_id: str, *, total_kg: float, inflow_kg: float, ) -> dict[str, Any]: with session_scope() as db: row = db.scalar( select(ZootechSkladStock).where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.component_id == component_id, ZootechSkladStock.is_deleted.is_(False), ) ) if not row: return {"success": False, "error": "Компонент не найден на складе"} row.total_kg = max(0.0, float(total_kg)) row.inflow_kg = max(0.0, float(inflow_kg)) row.version = int(row.version or 0) + 1 return {"success": True} def remove_sklad_item(enterprise_id: str, component_id: str) -> dict[str, Any]: with session_scope() as db: row = db.scalar( select(ZootechSkladStock).where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.component_id == component_id, ZootechSkladStock.is_deleted.is_(False), ) ) if not row: return {"success": False, "error": "Компонент не найден на складе"} row.is_deleted = True row.version = int(row.version or 0) + 1 return {"success": True} def move_sklad_item(enterprise_id: str, component_id: str, to_index: int) -> dict[str, Any]: with session_scope() as db: rows = list( db.scalars( select(ZootechSkladStock) .where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.is_deleted.is_(False), ) .order_by(ZootechSkladStock.sort_order, ZootechSkladStock.component_id) ) ) if not rows: return {"success": False, "error": "Склад пуст"} ids = [row.component_id for row in rows] if component_id not in ids: return {"success": False, "error": "Компонент не найден на складе"} from_index = ids.index(component_id) target = max(0, min(int(to_index), len(rows) - 1)) if from_index == target: return {"success": True} moved = rows.pop(from_index) rows.insert(target, moved) for idx, row in enumerate(rows): row.sort_order = idx row.version = int(row.version or 0) + 1 return {"success": True} def sklad_chart_points( enterprise_id: str, component_id: str, *, date_from: str | None = None, date_to: str | None = None, ) -> dict[str, Any]: components = _component_lookup(enterprise_id) comp_name = components.get(component_id, component_id) end = datetime.now(UTC) start = end - timedelta(days=30) if date_from and date_to: start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC) end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) with session_scope() as db: row = db.scalar( select(ZootechSkladStock).where( ZootechSkladStock.enterprise_id == enterprise_id, ZootechSkladStock.component_id == component_id, ZootechSkladStock.is_deleted.is_(False), ) ) total_kg = float(row.total_kg or 0) if row else 0.0 inflow_kg = float(row.inflow_kg or 0) if row else 0.0 baseline = total_kg + inflow_kg by_day = _consumption_by_day(enterprise_id, component_id, comp_name, (start, end)) points: list[dict[str, Any]] = [] cumulative = 0.0 day = start.date() end_day = (end - timedelta(seconds=1)).date() while day <= end_day: day_key = day.isoformat() cumulative += by_day.get(day_key, 0.0) points.append( { "date": day_key, "remaining_kg": round(max(0.0, baseline - cumulative), 2), } ) day += timedelta(days=1) return {"points": points}