Add procurement management logic
This commit is contained in:
432
services/orders/app/api/procurement.py
Normal file
432
services/orders/app/api/procurement.py
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user