# ================================================================ # 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, 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.schemas.purchase_order_schemas import ( PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderResponse, PurchaseOrderApproval, DeliveryCreate, DeliveryResponse, SupplierInvoiceCreate, SupplierInvoiceResponse, ) import structlog logger = structlog.get_logger() router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/purchase-orders", 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("", response_model=PurchaseOrderResponse, status_code=201) async def create_purchase_order( tenant_id: str, po_data: PurchaseOrderCreate, 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("/{po_id}", response_model=PurchaseOrderResponse) async def get_purchase_order( tenant_id: str, po_id: str, 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 PurchaseOrderResponse.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("", response_model=List[PurchaseOrderResponse]) async def list_purchase_orders( tenant_id: str, 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), service: PurchaseOrderService = Depends(get_po_service) ): """ List purchase orders with filters 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) Returns: List of purchase orders """ try: 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 ) return [PurchaseOrderResponse.model_validate(po) for po in pos] 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("/{po_id}", response_model=PurchaseOrderResponse) async def update_purchase_order( tenant_id: str, po_id: str, po_data: PurchaseOrderUpdate, 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("/{po_id}/status") async def update_order_status( tenant_id: str, po_id: str, status: str = Query(..., description="New status"), 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("/{po_id}/approve", response_model=PurchaseOrderResponse) async def approve_purchase_order( tenant_id: str, po_id: str, approval_data: PurchaseOrderApproval, 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("/{po_id}/cancel", response_model=PurchaseOrderResponse) async def cancel_purchase_order( tenant_id: str, po_id: str, reason: str = Query(..., description="Cancellation reason"), cancelled_by: Optional[str] = Query(default=None), 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("/{po_id}/deliveries", response_model=DeliveryResponse, status_code=201) async def create_delivery( tenant_id: str, po_id: str, delivery_data: DeliveryCreate, 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.uuid4() # TODO: Get from auth context ) 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("/deliveries/{delivery_id}/status") async def update_delivery_status( tenant_id: str, delivery_id: str, status: str = Query(..., description="New delivery status"), 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.uuid4() # TODO: Get from auth context ) 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("/{po_id}/invoices", response_model=SupplierInvoiceResponse, status_code=201) async def create_invoice( tenant_id: str, po_id: str, invoice_data: SupplierInvoiceCreate, 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.uuid4() # TODO: Get from auth context ) 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))