"""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