950 lines
33 KiB
Python
950 lines
33 KiB
Python
"""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
|