"""WESP-shaped HTTP API for copied static UI (orchestrator zootech catalog).""" from __future__ import annotations import json 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.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.models import ZootechComponent, ZootechIngredient, ZootechRecipe router = APIRouter() 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) -> dict[str, Any]: return { "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, } 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 _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) -> 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": False, **{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) for r in rows] @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) row = load_catalog_row(enterprise_id, "component", component_id) if not row: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") return { "id": row["id"], "name": row["name"], "type": row.get("type", ""), "is_active": row.get("is_active", True), "dryMatter": row.get("dry_matter", 0), "protein": row.get("protein", 0), "energy": row.get("energy", 0), "price": row.get("price", 0), "externalNo": row.get("external_no"), "version": row.get("version", 1), "content_hash": row.get("content_hash", ""), } @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) 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] result.append(_dispenser_wesp(d, period_payload)) return result @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) 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), ) ) ) return [{"id": p.id, "name": p.name, "dispenser_id": p.dispenser_id} for p in periods] 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: 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 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.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") return _serialize_recipe_wesp(db, enterprise_id, recipe) @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}") 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