# ================================================================ # 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, 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, ) import structlog logger = structlog.get_logger() router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/procurement", 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("/auto-generate", response_model=AutoGenerateProcurementResponse) async def auto_generate_procurement( tenant_id: str, request_data: AutoGenerateProcurementRequest, 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("/plans/generate", response_model=GeneratePlanResponse) async def generate_procurement_plan( tenant_id: str, request_data: GeneratePlanRequest, 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("/plans/current", response_model=Optional[ProcurementPlanResponse]) async def get_current_plan( tenant_id: str, 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("/plans/{plan_id}", response_model=ProcurementPlanResponse) async def get_plan_by_id( tenant_id: str, plan_id: str, 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("/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse]) async def get_plan_by_date( tenant_id: str, plan_date: date, 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("/plans", response_model=PaginatedProcurementPlans) async def list_procurement_plans( tenant_id: str, 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("/plans/{plan_id}/status") async def update_plan_status( tenant_id: str, plan_id: str, status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"), 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("/plans/{plan_id}/create-purchase-orders") async def create_purchase_orders_from_plan( tenant_id: str, plan_id: str, auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"), 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("/plans/{plan_id}/requirements") async def get_plan_requirements( tenant_id: str, plan_id: str, 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))