"""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}