452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""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}
|