# ================================================================ # services/procurement/app/api/procurement_plans.py # ================================================================ """ Procurement Plans API - Endpoints for procurement planning """ import uuid from typing import List, Optional from datetime import date from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.config import settings from app.services.procurement_service import ProcurementService from app.schemas.procurement_schemas import ( ProcurementPlanResponse, GeneratePlanRequest, GeneratePlanResponse, AutoGenerateProcurementRequest, AutoGenerateProcurementResponse, PaginatedProcurementPlans, ) from shared.routing import RouteBuilder import structlog logger = structlog.get_logger() # Create route builder for consistent URL structure route_builder = RouteBuilder('procurement') router = APIRouter(tags=["procurement-plans"]) def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService: """Dependency to get procurement service""" return ProcurementService(db, settings) # ================================================================ # ORCHESTRATOR ENTRY POINT # ================================================================ @router.post( route_builder.build_operations_route("auto-generate"), response_model=AutoGenerateProcurementResponse ) async def auto_generate_procurement( request_data: AutoGenerateProcurementRequest, tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service), db: AsyncSession = Depends(get_db) ): """ Auto-generate procurement plan from forecast data (called by Orchestrator) This is the main entry point for orchestrated procurement planning. The Orchestrator calls Forecasting Service first, then passes forecast data here. Flow: 1. Receive forecast data from orchestrator 2. Calculate procurement requirements 3. Apply Recipe Explosion for locally-produced items 4. Create procurement plan 5. Optionally create and auto-approve purchase orders Returns: AutoGenerateProcurementResponse with plan details and created POs """ try: logger.info("Auto-generate procurement endpoint called", tenant_id=tenant_id, has_forecast_data=bool(request_data.forecast_data)) result = await service.auto_generate_procurement( tenant_id=uuid.UUID(tenant_id), request=request_data ) return result except Exception as e: logger.error("Error in auto_generate_procurement endpoint", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # MANUAL PROCUREMENT PLAN GENERATION # ================================================================ @router.post( route_builder.build_base_route("plans"), response_model=GeneratePlanResponse ) async def generate_procurement_plan( request_data: GeneratePlanRequest, tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service) ): """ Generate a new procurement plan (manual/UI-driven) This endpoint is used for manual procurement planning from the UI. Unlike auto_generate_procurement, this generates its own forecasts. Args: tenant_id: Tenant UUID request_data: Plan generation parameters Returns: GeneratePlanResponse with the created plan """ try: logger.info("Generate procurement plan endpoint called", tenant_id=tenant_id, plan_date=request_data.plan_date) result = await service.generate_procurement_plan( tenant_id=uuid.UUID(tenant_id), request=request_data ) return result except Exception as e: logger.error("Error generating procurement plan", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # PROCUREMENT PLAN CRUD # ================================================================ @router.get( route_builder.build_base_route("plans/current"), response_model=Optional[ProcurementPlanResponse] ) async def get_current_plan( tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service) ): """Get the current day's procurement plan""" try: plan = await service.get_current_plan(uuid.UUID(tenant_id)) return plan except Exception as e: logger.error("Error getting current plan", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) @router.get( route_builder.build_resource_detail_route("plans", "plan_id"), response_model=ProcurementPlanResponse ) async def get_plan_by_id( plan_id: str, tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service) ): """Get procurement plan by ID""" try: plan = await service.get_plan_by_id(uuid.UUID(tenant_id), uuid.UUID(plan_id)) if not plan: raise HTTPException(status_code=404, detail="Plan not found") return plan except HTTPException: raise except Exception as e: logger.error("Error getting plan by ID", error=str(e), tenant_id=tenant_id, plan_id=plan_id) raise HTTPException(status_code=500, detail=str(e)) @router.get( route_builder.build_base_route("plans/date/{plan_date}"), response_model=Optional[ProcurementPlanResponse] ) async def get_plan_by_date( plan_date: date, tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service) ): """Get procurement plan for a specific date""" try: plan = await service.get_plan_by_date(uuid.UUID(tenant_id), plan_date) return plan except Exception as e: logger.error("Error getting plan by date", error=str(e), tenant_id=tenant_id, plan_date=plan_date) raise HTTPException(status_code=500, detail=str(e)) @router.get( route_builder.build_base_route("plans"), response_model=PaginatedProcurementPlans ) async def list_procurement_plans( tenant_id: str = Path(..., description="Tenant ID"), skip: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=100), service: ProcurementService = Depends(get_procurement_service), db: AsyncSession = Depends(get_db) ): """List all procurement plans for tenant with pagination""" try: from app.repositories.procurement_plan_repository import ProcurementPlanRepository repo = ProcurementPlanRepository(db) plans = await repo.list_plans(uuid.UUID(tenant_id), skip=skip, limit=limit) total = await repo.count_plans(uuid.UUID(tenant_id)) plans_response = [ProcurementPlanResponse.model_validate(p) for p in plans] return PaginatedProcurementPlans( plans=plans_response, total=total, page=skip // limit + 1, limit=limit, has_more=(skip + limit) < total ) except Exception as e: logger.error("Error listing procurement plans", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) @router.patch( route_builder.build_resource_action_route("plans", "plan_id", "status") ) async def update_plan_status( plan_id: str, status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"), tenant_id: str = Path(..., description="Tenant ID"), notes: Optional[str] = None, service: ProcurementService = Depends(get_procurement_service) ): """Update procurement plan status""" try: updated_plan = await service.update_plan_status( tenant_id=uuid.UUID(tenant_id), plan_id=uuid.UUID(plan_id), status=status, approval_notes=notes ) if not updated_plan: raise HTTPException(status_code=404, detail="Plan not found") return updated_plan except HTTPException: raise except Exception as e: logger.error("Error updating plan status", error=str(e), tenant_id=tenant_id, plan_id=plan_id) raise HTTPException(status_code=500, detail=str(e)) @router.post( route_builder.build_resource_action_route("plans", "plan_id", "create-purchase-orders") ) async def create_purchase_orders_from_plan( plan_id: str, auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"), tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service) ): """ Create purchase orders from procurement plan requirements Groups requirements by supplier and creates POs automatically. Optionally evaluates auto-approval rules for qualifying POs. Args: tenant_id: Tenant UUID plan_id: Procurement plan UUID auto_approve: Whether to auto-approve qualifying POs Returns: Summary of created, approved, and failed purchase orders """ try: result = await service.create_purchase_orders_from_plan( tenant_id=uuid.UUID(tenant_id), plan_id=uuid.UUID(plan_id), auto_approve=auto_approve ) if not result.get('success'): raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create purchase orders')) return result except HTTPException: raise except Exception as e: logger.error("Error creating POs from plan", error=str(e), tenant_id=tenant_id, plan_id=plan_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # TESTING AND UTILITIES # ================================================================ @router.get( route_builder.build_resource_action_route("plans", "plan_id", "requirements") ) async def get_plan_requirements( plan_id: str, tenant_id: str = Path(..., description="Tenant ID"), service: ProcurementService = Depends(get_procurement_service), db: AsyncSession = Depends(get_db) ): """Get all requirements for a procurement plan""" try: from app.repositories.procurement_plan_repository import ProcurementRequirementRepository repo = ProcurementRequirementRepository(db) requirements = await repo.get_requirements_by_plan(uuid.UUID(plan_id)) return { "plan_id": plan_id, "requirements_count": len(requirements), "requirements": [ { "id": str(req.id), "requirement_number": req.requirement_number, "product_name": req.product_name, "net_requirement": float(req.net_requirement), "unit_of_measure": req.unit_of_measure, "priority": req.priority, "status": req.status, "is_locally_produced": req.is_locally_produced, "bom_explosion_level": req.bom_explosion_level, "supplier_name": req.supplier_name, "estimated_total_cost": float(req.estimated_total_cost or 0) } for req in requirements ] } except Exception as e: logger.error("Error getting plan requirements", error=str(e), tenant_id=tenant_id, plan_id=plan_id) raise HTTPException(status_code=500, detail=str(e))