"""HTTP routes for the planning engine. Routers stay thin: validate via Pydantic, call the service, map domain errors to HTTP. All allocation logic lives in `app.services.allocation_service`, and all bulk-apply logic (idempotency, collisions, `replace`, validation) lives in `app.services.planning_service.PlanningService.apply_proposed_days`. See PLANNING_ENGINE_SPEC.md §4. """ from fastapi import APIRouter, HTTPException, status from app.dependencies import PlanningEngineServiceDep, PlanningServiceDep from app.models.plan import ( PlanApplyRequest, PlanApplyResponse, PlanProposalResponse, PlanProposeRequest, ) from app.utils.errors import NotFoundError, ValidationError router = APIRouter(prefix="/api/plan", tags=["plan"]) @router.post("/propose", response_model=PlanProposalResponse) async def propose_plan( payload: PlanProposeRequest, service: PlanningEngineServiceDep ) -> PlanProposalResponse: """Compute a plan proposal. Read-only — persists nothing, safe to spam.""" try: proposal = await service.propose(payload.workspace_id) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc except ValidationError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc return PlanProposalResponse.from_domain(proposal) @router.post("/apply", response_model=PlanApplyResponse) async def apply_plan(payload: PlanApplyRequest, service: PlanningServiceDep) -> PlanApplyResponse: """Accept a (possibly user-edited) set of proposed days as real plan. Always returns 200 with `accepted: false` and a `rejections`/`collisions` breakdown when the batch is refused — refusal (a collision without `replace`, an invalid day type, a balance/eligibility violation) is an expected, structured outcome for the UI to render, not an HTTP error. A genuinely missing workspace/child (404) is the one case still raised as an HTTP error, since that's not something the batch report can explain. """ try: result = await service.apply_proposed_days( workspace_id=payload.workspace_id, days=[item.to_domain() for item in payload.days], replace=payload.replace, ) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc return PlanApplyResponse.from_domain(result)