Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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}