546 lines
20 KiB
Python
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}
|