@@ -0,0 +1,451 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user