# ================================================================ # services/procurement/app/api/purchase_orders.py # ================================================================ """ Purchase Orders API - Endpoints for purchase order management """ import uuid from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.config import settings from app.services.purchase_order_service import PurchaseOrderService from app.services.overdue_po_detector import OverduePODetector from app.schemas.purchase_order_schemas import ( PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderResponse, PurchaseOrderWithSupplierResponse, PurchaseOrderApproval, DeliveryCreate, DeliveryResponse, SupplierInvoiceCreate, SupplierInvoiceResponse, ) from shared.routing import RouteBuilder from shared.auth.decorators import get_current_user_dep from app.utils.cache import get_cached, set_cached, make_cache_key import structlog logger = structlog.get_logger() # Create route builder for consistent URL structure route_builder = RouteBuilder('procurement') router = APIRouter(tags=["purchase-orders"]) def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService: """Dependency to get purchase order service""" return PurchaseOrderService(db, settings) # ================================================================ # PURCHASE ORDER CRUD # ================================================================ @router.post( route_builder.build_base_route("purchase-orders"), response_model=PurchaseOrderResponse, status_code=201 ) async def create_purchase_order( po_data: PurchaseOrderCreate, tenant_id: str = Path(..., description="Tenant ID"), service: PurchaseOrderService = Depends(get_po_service) ): """ Create a new purchase order with items Creates a PO with automatic approval rules evaluation. Links to procurement plan if procurement_plan_id is provided. Args: tenant_id: Tenant UUID po_data: Purchase order creation data Returns: PurchaseOrderResponse with created PO details """ try: logger.info("Create PO endpoint called", tenant_id=tenant_id) po = await service.create_purchase_order( tenant_id=uuid.UUID(tenant_id), po_data=po_data ) return PurchaseOrderResponse.model_validate(po) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) @router.get( route_builder.build_resource_detail_route("purchase-orders", "po_id"), response_model=PurchaseOrderWithSupplierResponse ) async def get_purchase_order( po_id: str, tenant_id: str = Path(..., description="Tenant ID"), service: PurchaseOrderService = Depends(get_po_service) ): """Get purchase order by ID with items""" try: po = await service.get_purchase_order( tenant_id=uuid.UUID(tenant_id), po_id=uuid.UUID(po_id) ) if not po: raise HTTPException(status_code=404, detail="Purchase order not found") return PurchaseOrderWithSupplierResponse.model_validate(po) except HTTPException: raise except Exception as e: logger.error("Error getting purchase order", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) @router.get( route_builder.build_base_route("purchase-orders"), response_model=List[PurchaseOrderResponse] ) async def list_purchase_orders( tenant_id: str = Path(..., description="Tenant ID"), skip: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=100), supplier_id: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), enrich_supplier: bool = Query(default=True, description="Include supplier details (slower)"), service: PurchaseOrderService = Depends(get_po_service) ): """ List purchase orders with filters and caching (30s TTL) Args: tenant_id: Tenant UUID skip: Number of records to skip (pagination) limit: Maximum number of records to return supplier_id: Filter by supplier ID (optional) status: Filter by status (optional) enrich_supplier: Whether to enrich with supplier data (default: True) Returns: List of purchase orders """ try: # PERFORMANCE OPTIMIZATION: Cache even with status filter for dashboard queries # Only skip cache for supplier_id filter and pagination (skip > 0) cache_key = None if skip == 0 and supplier_id is None: cache_key = make_cache_key( "purchase_orders", tenant_id, limit=limit, status=status, # Include status in cache key enrich_supplier=enrich_supplier ) cached_result = await get_cached(cache_key) if cached_result is not None: logger.debug("Cache hit for purchase orders", cache_key=cache_key, tenant_id=tenant_id, status=status) return [PurchaseOrderResponse(**po) for po in cached_result] # Cache miss - fetch from database pos = await service.list_purchase_orders( tenant_id=uuid.UUID(tenant_id), skip=skip, limit=limit, supplier_id=uuid.UUID(supplier_id) if supplier_id else None, status=status, enrich_supplier=enrich_supplier ) result = [PurchaseOrderResponse.model_validate(po) for po in pos] # PERFORMANCE OPTIMIZATION: Cache the result (20s TTL for purchase orders) if cache_key: await set_cached(cache_key, [po.model_dump() for po in result], ttl=20) logger.debug("Cached purchase orders", cache_key=cache_key, ttl=20, tenant_id=tenant_id, status=status) return result except Exception as e: logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) @router.patch( route_builder.build_resource_detail_route("purchase-orders", "po_id"), response_model=PurchaseOrderResponse ) async def update_purchase_order( po_id: str, po_data: PurchaseOrderUpdate, tenant_id: str = Path(..., description="Tenant ID"), service: PurchaseOrderService = Depends(get_po_service) ): """ Update purchase order information Only draft or pending_approval orders can be modified. Financial field changes trigger automatic total recalculation. Args: tenant_id: Tenant UUID po_id: Purchase order UUID po_data: Update data Returns: Updated purchase order """ try: po = await service.update_purchase_order( tenant_id=uuid.UUID(tenant_id), po_id=uuid.UUID(po_id), po_data=po_data ) if not po: raise HTTPException(status_code=404, detail="Purchase order not found") return PurchaseOrderResponse.model_validate(po) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error("Error updating purchase order", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) @router.patch( route_builder.build_resource_action_route("purchase-orders", "po_id", "status") ) async def update_order_status( po_id: str, status: str = Query(..., description="New status"), tenant_id: str = Path(..., description="Tenant ID"), notes: Optional[str] = Query(default=None), service: PurchaseOrderService = Depends(get_po_service) ): """ Update purchase order status Validates status transitions to prevent invalid state changes. Valid transitions: - draft -> pending_approval, approved, cancelled - pending_approval -> approved, rejected, cancelled - approved -> sent_to_supplier, cancelled - sent_to_supplier -> confirmed, cancelled - confirmed -> in_production, cancelled - in_production -> shipped, cancelled - shipped -> delivered, cancelled - delivered -> completed Args: tenant_id: Tenant UUID po_id: Purchase order UUID status: New status notes: Optional status change notes Returns: Updated purchase order """ try: po = await service.update_order_status( tenant_id=uuid.UUID(tenant_id), po_id=uuid.UUID(po_id), status=status, notes=notes ) if not po: raise HTTPException(status_code=404, detail="Purchase order not found") return PurchaseOrderResponse.model_validate(po) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error("Error updating PO status", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # APPROVAL WORKFLOW # ================================================================ @router.post( route_builder.build_resource_action_route("purchase-orders", "po_id", "approve"), response_model=PurchaseOrderResponse ) async def approve_purchase_order( po_id: str, approval_data: PurchaseOrderApproval, tenant_id: str = Path(..., description="Tenant ID"), service: PurchaseOrderService = Depends(get_po_service) ): """ Approve or reject a purchase order Args: tenant_id: Tenant UUID po_id: Purchase order UUID approval_data: Approval or rejection data Returns: Updated purchase order """ try: if approval_data.action == "approve": po = await service.approve_purchase_order( tenant_id=uuid.UUID(tenant_id), po_id=uuid.UUID(po_id), approved_by=approval_data.approved_by, approval_notes=approval_data.notes ) elif approval_data.action == "reject": po = await service.reject_purchase_order( tenant_id=uuid.UUID(tenant_id), po_id=uuid.UUID(po_id), rejected_by=approval_data.approved_by, rejection_reason=approval_data.notes or "No reason provided" ) else: raise ValueError("Invalid action. Must be 'approve' or 'reject'") if not po: raise HTTPException(status_code=404, detail="Purchase order not found") return PurchaseOrderResponse.model_validate(po) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error("Error in PO approval workflow", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) @router.post( route_builder.build_resource_action_route("purchase-orders", "po_id", "cancel"), response_model=PurchaseOrderResponse ) async def cancel_purchase_order( po_id: str, reason: str = Query(..., description="Cancellation reason"), cancelled_by: Optional[str] = Query(default=None), tenant_id: str = Path(..., description="Tenant ID"), service: PurchaseOrderService = Depends(get_po_service) ): """ Cancel a purchase order Args: tenant_id: Tenant UUID po_id: Purchase order UUID reason: Cancellation reason cancelled_by: User ID performing cancellation Returns: Cancelled purchase order """ try: po = await service.cancel_purchase_order( tenant_id=uuid.UUID(tenant_id), po_id=uuid.UUID(po_id), cancelled_by=uuid.UUID(cancelled_by) if cancelled_by else None, cancellation_reason=reason ) if not po: raise HTTPException(status_code=404, detail="Purchase order not found") return PurchaseOrderResponse.model_validate(po) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error("Error cancelling purchase order", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # DELIVERY MANAGEMENT # ================================================================ @router.post( route_builder.build_nested_resource_route("purchase-orders", "po_id", "deliveries"), response_model=DeliveryResponse, status_code=201 ) async def create_delivery( po_id: str, delivery_data: DeliveryCreate, tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), service: PurchaseOrderService = Depends(get_po_service) ): """ Create a delivery record for a purchase order Tracks delivery scheduling, items, quality inspection, and receipt. Args: tenant_id: Tenant UUID po_id: Purchase order UUID delivery_data: Delivery creation data Returns: DeliveryResponse with created delivery details """ try: # Validate PO ID matches if str(delivery_data.purchase_order_id) != po_id: raise ValueError("Purchase order ID mismatch") delivery = await service.create_delivery( tenant_id=uuid.UUID(tenant_id), delivery_data=delivery_data, created_by=uuid.UUID(current_user.get("user_id")) ) return DeliveryResponse.model_validate(delivery) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error creating delivery", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) @router.patch( route_builder.build_nested_resource_route("purchase-orders", "po_id", "deliveries") + "/{delivery_id}/status" ) async def update_delivery_status( po_id: str, delivery_id: str, status: str = Query(..., description="New delivery status"), tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), service: PurchaseOrderService = Depends(get_po_service) ): """ Update delivery status Valid statuses: scheduled, in_transit, delivered, completed, cancelled Args: tenant_id: Tenant UUID delivery_id: Delivery UUID status: New status Returns: Updated delivery """ try: delivery = await service.update_delivery_status( tenant_id=uuid.UUID(tenant_id), delivery_id=uuid.UUID(delivery_id), status=status, updated_by=uuid.UUID(current_user.get("user_id")) ) if not delivery: raise HTTPException(status_code=404, detail="Delivery not found") return DeliveryResponse.model_validate(delivery) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # INVOICE MANAGEMENT # ================================================================ @router.post( route_builder.build_nested_resource_route("purchase-orders", "po_id", "invoices"), response_model=SupplierInvoiceResponse, status_code=201 ) async def create_invoice( po_id: str, invoice_data: SupplierInvoiceCreate, tenant_id: str = Path(..., description="Tenant ID"), current_user: dict = Depends(get_current_user_dep), service: PurchaseOrderService = Depends(get_po_service) ): """ Create a supplier invoice for a purchase order Args: tenant_id: Tenant UUID po_id: Purchase order UUID invoice_data: Invoice creation data Returns: SupplierInvoiceResponse with created invoice details """ try: # Validate PO ID matches if str(invoice_data.purchase_order_id) != po_id: raise ValueError("Purchase order ID mismatch") invoice = await service.create_invoice( tenant_id=uuid.UUID(tenant_id), invoice_data=invoice_data, created_by=uuid.UUID(current_user.get("user_id")) ) return SupplierInvoiceResponse.model_validate(invoice) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error creating invoice", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e)) # ================================================================ # OVERDUE PO DETECTION # ================================================================ @router.get( route_builder.build_base_route("purchase-orders/overdue"), response_model=List[dict] ) async def get_overdue_purchase_orders( tenant_id: str = Path(..., description="Tenant ID"), limit: int = Query(10, ge=1, le=100, description="Max results") ): """ Get overdue purchase orders for dashboard display. Returns POs that are past their estimated delivery date but not yet marked as delivered. Args: tenant_id: Tenant UUID limit: Maximum number of results (default: 10) Returns: List of overdue PO summaries with severity and days overdue """ try: detector = OverduePODetector() overdue_pos = await detector.get_overdue_pos_for_dashboard( tenant_id=uuid.UUID(tenant_id), limit=limit ) return overdue_pos except Exception as e: logger.error("Error getting overdue POs", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=str(e)) @router.get( route_builder.build_resource_action_route("purchase-orders", "po_id", "overdue-status"), response_model=dict ) async def check_po_overdue_status( po_id: str, tenant_id: str = Path(..., description="Tenant ID") ): """ Check if a specific PO is overdue. Args: tenant_id: Tenant UUID po_id: Purchase order UUID Returns: Overdue status info or null if not overdue """ try: detector = OverduePODetector() overdue_info = await detector.check_single_po_overdue( po_id=uuid.UUID(po_id), tenant_id=uuid.UUID(tenant_id) ) if overdue_info: return overdue_info else: return {"overdue": False} except Exception as e: logger.error("Error checking PO overdue status", error=str(e), po_id=po_id) raise HTTPException(status_code=500, detail=str(e))