Files
2026-07-17 12:57:18 +03:00

950 lines
33 KiB
Python

"""WESP-shaped HTTP API for copied static UI (orchestrator zootech catalog)."""
from __future__ import annotations
import json
from datetime import date as date_cls
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from app.core.database import session_scope
from app.db.base import Base
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
from app.modules.zootech.catalog_apply import load_catalog_row
from app.modules.zootech.catalog_models import (
ZootechFeedDispenser,
ZootechFeedingPeriod,
ZootechPeriodRecipe,
ZootechUnloadingGroup,
)
from app.modules.zootech import service as zootech_service
from app.modules.zootech.service import ZootechServiceError
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
router = APIRouter()
_LAB_NUTRIENT_MODEL = None
def _lab_nutrient_model():
global _LAB_NUTRIENT_MODEL
if _LAB_NUTRIENT_MODEL is not False and _LAB_NUTRIENT_MODEL is None:
try:
from sqlalchemy import Float, String, inspect
from sqlalchemy.orm import Mapped, mapped_column
class ZootechLabComponentNutrientValue(Base):
__tablename__ = "lab_component_nutrient_value"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
component_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
nutrient_key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
value: Mapped[float | None] = mapped_column(Float, nullable=True)
with session_scope() as db:
if "lab_component_nutrient_value" not in inspect(db.bind).get_table_names():
_LAB_NUTRIENT_MODEL = False
else:
_LAB_NUTRIENT_MODEL = ZootechLabComponentNutrientValue
except Exception:
_LAB_NUTRIENT_MODEL = False
return _LAB_NUTRIENT_MODEL if _LAB_NUTRIENT_MODEL not in (None, False) else None
def _component_nutrients_dict(db, component_id: str) -> dict[str, float]:
model = _lab_nutrient_model()
if model is None:
return {}
rows = list(
db.scalars(select(model).where(model.component_id == component_id))
)
return {
row.nutrient_key: float(row.value)
for row in rows
if row.value is not None and row.nutrient_key != "СВ"
}
def _save_component_nutrients(db, component_id: str, nutrients: dict) -> None:
from uuid import uuid4
model = _lab_nutrient_model()
if model is None or not isinstance(nutrients, dict):
return
existing = {
row.nutrient_key: row
for row in db.scalars(select(model).where(model.component_id == component_id))
}
for key, value in nutrients.items():
if value is None or key == "СВ":
continue
row = existing.get(key)
if row is None:
db.add(
model(
id=str(uuid4()),
component_id=component_id,
nutrient_key=str(key),
value=float(value),
)
)
else:
row.value = float(value)
def _require_ent(enterprise_id: str, tenant: TenantContext) -> None:
if tenant.enterprise_id != enterprise_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN")
def _component_wesp(row: ZootechComponent, db=None) -> dict[str, Any]:
payload = {
"id": row.id,
"name": row.name,
"type": row.type,
"is_active": row.is_active,
"dryMatter": row.dry_matter,
"protein": row.protein,
"energy": row.energy,
"price": row.price,
"externalNo": row.external_no,
"version": row.version,
"content_hash": row.content_hash,
}
if db is not None:
nutrients = _component_nutrients_dict(db, row.id)
if nutrients:
payload["nutrients"] = nutrients
return payload
def _component_body_from_wesp(body: dict[str, Any]) -> dict[str, Any]:
normalized = dict(body)
if "externalNo" in body and "external_no" not in normalized:
normalized["external_no"] = body["externalNo"]
if "dryMatter" in body and "dry_matter" not in normalized:
normalized["dry_matter"] = body["dryMatter"]
return normalized
def _recipe_short(row: ZootechRecipe) -> dict[str, Any]:
return {
"id": row.id,
"name": row.name,
"heads_count": row.heads_per_trip,
"mixing_time": row.mixing_time,
"trip_percent": row.trip_percent,
}
def _payload_meta(row) -> dict[str, Any]:
if not row.payload_json:
return {}
try:
parsed = json.loads(row.payload_json)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _mill_recipes_for_dispenser(
db,
enterprise_id: str,
dispenser_id: str,
exclude_ids: set[str],
) -> list[dict]:
recipes = list(
db.scalars(
select(ZootechRecipe).where(
ZootechRecipe.enterprise_id == enterprise_id,
ZootechRecipe.is_deleted.is_(False),
)
)
)
out: list[dict] = []
for recipe in recipes:
if recipe.id in exclude_ids:
continue
meta = _payload_meta(recipe)
if str(meta.get("dispenser_id") or "") == str(dispenser_id):
out.append(_recipe_short(recipe))
return out
def _period_wesp(db, enterprise_id: str, period: ZootechFeedingPeriod, *, has_skip_today: bool = False) -> dict[str, Any]:
return {
"id": period.id,
"name": period.name,
"dispenser_id": period.dispenser_id,
"recipes": _recipes_for_period(db, enterprise_id, period.id),
"hasSkipToday": has_skip_today,
}
def _ingredient_wesp(row: ZootechIngredient, comp_name: str | None = None) -> dict[str, Any]:
wph = float(row.weight_per_head or 0)
dm_pct = float(row.dry_matter or 0)
dm_ph = float(row.dry_matter_per_head or 0)
if not dm_ph and wph > 0 and dm_pct > 0:
dm_ph = wph * (dm_pct / 100.0)
name = (row.name or "").strip() or (comp_name or "") or "—"
return {
"id": row.id,
"name": name,
"weightPerHead": wph,
"weight_per_head": wph,
"amount": float(row.amount or 0),
"dry_matter": dm_pct,
"dry_matter_per_head": dm_ph,
"order": int(row.order or 0),
"component_id": row.component_id,
"version": row.version,
}
def _unloading_group_wesp(row: ZootechUnloadingGroup) -> dict[str, Any]:
extra: dict[str, Any] = {}
if row.payload_json:
try:
extra = json.loads(row.payload_json)
except json.JSONDecodeError:
pass
distribution_type = str(extra.get("distribution_type") or extra.get("distributionType") or "percent")
value = float(extra.get("value") or 0)
weight = float(extra.get("weight") or 0)
order = int(extra.get("order") or 0)
base = {
"id": row.id,
"name": row.name,
"distributionType": distribution_type,
"distribution_type": distribution_type,
"value": value,
"weight": weight,
"order": order,
"version": row.version,
}
for key in ("created_at", "updated_at", "created_by", "updated_by"):
if key in extra:
base[key] = extra[key]
return base
def _serialize_recipe_wesp(db, enterprise_id: str, recipe: ZootechRecipe) -> dict[str, Any]:
ingredients = list(
db.scalars(
select(ZootechIngredient)
.where(
ZootechIngredient.enterprise_id == enterprise_id,
ZootechIngredient.recipe_id == recipe.id,
ZootechIngredient.is_deleted.is_(False),
)
.order_by(ZootechIngredient.order.asc())
)
)
comp_ids = [i.component_id for i in ingredients if i.component_id]
comp_names: dict[str, str] = {}
if comp_ids:
for comp in db.scalars(
select(ZootechComponent).where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.id.in_(comp_ids),
)
):
comp_names[comp.id] = comp.name
groups = list(
db.scalars(
select(ZootechUnloadingGroup)
.where(
ZootechUnloadingGroup.enterprise_id == enterprise_id,
ZootechUnloadingGroup.recipe_id == recipe.id,
ZootechUnloadingGroup.is_deleted.is_(False),
)
)
)
groups.sort(key=lambda g: int((_unloading_group_wesp(g).get("order") or 0)))
unloading_groups = [_unloading_group_wesp(g) for g in groups]
return {
"id": recipe.id,
"name": recipe.name,
"headsPerTrip": recipe.heads_per_trip,
"mixingTime": recipe.mixing_time,
"tripPercent": recipe.trip_percent,
"heads_count": recipe.heads_per_trip,
"mixing_time": recipe.mixing_time,
"trip_percent": recipe.trip_percent,
"dryMatterLocked": recipe.dry_matter_locked,
"dry_matter_locked": recipe.dry_matter_locked,
"unloading_link_broken": recipe.unloading_link_broken,
"unloadingLinkBroken": recipe.unloading_link_broken,
"target_component_id": recipe.target_component_id,
"version": recipe.version,
"ingredients": [_ingredient_wesp(i, comp_names.get(i.component_id or "")) for i in ingredients],
"unloadingGroups": unloading_groups,
"unloading_groups": unloading_groups,
}
def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None, *, has_skip_today: bool = False) -> dict[str, Any]:
extra: dict[str, Any] = {}
if row.payload_json:
try:
extra = json.loads(row.payload_json)
except json.JSONDecodeError:
pass
return {
"id": row.id,
"name": row.name,
"version": row.version,
"content_hash": row.content_hash,
"periods": periods or [],
"hasSkipToday": has_skip_today,
**{k: v for k, v in extra.items() if k not in ("id", "name")},
}
@router.get("/components/ping")
def components_ping():
return {"status": "ok"}
@router.get("/components")
def wesp_list_components(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
limit: int = Query(1000),
offset: int = Query(0),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
rows = list(
db.scalars(
select(ZootechComponent)
.where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.is_deleted.is_(False),
ZootechComponent.is_active.is_(True),
)
.offset(offset)
.limit(limit)
)
)
return [_component_wesp(r, db) for r in rows]
@router.post("/components")
def wesp_create_component(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
row = zootech_service.upsert_component(enterprise_id, None, _component_body_from_wesp(body))
with session_scope() as db:
component = db.scalar(
select(ZootechComponent).where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.id == row["id"],
)
)
if not component:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="CREATE_FAILED")
payload = _component_wesp(component, db)
payload["success"] = True
return payload
_FEED_TYPE_OPTIONS = [
{"value": "Грубые корма", "groupLabel": "Корма"},
{"value": "Сочные корма", "groupLabel": "Корма"},
{"value": "Концентрированные", "groupLabel": "Корма"},
{"value": "Добавки", "groupLabel": "Корма"},
]
@router.get("/components/feed-types")
def wesp_component_feed_types(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
return {"types": _FEED_TYPE_OPTIONS}
@router.get("/components/{component_id}")
def wesp_get_component(
component_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
component = db.scalar(
select(ZootechComponent).where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.id == component_id,
ZootechComponent.is_deleted.is_(False),
)
)
if not component:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
return _component_wesp(component, db)
@router.put("/components/{component_id}")
def wesp_update_component(
component_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
nutrients = body.get("nutrients")
try:
zootech_service.patch_component(enterprise_id, component_id, _component_body_from_wesp(body))
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
if isinstance(nutrients, dict):
with session_scope() as db:
_save_component_nutrients(db, component_id, nutrients)
return {"success": True, "message": "Компонент обновлён"}
@router.delete("/components/{component_id}")
def wesp_delete_component(
component_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.delete_component(enterprise_id, component_id)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"success": True, "message": "Компонент удалён"}
@router.get("/feed_dispensers/ping")
def feed_dispensers_ping():
return {"status": "ok"}
@router.get("/feed_dispensers/names")
def feed_dispenser_names(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
rows = list(
db.scalars(
select(ZootechFeedDispenser)
.where(
ZootechFeedDispenser.enterprise_id == enterprise_id,
ZootechFeedDispenser.is_deleted.is_(False),
)
.order_by(ZootechFeedDispenser.name.asc())
)
)
seen: set[str] = set()
names: list[str] = []
for r in rows:
if r.name and r.name not in seen:
seen.add(r.name)
names.append(r.name)
return names
@router.get("/feed_dispensers")
def list_feed_dispensers(
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
limit: int = Query(100),
offset: int = Query(0),
):
_require_ent(enterprise_id, tenant)
from app.modules.zootech import wesp_daily_plan_service as dp_service
today_iso = date_cls.today().isoformat()
skip_today_ids = dp_service.get_recipe_ids_with_any_skip(enterprise_id, today_iso)
with session_scope() as db:
dispensers = list(
db.scalars(
select(ZootechFeedDispenser)
.where(
ZootechFeedDispenser.enterprise_id == enterprise_id,
ZootechFeedDispenser.is_deleted.is_(False),
)
.order_by(ZootechFeedDispenser.created_at.desc())
.offset(offset)
.limit(limit)
)
)
result = []
for d in dispensers:
periods = list(
db.scalars(
select(ZootechFeedingPeriod).where(
ZootechFeedingPeriod.enterprise_id == enterprise_id,
ZootechFeedingPeriod.dispenser_id == d.id,
ZootechFeedingPeriod.is_deleted.is_(False),
)
)
)
period_payload = [{"id": p.id, "name": p.name} for p in periods]
dispenser_has_skip = False
for period in periods:
for recipe in _recipes_for_period(db, enterprise_id, period.id):
if recipe["id"] in skip_today_ids:
dispenser_has_skip = True
break
if dispenser_has_skip:
break
result.append(_dispenser_wesp(d, period_payload, has_skip_today=dispenser_has_skip))
return result
@router.post("/feed_dispensers")
def create_feed_dispenser(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
row = zootech_service.upsert_feed_dispenser(enterprise_id, None, body)
with session_scope() as db:
dispenser = db.scalar(
select(ZootechFeedDispenser).where(
ZootechFeedDispenser.enterprise_id == enterprise_id,
ZootechFeedDispenser.id == row["id"],
)
)
if not dispenser:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="CREATE_FAILED")
return _dispenser_wesp(dispenser, [])
@router.put("/feed_dispensers/{dispenser_id}")
def update_feed_dispenser(
dispenser_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.patch_feed_dispenser(enterprise_id, dispenser_id, body)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"message": "Кормораздатчик обновлён"}
@router.delete("/feed_dispensers/{dispenser_id}")
def delete_feed_dispenser_route(
dispenser_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.delete_feed_dispenser(enterprise_id, dispenser_id)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"message": "Кормораздатчик удалён"}
@router.post("/feed_dispensers/{dispenser_id}/periods")
def create_dispenser_period(
dispenser_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
payload = {"name": body.get("name", ""), "dispenser_id": dispenser_id}
row = zootech_service.upsert_feeding_period(enterprise_id, None, payload)
return {"id": row["id"], "name": row.get("name", ""), "dispenser_id": dispenser_id}
@router.put("/feed_dispensers/{dispenser_id}/periods/{period_id}")
def update_dispenser_period(
dispenser_id: str,
period_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
row = zootech_service.patch_feeding_period(enterprise_id, period_id, {"name": body.get("name", ""), "dispenser_id": dispenser_id})
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"id": row["id"], "name": row.get("name", ""), "dispenser_id": dispenser_id, "success": True}
@router.delete("/feed_dispensers/{dispenser_id}/periods/{period_id}")
def delete_dispenser_period(
dispenser_id: str,
period_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
try:
zootech_service.delete_feeding_period(enterprise_id, period_id)
except ZootechServiceError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None
return {"message": "Период удалён"}
@router.get("/feed_dispensers/{dispenser_id}/periods")
def dispenser_periods(
dispenser_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
from app.modules.zootech import wesp_daily_plan_service as dp_service
today_iso = date_cls.today().isoformat()
skip_today_ids = dp_service.get_recipe_ids_with_any_skip(enterprise_id, today_iso)
with session_scope() as db:
periods = list(
db.scalars(
select(ZootechFeedingPeriod).where(
ZootechFeedingPeriod.enterprise_id == enterprise_id,
ZootechFeedingPeriod.dispenser_id == dispenser_id,
ZootechFeedingPeriod.is_deleted.is_(False),
)
)
)
result = []
for p in periods:
recipes = _recipes_for_period(db, enterprise_id, p.id)
period_has_skip = any(r["id"] in skip_today_ids for r in recipes)
result.append(_period_wesp(db, enterprise_id, p, has_skip_today=period_has_skip))
return result
def _recipes_for_period(db, enterprise_id: str, period_id: str) -> list[dict]:
links = list(
db.scalars(
select(ZootechPeriodRecipe)
.where(
ZootechPeriodRecipe.enterprise_id == enterprise_id,
ZootechPeriodRecipe.period_id == period_id,
ZootechPeriodRecipe.is_deleted.is_(False),
)
.order_by(ZootechPeriodRecipe.order.asc())
)
)
out: list[dict] = []
for link in links:
recipe = db.scalar(
select(ZootechRecipe).where(
ZootechRecipe.enterprise_id == enterprise_id,
ZootechRecipe.id == link.recipe_id,
ZootechRecipe.is_deleted.is_(False),
)
)
if recipe:
out.append(_recipe_short(recipe))
return out
@router.get("/feed_dispensers/{dispenser_id}/recipes")
def dispenser_recipes(
dispenser_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
dispenser = db.scalar(
select(ZootechFeedDispenser).where(
ZootechFeedDispenser.enterprise_id == enterprise_id,
ZootechFeedDispenser.id == dispenser_id,
ZootechFeedDispenser.is_deleted.is_(False),
)
)
if not dispenser:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
periods = list(
db.scalars(
select(ZootechFeedingPeriod).where(
ZootechFeedingPeriod.enterprise_id == enterprise_id,
ZootechFeedingPeriod.dispenser_id == dispenser_id,
ZootechFeedingPeriod.is_deleted.is_(False),
)
)
)
merged: dict[str, dict] = {}
for p in periods:
for r in _recipes_for_period(db, enterprise_id, p.id):
merged[r["id"]] = r
is_mill = _payload_meta(dispenser).get("type") == "mill"
mill_recipes: list[dict] = []
if is_mill:
mill_recipes = _mill_recipes_for_dispenser(db, enterprise_id, dispenser_id, set(merged.keys()))
for r in mill_recipes:
merged[r["id"]] = r
return list(merged.values())
@router.get("/periods/{period_id}/recipes")
def period_recipes(
period_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
period = db.scalar(
select(ZootechFeedingPeriod).where(
ZootechFeedingPeriod.enterprise_id == enterprise_id,
ZootechFeedingPeriod.id == period_id,
ZootechFeedingPeriod.is_deleted.is_(False),
)
)
if not period:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
return _recipes_for_period(db, enterprise_id, period_id)
@router.post("/periods/{period_id}/recipes")
def wesp_create_period_recipe(
period_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, create_recipe_wesp, recipe_write_http_error
_require_ent(enterprise_id, tenant)
try:
return create_recipe_wesp(enterprise_id, body, period_id=period_id)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.post("/recipes")
def wesp_create_recipe(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, create_recipe_wesp, recipe_write_http_error
_require_ent(enterprise_id, tenant)
try:
return create_recipe_wesp(enterprise_id, body)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
def _apply_trip_overlay(payload: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
result = dict(payload)
overlay_ings = {row["id"]: row for row in overlay.get("ingredients") or []}
overlay_groups = {row["id"]: row for row in overlay.get("unloadingGroups") or []}
ingredients = []
for ing in payload.get("ingredients") or []:
row = dict(ing)
trip_row = overlay_ings.get(row.get("id"))
if trip_row:
row["name"] = trip_row.get("name") or row.get("name")
row["weightPerHead"] = trip_row.get("weightPerHead", row.get("weightPerHead"))
row["weight_per_head"] = row["weightPerHead"]
row["amount"] = trip_row.get("totalKg", row.get("amount"))
if trip_row.get("componentId"):
row["component_id"] = trip_row["componentId"]
if trip_row.get("skippedToday"):
row["skippedToday"] = True
if trip_row.get("replacedToday"):
row["replacedToday"] = True
if trip_row.get("adjustedToday"):
row["adjustedToday"] = True
ingredients.append(row)
groups = []
for grp in payload.get("unloadingGroups") or []:
row = dict(grp)
trip_grp = overlay_groups.get(row.get("id"))
if trip_grp:
if trip_grp.get("weightKg") is not None:
row["weight"] = trip_grp["weightKg"]
if trip_grp.get("skippedToday"):
row["skippedToday"] = True
groups.append(row)
result["ingredients"] = ingredients
result["unloadingGroups"] = groups
result["unloading_groups"] = groups
if overlay.get("totalWeightKg") is not None:
result["totalWeightKg"] = overlay["totalWeightKg"]
result["total_weight"] = overlay["totalWeightKg"]
if overlay.get("unloadingTotalKg") is not None:
result["unloadingTotalKg"] = overlay["unloadingTotalKg"]
return result
@router.get("/recipes/{recipe_id}")
def wesp_get_recipe(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
date: str | None = Query(None),
):
_require_ent(enterprise_id, tenant)
with session_scope() as db:
recipe = db.scalar(
select(ZootechRecipe).where(
ZootechRecipe.enterprise_id == enterprise_id,
ZootechRecipe.id == recipe_id,
ZootechRecipe.is_deleted.is_(False),
)
)
if not recipe:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND")
payload = _serialize_recipe_wesp(db, enterprise_id, recipe)
if date:
from app.modules.zootech import wesp_daily_plan_service as dp_service
overlay = dp_service.trip_overlay_for_recipe(enterprise_id, recipe, date)
payload = _apply_trip_overlay(payload, overlay)
return payload
@router.get("/recipes/{recipe_id}/open-context")
def wesp_recipe_open_context(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
from app.modules.zootech import wesp_daily_plan_service as dp_service
try:
return dp_service.recipe_open_context(enterprise_id, recipe_id)
except LookupError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": str(exc)},
) from exc
@router.post("/recipes/calculate")
def wesp_calculate_recipe(body: dict):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, calculate_recipe_wesp, recipe_write_http_error
try:
return calculate_recipe_wesp(body)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.put("/recipes/{recipe_id}/move")
def wesp_move_recipe_in_period(
recipe_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import (
RecipeWriteError,
move_recipe_in_period_wesp,
recipe_write_http_error,
)
_require_ent(enterprise_id, tenant)
try:
return move_recipe_in_period_wesp(
enterprise_id,
recipe_id,
period_id=str(body.get("period_id") or ""),
from_index=body.get("from_index", 0),
to_index=body.get("to_index", 0),
)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.put("/recipes/{recipe_id}")
def wesp_update_recipe(
recipe_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, recipe_write_http_error, update_recipe_wesp
_require_ent(enterprise_id, tenant)
try:
return update_recipe_wesp(enterprise_id, recipe_id, body)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.post("/feed_dispensers/{dispenser_id}/periods/{period_id}/recipes/{recipe_id}/transfer")
def wesp_transfer_period_recipe(
dispenser_id: str,
period_id: str,
recipe_id: str,
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import (
RecipeWriteError,
recipe_write_http_error,
transfer_recipe_between_periods_wesp,
)
_require_ent(enterprise_id, tenant)
try:
return transfer_recipe_between_periods_wesp(
enterprise_id,
dispenser_id,
period_id,
recipe_id,
from_dispenser_id=str(body.get("from_dispenser_id") or ""),
from_period_id=str(body.get("from_period_id") or ""),
to_index=body.get("to_index", 0),
)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.delete("/feed_dispensers/{dispenser_id}/periods/{period_id}/recipes/{recipe_id}")
def wesp_unlink_period_recipe(
dispenser_id: str,
period_id: str,
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, recipe_write_http_error, unlink_recipe_from_period_wesp
_require_ent(enterprise_id, tenant)
try:
return unlink_recipe_from_period_wesp(enterprise_id, dispenser_id, period_id, recipe_id)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc
@router.delete("/recipes/{recipe_id}")
def wesp_delete_recipe(
recipe_id: str,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
from app.modules.zootech.wesp_recipe_write import RecipeWriteError, delete_recipe_wesp, recipe_write_http_error
_require_ent(enterprise_id, tenant)
try:
return delete_recipe_wesp(enterprise_id, recipe_id)
except RecipeWriteError as exc:
raise recipe_write_http_error(exc) from exc