Initial commit - production deployment
This commit is contained in:
602
services/procurement/app/api/purchase_orders.py
Normal file
602
services/procurement/app/api/purchase_orders.py
Normal file
@@ -0,0 +1,602 @@
|
||||
# ================================================================
|
||||
# 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 shared.redis_utils import get_value, set_with_ttl
|
||||
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 = f"purchase_orders:{tenant_id}:limit:{limit}:status:{status}:enrich:{enrich_supplier}"
|
||||
try:
|
||||
cached_result = await get_value(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]
|
||||
except Exception as e:
|
||||
logger.warning("Cache read failed, continuing without cache", cache_key=cache_key, error=str(e))
|
||||
|
||||
# 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:
|
||||
try:
|
||||
import json
|
||||
await set_with_ttl(cache_key, json.dumps([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)
|
||||
except Exception as e:
|
||||
logger.warning("Cache write failed, continuing without caching", cache_key=cache_key, error=str(e))
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user