Files
site/apps/api/app/modules/zootech/wesp_compat_daily_plan.py
2026-07-17 12:57:18 +03:00

546 lines
20 KiB
Python

"""WESP-shaped daily plan API (/api/daily-plan)."""
from __future__ import annotations
from datetime import date as date_cls
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
from app.modules.zootech import wesp_daily_plan_service as dp_service
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 _skip_duration_kwargs(data: dict[str, Any]) -> dict[str, Any]:
return {
"duration": data.get("duration") or data.get("skipDuration"),
"until_date": data.get("untilDate") or data.get("until_date") or data.get("validUntil"),
}
def _parse_plan_date_route(value) -> date_cls:
try:
return date_cls.fromisoformat(str(value).strip()[:10])
except (TypeError, ValueError):
return date_cls.today()
def _lookup_error(exc: LookupError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": str(exc)},
)
@router.get("/daily-plan")
def get_daily_plan(
enterprise_id: str = Query(...),
dispenser_id: str = Query(..., alias="dispenser_id"),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
if not (dispenser_id or "").strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "dispenser_id обязателен"},
)
try:
return dp_service.build_daily_plan(enterprise_id, dispenser_id=dispenser_id.strip(), plan_date=date)
except LookupError as exc:
raise _lookup_error(exc) from exc
@router.get("/daily-plan/pdf")
def get_daily_plan_pdf(
enterprise_id: str = Query(...),
dispenser_id: str = Query(..., alias="dispenser_id"),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
if not (dispenser_id or "").strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "dispenser_id обязателен"},
)
try:
plan = dp_service.build_daily_plan(enterprise_id, dispenser_id=dispenser_id.strip(), plan_date=date)
except LookupError as exc:
raise _lookup_error(exc) from exc
pdf_bytes = dp_service.build_daily_plan_pdf(plan)
filename = f"plan-{plan.get('date', 'day')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/daily-plan/skips")
def get_daily_plan_skips(
enterprise_id: str = Query(...),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
return dp_service.list_all_skips(enterprise_id, date)
@router.post("/daily-plan/skips")
def post_daily_plan_skip(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
if not recipe_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipeId обязателен"},
)
try:
row = dp_service.skip_trip(
enterprise_id,
recipe_id,
body.get("date"),
user="system",
**_skip_duration_kwargs(body),
)
except LookupError as exc:
raise _lookup_error(exc) from exc
return dp_service._skip_row_response(row)
@router.delete("/daily-plan/skips")
def delete_daily_plan_skip(
enterprise_id: str = Query(...),
recipe_id: str | None = Query(None, alias="recipe_id"),
recipeId: str | None = Query(None),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
rid = (recipe_id or recipeId or "").strip()
if not rid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipe_id обязателен"},
)
if not dp_service.unskip_trip(enterprise_id, rid, date):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Исключение не найдено"},
)
return {"ok": True}
@router.post("/daily-plan/skips/ingredients")
def post_daily_plan_ingredient_skip(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
ingredient_id = (body.get("ingredientId") or body.get("ingredient_id") or "").strip()
if not recipe_id or not ingredient_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipeId и ingredientId обязательны"},
)
try:
row = dp_service.skip_ingredient(
enterprise_id,
recipe_id,
ingredient_id,
body.get("date"),
user="system",
**_skip_duration_kwargs(body),
)
except LookupError as exc:
raise _lookup_error(exc) from exc
return dp_service._skip_row_response(
row,
id_key="ingredient_id",
extra={"ingredientId": row.get("ingredient_id")},
)
@router.delete("/daily-plan/skips/ingredients")
def delete_daily_plan_ingredient_skip(
enterprise_id: str = Query(...),
recipe_id: str | None = Query(None, alias="recipe_id"),
recipeId: str | None = Query(None),
ingredient_id: str | None = Query(None, alias="ingredient_id"),
ingredientId: str | None = Query(None),
date: str | None = Query(None),
all: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
rid = (recipe_id or recipeId or "").strip()
iid = (ingredient_id or ingredientId or "").strip()
if not rid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipe_id обязателен"},
)
if all in ("1", "true", "yes"):
count = dp_service.unskip_all_ingredient_parts(enterprise_id, rid, date)
if not count:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Исключения не найдены"},
)
return {"ok": True, "count": count}
if not iid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "ingredient_id обязателен"},
)
if not dp_service.unskip_ingredient(enterprise_id, rid, iid, date):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Исключение не найдено"},
)
return {"ok": True}
@router.post("/daily-plan/skips/unloading-groups")
def post_daily_plan_unloading_group_skip(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
group_id = (
body.get("unloadingGroupId")
or body.get("unloading_group_id")
or body.get("groupId")
or ""
).strip()
if not recipe_id or not group_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipeId и unloadingGroupId обязательны"},
)
try:
row = dp_service.skip_unloading_group(
enterprise_id,
recipe_id,
group_id,
body.get("date"),
user="system",
**_skip_duration_kwargs(body),
)
except LookupError as exc:
raise _lookup_error(exc) from exc
return dp_service._skip_row_response(row, extra={"unloadingGroupId": row.get("unloading_group_id")})
@router.delete("/daily-plan/skips/unloading-groups")
def delete_daily_plan_unloading_group_skip(
enterprise_id: str = Query(...),
recipe_id: str | None = Query(None, alias="recipe_id"),
recipeId: str | None = Query(None),
unloading_group_id: str | None = Query(None, alias="unloading_group_id"),
unloadingGroupId: str | None = Query(None),
groupId: str | None = Query(None),
date: str | None = Query(None),
all: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
rid = (recipe_id or recipeId or "").strip()
gid = (unloading_group_id or unloadingGroupId or groupId or "").strip()
if not rid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipe_id обязателен"},
)
if all in ("1", "true", "yes"):
count = dp_service.unskip_all_unloading_group_parts(enterprise_id, rid, date)
if not count:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Исключения не найдены"},
)
return {"ok": True, "count": count}
if not gid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "unloading_group_id обязателен"},
)
if not dp_service.unskip_unloading_group(enterprise_id, rid, gid, date):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Исключение не найдено"},
)
return {"ok": True}
@router.get("/daily-plan/component-alternatives")
def get_component_alternatives(
enterprise_id: str = Query(...),
component_id: str | None = Query(None, alias="component_id"),
componentId: str | None = Query(None),
q: str | None = Query(None),
query: str | None = Query(None),
limit: int = Query(20),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
cid = (component_id or componentId or "").strip()
if not cid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "component_id обязателен"},
)
try:
return dp_service.find_component_alternatives(
enterprise_id,
cid,
query=q or query or "",
limit=limit,
)
except LookupError as exc:
raise _lookup_error(exc) from exc
@router.post("/daily-plan/replacements/ingredients")
def post_daily_plan_ingredient_replacement(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
recipe_id = (body.get("recipeId") or body.get("recipe_id") or "").strip()
ingredient_id = (body.get("ingredientId") or body.get("ingredient_id") or "").strip()
replacement_id = (
body.get("replacementComponentId")
or body.get("replacement_component_id")
or body.get("componentId")
or ""
).strip()
if not recipe_id or not ingredient_id or not replacement_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": True,
"message": "recipeId, ingredientId и replacementComponentId обязательны",
},
)
try:
row = dp_service.replace_ingredient(
enterprise_id,
recipe_id,
ingredient_id,
replacement_id,
body.get("date"),
user="system",
**_skip_duration_kwargs(body),
)
except LookupError as exc:
raise _lookup_error(exc) from exc
return dp_service._skip_row_response(
row,
extra={
"ingredientId": row.get("ingredient_id"),
"replacementComponentId": row.get("replacement_component_id"),
},
)
@router.post("/daily-plan/replacements/components")
def post_daily_plan_component_replacement(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
component_id = (body.get("componentId") or body.get("component_id") or "").strip()
replacement_id = (
body.get("replacementComponentId") or body.get("replacement_component_id") or ""
).strip()
dispenser_id = (body.get("dispenserId") or body.get("dispenser_id") or "").strip()
if not component_id or not replacement_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "componentId и replacementComponentId обязательны"},
)
if not dispenser_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "dispenserId обязателен"},
)
try:
rows = dp_service.replace_component_in_plan(
enterprise_id,
component_id,
replacement_id,
body.get("date"),
dispenser_id=dispenser_id,
user="system",
**_skip_duration_kwargs(body),
)
except LookupError as exc:
raise _lookup_error(exc) from exc
return {
"ok": True,
"count": len(rows),
"componentId": component_id,
"replacementComponentId": replacement_id,
"recipeIds": sorted({str(row.get("recipe_id")) for row in rows if row.get("recipe_id")}),
}
@router.delete("/daily-plan/replacements/ingredients")
def delete_daily_plan_ingredient_replacement(
enterprise_id: str = Query(...),
recipe_id: str | None = Query(None, alias="recipe_id"),
recipeId: str | None = Query(None),
ingredient_id: str | None = Query(None, alias="ingredient_id"),
ingredientId: str | None = Query(None),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
rid = (recipe_id or recipeId or "").strip()
iid = (ingredient_id or ingredientId or "").strip()
if not rid or not iid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "recipe_id и ingredient_id обязательны"},
)
if not dp_service.undo_ingredient_replacement(enterprise_id, rid, iid, date):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Замена не найдена"},
)
return {"ok": True}
@router.get("/daily-plan/component-norms")
def get_component_norms(
enterprise_id: str = Query(...),
dispenser_id: str = Query(..., alias="dispenser_id"),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
if not (dispenser_id or "").strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "dispenser_id обязателен"},
)
rows = dp_service.list_component_norms_for_plan(
enterprise_id,
date,
dispenser_id=dispenser_id.strip(),
)
return {"items": rows, "date": date}
@router.post("/daily-plan/adjustments/components")
def post_component_norm_adjustment(
body: dict,
enterprise_id: str = Query(...),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
component_id = (body.get("componentId") or body.get("component_id") or "").strip()
if not component_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "componentId обязателен"},
)
wph = body.get("weightPerHead", body.get("weight_per_head"))
dm_ph = body.get("dryMatterPerHead", body.get("dry_matter_per_head"))
dm = body.get("dryMatter", body.get("dryMatterPct", body.get("dry_matter")))
dm_locked = body.get("dryMatterLocked", body.get("dry_matter_locked", False))
try:
wph_val = float(wph) if wph is not None and wph != "" else None
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "weightPerHead должен быть числом"},
) from exc
try:
dm_val = float(dm_ph) if dm_ph is not None and dm_ph != "" else None
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "dryMatterPerHead должен быть числом"},
) from exc
try:
dm_pct_val = float(dm) if dm is not None and dm != "" else None
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "dryMatter должен быть числом"},
) from exc
try:
row = dp_service.adjust_component_norm(
enterprise_id,
component_id,
body.get("date"),
dry_matter=dm_pct_val,
dry_matter_locked=bool(dm_locked),
weight_per_head=wph_val,
dry_matter_per_head=dm_val,
user="system",
**_skip_duration_kwargs(body),
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": str(exc)},
) from exc
except LookupError as exc:
raise _lookup_error(exc) from exc
end = row.get("valid_until") or row.get("plan_date")
return {
"ok": True,
"componentId": row.get("component_id"),
"dryMatter": row.get("dry_matter"),
"dryMatterLocked": bool(row.get("dry_matter_locked")),
"weightPerHead": row.get("weight_per_head"),
"dryMatterPerHead": row.get("dry_matter_per_head"),
"date": str(row.get("plan_date") or "")[:10],
"validUntil": str(end)[:10] if end else str(row.get("plan_date") or "")[:10],
}
@router.delete("/daily-plan/adjustments/components")
def delete_component_norm_adjustment(
enterprise_id: str = Query(...),
component_id: str | None = Query(None, alias="component_id"),
componentId: str | None = Query(None),
date: str | None = Query(None),
tenant: TenantContext = Depends(require_enterprise_zootech),
):
_require_ent(enterprise_id, tenant)
cid = (component_id or componentId or "").strip()
if not cid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": True, "message": "component_id обязателен"},
)
if not dp_service.undo_component_norm_adjustment(enterprise_id, cid, date):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": True, "message": "Правка нормы не найдена"},
)
return {"ok": True}