Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user