Add procurement management logic

This commit is contained in:
Urtzi Alfaro
2025-08-23 19:47:08 +02:00
parent 62ff755f25
commit 5077a45a25
22 changed files with 4011 additions and 79 deletions

View File

@@ -0,0 +1,432 @@
# ================================================================
# services/orders/app/api/procurement.py
# ================================================================
"""
Procurement API Endpoints - RESTful APIs for procurement planning
"""
import uuid
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
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
# Create router
router = APIRouter(prefix="/procurement-plans", 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"""
inventory_client = InventoryServiceClient(service_settings)
forecast_client = ForecastServiceClient(service_settings, "orders-service")
return ProcurementService(db, service_settings, inventory_client, forecast_client)
# ================================================================
# PROCUREMENT PLAN ENDPOINTS
# ================================================================
@router.get("/current", response_model=Optional[ProcurementPlanResponse])
@monitor_performance("get_current_procurement_plan")
async def get_current_procurement_plan(
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("/{plan_date}", response_model=Optional[ProcurementPlanResponse])
@monitor_performance("get_procurement_plan_by_date")
async def get_procurement_plan_by_date(
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("/", response_model=PaginatedProcurementPlans)
@monitor_performance("list_procurement_plans")
async def list_procurement_plans(
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_access.tenant_id,
status=status,
start_date=start_date,
end_date=end_date,
limit=limit,
offset=offset
)
# Convert to response models
plan_responses = [ProcurementPlanResponse.model_validate(plan) for plan in plans]
# 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("/generate", response_model=GeneratePlanResponse)
@monitor_performance("generate_procurement_plan")
async def generate_procurement_plan(
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("/{plan_id}/status")
@monitor_performance("update_procurement_plan_status")
async def update_procurement_plan_status(
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("/id/{plan_id}", response_model=Optional[ProcurementPlanResponse])
@monitor_performance("get_procurement_plan_by_id")
async def get_procurement_plan_by_id(
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("/dashboard/data", response_model=Optional[DashboardData])
@monitor_performance("get_procurement_dashboard")
async def get_procurement_dashboard(
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("/{plan_id}/requirements")
@monitor_performance("get_plan_requirements")
async def get_plan_requirements(
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("/requirements/critical")
@monitor_performance("get_critical_requirements")
async def get_critical_requirements(
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)}"
)
# ================================================================
# UTILITY ENDPOINTS
# ================================================================
@router.post("/scheduler/trigger")
@monitor_performance("trigger_daily_scheduler")
async def trigger_daily_scheduler(
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Manually trigger the daily scheduler for the current tenant
This endpoint is primarily for testing and maintenance purposes.
"""
try:
# Process daily plan for current tenant only
await procurement_service._process_daily_plan_for_tenant(tenant_access.tenant_id)
return {
"success": True,
"message": "Daily scheduler executed successfully",
"tenant_id": str(tenant_access.tenant_id)
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error triggering daily scheduler: {str(e)}"
)
@router.get("/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()
}