# ================================================================ # services/orders/app/api/procurement_operations.py # ================================================================ """ Procurement Operations API Endpoints - BUSINESS logic for procurement planning RESTful APIs for procurement planning, approval workflows, and PO management """ import uuid from datetime import date from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy.ext.asyncio import AsyncSession import structlog logger = structlog.get_logger() 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, DashboardData, PaginatedProcurementPlans ) from shared.auth.decorators import require_authentication, get_current_user_dep from fastapi import Depends, Request from typing import Dict, Any import uuid from shared.clients.inventory_client import InventoryServiceClient from shared.clients.forecast_client import ForecastServiceClient from shared.config.base import BaseServiceSettings from shared.monitoring.decorators import monitor_performance from shared.routing import RouteBuilder from shared.auth.access_control import ( require_user_role, admin_role_required, owner_role_required, require_subscription_tier, analytics_tier_required, enterprise_tier_required ) # Create route builder for consistent URL structure route_builder = RouteBuilder('orders') # Create router router = APIRouter(tags=["Procurement Planning"]) # Create service settings service_settings = BaseServiceSettings() # Simple TenantAccess class for compatibility class TenantAccess: def __init__(self, tenant_id: uuid.UUID, user_id: str): self.tenant_id = tenant_id self.user_id = user_id async def get_current_tenant( request: Request, current_user: Dict[str, Any] = Depends(get_current_user_dep) ) -> TenantAccess: """Get current tenant from user context""" # For now, create a simple tenant access from user data # In a real implementation, this would validate tenant access tenant_id = current_user.get('tenant_id') if not tenant_id: # Try to get from headers as fallback tenant_id = request.headers.get('x-tenant-id') if not tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Tenant access required" ) try: tenant_uuid = uuid.UUID(tenant_id) except (ValueError, TypeError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid tenant ID format" ) return TenantAccess( tenant_id=tenant_uuid, user_id=current_user['user_id'] ) async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService: """Get procurement service instance with all required clients""" from shared.clients.suppliers_client import SuppliersServiceClient inventory_client = InventoryServiceClient(service_settings) forecast_client = ForecastServiceClient(service_settings, "orders-service") suppliers_client = SuppliersServiceClient(service_settings) return ProcurementService(db, service_settings, inventory_client, forecast_client, suppliers_client) # ================================================================ # PROCUREMENT PLAN ENDPOINTS # ================================================================ @router.get( route_builder.build_operations_route("procurement/plans/current"), response_model=Optional[ProcurementPlanResponse] ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("get_current_procurement_plan") async def get_current_procurement_plan( tenant_id: uuid.UUID, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Get the procurement plan for the current day (forecasting for the next day) Returns the plan details, including requirements per item. """ try: plan = await procurement_service.get_current_plan(tenant_access.tenant_id) return plan except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving current procurement plan: {str(e)}" ) @router.get( route_builder.build_operations_route("procurement/plans/date/{plan_date}"), response_model=Optional[ProcurementPlanResponse] ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("get_procurement_plan_by_date") async def get_procurement_plan_by_date( tenant_id: uuid.UUID, plan_date: date, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Get the procurement plan for a specific date (format: YYYY-MM-DD) Returns the plan details, including requirements per item for the specified date. """ try: plan = await procurement_service.get_plan_by_date(tenant_access.tenant_id, plan_date) return plan except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving procurement plan for {plan_date}: {str(e)}" ) @router.get( route_builder.build_operations_route("procurement/plans"), response_model=PaginatedProcurementPlans ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("list_procurement_plans") async def list_procurement_plans( tenant_id: uuid.UUID, plan_status: Optional[str] = Query(None, description="Filter by plan status"), start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"), limit: int = Query(50, ge=1, le=100, description="Number of plans to return"), offset: int = Query(0, ge=0, description="Number of plans to skip"), # tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ List procurement plans with optional filters Supports filtering by status, date range, and pagination. """ try: # Get plans from repository directly for listing plans = await procurement_service.plan_repo.list_plans( tenant_id, status=plan_status, start_date=start_date, end_date=end_date, limit=limit, offset=offset ) # Convert to response models plan_responses = [] for plan in plans: try: plan_response = ProcurementPlanResponse.model_validate(plan) plan_responses.append(plan_response) except Exception as validation_error: logger.error(f"Error validating plan {plan.id}: {validation_error}") raise # For simplicity, we'll use the returned count as total # In a production system, you'd want a separate count query total = len(plan_responses) has_more = len(plan_responses) == limit return PaginatedProcurementPlans( plans=plan_responses, total=total, page=offset // limit + 1 if limit > 0 else 1, limit=limit, has_more=has_more ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error listing procurement plans: {str(e)}" ) @router.post( route_builder.build_operations_route("procurement/plans/generate"), response_model=GeneratePlanResponse ) @require_user_role(['member', 'admin', 'owner']) @monitor_performance("generate_procurement_plan") async def generate_procurement_plan( tenant_id: uuid.UUID, request: GeneratePlanRequest, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Manually trigger the generation of a procurement plan This can serve as a fallback if the daily scheduler hasn't run, or for testing purposes. Can be forced to regenerate an existing plan. """ try: if not settings.PROCUREMENT_PLANNING_ENABLED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Procurement planning is currently disabled" ) result = await procurement_service.generate_procurement_plan( tenant_access.tenant_id, request ) if not result.success: # Return the result with errors but don't raise an exception # since the service method handles the error state properly return result return result except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error generating procurement plan: {str(e)}" ) @router.put( route_builder.build_operations_route("procurement/plans/{plan_id}/status") ) @require_user_role(['admin', 'owner']) @monitor_performance("update_procurement_plan_status") async def update_procurement_plan_status( tenant_id: uuid.UUID, plan_id: uuid.UUID, status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Update the status of a procurement plan Valid statuses: draft, pending_approval, approved, in_execution, completed, cancelled """ try: updated_plan = await procurement_service.update_plan_status( tenant_access.tenant_id, plan_id, status, tenant_access.user_id ) if not updated_plan: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Procurement plan not found" ) return updated_plan except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error updating procurement plan status: {str(e)}" ) @router.get( route_builder.build_operations_route("procurement/plans/id/{plan_id}"), response_model=Optional[ProcurementPlanResponse] ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("get_procurement_plan_by_id") async def get_procurement_plan_by_id( tenant_id: uuid.UUID, plan_id: uuid.UUID, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Get a specific procurement plan by its ID Returns detailed plan information including all requirements. """ try: plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id) if not plan: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Procurement plan not found" ) return plan except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving procurement plan: {str(e)}" ) # ================================================================ # DASHBOARD ENDPOINTS # ================================================================ @router.get( route_builder.build_dashboard_route("procurement"), response_model=Optional[DashboardData] ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("get_procurement_dashboard") async def get_procurement_dashboard( tenant_id: uuid.UUID, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Get procurement dashboard data Returns comprehensive dashboard information including: - Current plan - Summary statistics - Upcoming deliveries - Overdue requirements - Low stock alerts - Performance metrics """ try: dashboard_data = await procurement_service.get_dashboard_data(tenant_access.tenant_id) return dashboard_data except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving dashboard data: {str(e)}" ) # ================================================================ # REQUIREMENT MANAGEMENT ENDPOINTS # ================================================================ @router.get( route_builder.build_operations_route("procurement/plans/{plan_id}/requirements") ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("get_plan_requirements") async def get_plan_requirements( tenant_id: uuid.UUID, plan_id: uuid.UUID, status: Optional[str] = Query(None, description="Filter by requirement status"), priority: Optional[str] = Query(None, description="Filter by priority level"), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Get all requirements for a specific procurement plan Supports filtering by status and priority level. """ try: # Verify plan exists and belongs to tenant plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id) if not plan: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Procurement plan not found" ) # Get requirements from repository requirements = await procurement_service.requirement_repo.get_requirements_by_plan(plan_id) # Apply filters if provided if status: requirements = [r for r in requirements if r.status == status] if priority: requirements = [r for r in requirements if r.priority == priority] return requirements except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving plan requirements: {str(e)}" ) @router.get( route_builder.build_operations_route("procurement/requirements/critical") ) @require_user_role(['viewer', 'member', 'admin', 'owner']) @monitor_performance("get_critical_requirements") async def get_critical_requirements( tenant_id: uuid.UUID, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Get all critical priority requirements across all active plans Returns requirements that need immediate attention. """ try: requirements = await procurement_service.requirement_repo.get_critical_requirements( tenant_access.tenant_id ) return requirements except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error retrieving critical requirements: {str(e)}" ) # ================================================================ # NEW FEATURE ENDPOINTS # ================================================================ @router.post( route_builder.build_operations_route("procurement/plans/{plan_id}/recalculate"), response_model=GeneratePlanResponse ) @require_user_role(['member', 'admin', 'owner']) @monitor_performance("recalculate_procurement_plan") async def recalculate_procurement_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Recalculate an existing procurement plan (Edge Case #3) Useful when inventory has changed significantly after plan creation """ try: if tenant_access.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant" ) result = await procurement_service.recalculate_plan(tenant_id, plan_id) if not result.success: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=result.message ) return result except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error recalculating procurement plan: {str(e)}" ) @router.post( route_builder.build_operations_route("procurement/requirements/{requirement_id}/link-purchase-order") ) @require_user_role(['member', 'admin', 'owner']) @monitor_performance("link_requirement_to_po") async def link_requirement_to_purchase_order( tenant_id: uuid.UUID, requirement_id: uuid.UUID, purchase_order_id: uuid.UUID, purchase_order_number: str, ordered_quantity: float, expected_delivery_date: Optional[date] = None, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Link a procurement requirement to a purchase order (Bug #4 FIX, Feature #1) Updates requirement status and tracks PO information """ try: if tenant_access.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant" ) from decimal import Decimal success = await procurement_service.link_requirement_to_purchase_order( tenant_id=tenant_id, requirement_id=requirement_id, purchase_order_id=purchase_order_id, purchase_order_number=purchase_order_number, ordered_quantity=Decimal(str(ordered_quantity)), expected_delivery_date=expected_delivery_date ) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found or unauthorized" ) return { "success": True, "message": "Requirement linked to purchase order successfully", "requirement_id": str(requirement_id), "purchase_order_id": str(purchase_order_id) } except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error linking requirement to PO: {str(e)}" ) @router.put( route_builder.build_operations_route("procurement/requirements/{requirement_id}/delivery-status") ) @require_user_role(['member', 'admin', 'owner']) @monitor_performance("update_delivery_status") async def update_requirement_delivery_status( tenant_id: uuid.UUID, requirement_id: uuid.UUID, delivery_status: str, received_quantity: Optional[float] = None, actual_delivery_date: Optional[date] = None, quality_rating: Optional[float] = None, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Update delivery status for a requirement (Feature #2) Tracks received quantities, delivery dates, and quality ratings """ try: if tenant_access.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant" ) from decimal import Decimal success = await procurement_service.update_delivery_status( tenant_id=tenant_id, requirement_id=requirement_id, delivery_status=delivery_status, received_quantity=Decimal(str(received_quantity)) if received_quantity is not None else None, actual_delivery_date=actual_delivery_date, quality_rating=Decimal(str(quality_rating)) if quality_rating is not None else None ) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found or unauthorized" ) return { "success": True, "message": "Delivery status updated successfully", "requirement_id": str(requirement_id), "delivery_status": delivery_status } except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error updating delivery status: {str(e)}" ) @router.post( route_builder.build_operations_route("procurement/plans/{plan_id}/approve") ) @require_user_role(['admin', 'owner']) @monitor_performance("approve_procurement_plan") async def approve_procurement_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, approval_notes: Optional[str] = None, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Approve a procurement plan (Edge Case #7: Enhanced approval workflow) Includes approval notes and workflow tracking """ try: if tenant_access.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant" ) try: user_id = uuid.UUID(tenant_access.user_id) except (ValueError, TypeError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user ID" ) result = await procurement_service.update_plan_status( tenant_id=tenant_id, plan_id=plan_id, status="approved", updated_by=user_id, approval_notes=approval_notes ) if not result: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found" ) return result except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error approving plan: {str(e)}" ) @router.post( route_builder.build_operations_route("procurement/plans/{plan_id}/reject") ) @require_user_role(['admin', 'owner']) @monitor_performance("reject_procurement_plan") async def reject_procurement_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, rejection_notes: Optional[str] = None, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Reject a procurement plan (Edge Case #7: Enhanced approval workflow) Marks plan as cancelled with rejection notes """ try: if tenant_access.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant" ) try: user_id = uuid.UUID(tenant_access.user_id) except (ValueError, TypeError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user ID" ) result = await procurement_service.update_plan_status( tenant_id=tenant_id, plan_id=plan_id, status="cancelled", updated_by=user_id, approval_notes=f"REJECTED: {rejection_notes}" if rejection_notes else "REJECTED" ) if not result: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found" ) return result except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error rejecting plan: {str(e)}" ) @router.post( route_builder.build_operations_route("procurement/plans/{plan_id}/create-purchase-orders") ) @require_user_role(['admin', 'owner']) @monitor_performance("create_pos_from_plan") async def create_purchase_orders_from_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, auto_approve: bool = False, tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ Automatically create purchase orders from procurement plan (Feature #1) Groups requirements by supplier and creates POs automatically """ try: if tenant_access.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this tenant" ) result = await procurement_service.create_purchase_orders_from_plan( tenant_id=tenant_id, plan_id=plan_id, auto_approve=auto_approve ) if not result.get('success'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=result.get('error', 'Failed to create purchase orders') ) return result except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error creating purchase orders: {str(e)}" ) # ================================================================ # UTILITY ENDPOINTS # ================================================================ @router.post( route_builder.build_operations_route("procurement/scheduler/trigger") ) @monitor_performance("trigger_daily_scheduler") async def trigger_daily_scheduler( tenant_id: uuid.UUID, request: Request ): """ Manually trigger the daily scheduler for the current tenant This endpoint is primarily for testing and maintenance purposes. Note: Authentication temporarily disabled for development testing. """ try: # Get the scheduler service from app state and call process_tenant_procurement if hasattr(request.app.state, 'scheduler_service'): scheduler_service = request.app.state.scheduler_service await scheduler_service.process_tenant_procurement(tenant_id) return { "success": True, "message": "Daily scheduler executed successfully for tenant", "tenant_id": str(tenant_id) } else: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Scheduler service is not available" ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error triggering daily scheduler: {str(e)}" ) @router.get( route_builder.build_base_route("procurement/health") ) async def procurement_health_check(): """ Health check endpoint for procurement service """ return { "status": "healthy", "service": "procurement-planning", "procurement_enabled": settings.PROCUREMENT_PLANNING_ENABLED, "timestamp": date.today().isoformat() }