436 lines
14 KiB
Python
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
|