459 lines
14 KiB
Python
459 lines
14 KiB
Python
# ================================================================
|
|
# 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))
|