Files
site/apps/api/app/modules/zootech/wesp_compat.py
T

436 lines
14 KiB
Python

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