Files
bakery-ia/services/procurement/app/api/replenishment.py

464 lines
15 KiB
Python

"""
Replenishment Planning API Routes
Provides endpoints for advanced replenishment planning including:
- Generate replenishment plans
- View inventory projections
- Review supplier allocations
- Get planning analytics
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional
from uuid import UUID
from datetime import date
from app.schemas.replenishment import (
GenerateReplenishmentPlanRequest,
GenerateReplenishmentPlanResponse,
ReplenishmentPlanResponse,
ReplenishmentPlanSummary,
InventoryProjectionResponse,
SupplierAllocationResponse,
SupplierSelectionRequest,
SupplierSelectionResult,
SafetyStockRequest,
SafetyStockResponse,
ProjectInventoryRequest,
ProjectInventoryResponse,
ReplenishmentAnalytics,
MOQAggregationRequest,
MOQAggregationResponse
)
from app.services.procurement_service import ProcurementService
from app.services.replenishment_planning_service import ReplenishmentPlanningService
from app.services.safety_stock_calculator import SafetyStockCalculator
from app.services.inventory_projector import InventoryProjector, DailyDemand, ScheduledReceipt
from app.services.moq_aggregator import MOQAggregator
from app.services.supplier_selector import SupplierSelector
from app.core.dependencies import get_db, get_current_tenant_id
from sqlalchemy.ext.asyncio import AsyncSession
from shared.routing import RouteBuilder
import structlog
logger = structlog.get_logger()
# Create route builder for consistent URL structure
route_builder = RouteBuilder('procurement')
router = APIRouter(tags=["replenishment-planning"])
# ============================================================
# Replenishment Plan Endpoints
# ============================================================
@router.post(
route_builder.build_operations_route("replenishment-plans/generate"),
response_model=GenerateReplenishmentPlanResponse
)
async def generate_replenishment_plan(
request: GenerateReplenishmentPlanRequest,
tenant_id: UUID = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db)
):
"""
Generate advanced replenishment plan with:
- Lead-time-aware order date calculation
- Dynamic safety stock
- Inventory projection
- Shelf-life management
"""
try:
logger.info("Generating replenishment plan", tenant_id=tenant_id)
# Initialize replenishment planner
planner = ReplenishmentPlanningService(
projection_horizon_days=request.projection_horizon_days,
default_service_level=request.service_level,
default_buffer_days=request.buffer_days
)
# Generate plan
plan = await planner.generate_replenishment_plan(
tenant_id=str(tenant_id),
requirements=request.requirements,
forecast_id=request.forecast_id,
production_schedule_id=request.production_schedule_id
)
# Export to response
plan_dict = planner.export_plan_to_dict(plan)
return GenerateReplenishmentPlanResponse(**plan_dict)
except Exception as e:
logger.error("Failed to generate replenishment plan",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
route_builder.build_operations_route("replenishment-plans"),
response_model=List[ReplenishmentPlanSummary]
)
async def list_replenishment_plans(
tenant_id: UUID = Depends(get_current_tenant_id),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
status: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
List replenishment plans for tenant
"""
try:
# Query from database (implementation depends on your repo)
# This is a placeholder - implement based on your repository
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
repo = ReplenishmentPlanRepository(db)
plans = await repo.list_plans(
tenant_id=tenant_id,
skip=skip,
limit=limit,
status=status
)
return plans
except Exception as e:
logger.error("Failed to list replenishment plans",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get(
route_builder.build_resource_detail_route("replenishment-plans", "plan_id"),
response_model=ReplenishmentPlanResponse
)
async def get_replenishment_plan(
plan_id: UUID = Path(...),
tenant_id: UUID = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db)
):
"""
Get replenishment plan by ID
"""
try:
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
repo = ReplenishmentPlanRepository(db)
plan = await repo.get_plan_by_id(plan_id, tenant_id)
if not plan:
raise HTTPException(status_code=404, detail="Replenishment plan not found")
return plan
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get replenishment plan",
tenant_id=tenant_id, plan_id=plan_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Inventory Projection Endpoints
# ============================================================
@router.post(
route_builder.build_operations_route("replenishment-plans/inventory-projections/project"),
response_model=ProjectInventoryResponse
)
async def project_inventory(
request: ProjectInventoryRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Project inventory levels to identify future stockouts
"""
try:
logger.info("Projecting inventory", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
projector = InventoryProjector(request.projection_horizon_days)
# Build daily demand objects
daily_demand = [
DailyDemand(
ingredient_id=request.ingredient_id,
date=d['date'],
quantity=d['quantity']
)
for d in request.daily_demand
]
# Build scheduled receipts
scheduled_receipts = [
ScheduledReceipt(
ingredient_id=request.ingredient_id,
date=r['date'],
quantity=r['quantity'],
source=r.get('source', 'purchase_order'),
reference_id=r.get('reference_id')
)
for r in request.scheduled_receipts
]
# Project inventory
projection = projector.project_inventory(
ingredient_id=request.ingredient_id,
ingredient_name=request.ingredient_name,
current_stock=request.current_stock,
unit_of_measure=request.unit_of_measure,
daily_demand=daily_demand,
scheduled_receipts=scheduled_receipts
)
# Export to response
projection_dict = projector.export_projection_to_dict(projection)
return ProjectInventoryResponse(**projection_dict)
except Exception as e:
logger.error("Failed to project inventory",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
route_builder.build_operations_route("replenishment-plans/inventory-projections"),
response_model=List[InventoryProjectionResponse]
)
async def list_inventory_projections(
tenant_id: UUID = Depends(get_current_tenant_id),
ingredient_id: Optional[UUID] = None,
projection_date: Optional[date] = None,
stockout_only: bool = False,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db)
):
"""
List inventory projections
"""
try:
from app.repositories.replenishment_repository import InventoryProjectionRepository
repo = InventoryProjectionRepository(db)
projections = await repo.list_projections(
tenant_id=tenant_id,
ingredient_id=ingredient_id,
projection_date=projection_date,
stockout_only=stockout_only,
skip=skip,
limit=limit
)
return projections
except Exception as e:
logger.error("Failed to list inventory projections",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Safety Stock Endpoints
# ============================================================
@router.post(
route_builder.build_operations_route("replenishment-plans/safety-stock/calculate"),
response_model=SafetyStockResponse
)
async def calculate_safety_stock(
request: SafetyStockRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Calculate dynamic safety stock using statistical methods
"""
try:
logger.info("Calculating safety stock", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
calculator = SafetyStockCalculator(request.service_level)
result = calculator.calculate_from_demand_history(
daily_demands=request.daily_demands,
lead_time_days=request.lead_time_days,
service_level=request.service_level
)
return SafetyStockResponse(**calculator.export_to_dict(result))
except Exception as e:
logger.error("Failed to calculate safety stock",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Supplier Selection Endpoints
# ============================================================
@router.post(
route_builder.build_operations_route("replenishment-plans/supplier-selections/evaluate"),
response_model=SupplierSelectionResult
)
async def evaluate_supplier_selection(
request: SupplierSelectionRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Evaluate supplier options using multi-criteria decision analysis
"""
try:
logger.info("Evaluating supplier selection", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
selector = SupplierSelector()
# Convert supplier options
from app.services.supplier_selector import SupplierOption
supplier_options = [
SupplierOption(**opt) for opt in request.supplier_options
]
result = selector.select_suppliers(
ingredient_id=request.ingredient_id,
ingredient_name=request.ingredient_name,
required_quantity=request.required_quantity,
supplier_options=supplier_options
)
return SupplierSelectionResult(**selector.export_result_to_dict(result))
except Exception as e:
logger.error("Failed to evaluate supplier selection",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
route_builder.build_operations_route("replenishment-plans/supplier-allocations"),
response_model=List[SupplierAllocationResponse]
)
async def list_supplier_allocations(
tenant_id: UUID = Depends(get_current_tenant_id),
requirement_id: Optional[UUID] = None,
supplier_id: Optional[UUID] = None,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db)
):
"""
List supplier allocations
"""
try:
from app.repositories.replenishment_repository import SupplierAllocationRepository
repo = SupplierAllocationRepository(db)
allocations = await repo.list_allocations(
tenant_id=tenant_id,
requirement_id=requirement_id,
supplier_id=supplier_id,
skip=skip,
limit=limit
)
return allocations
except Exception as e:
logger.error("Failed to list supplier allocations",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# MOQ Aggregation Endpoints
# ============================================================
@router.post(
route_builder.build_operations_route("replenishment-plans/moq-aggregation/aggregate"),
response_model=MOQAggregationResponse
)
async def aggregate_for_moq(
request: MOQAggregationRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Aggregate requirements to meet Minimum Order Quantities
"""
try:
logger.info("Aggregating requirements for MOQ", tenant_id=tenant_id)
aggregator = MOQAggregator()
# Convert requirements and constraints
from app.services.moq_aggregator import (
ProcurementRequirement as MOQReq,
SupplierConstraints
)
requirements = [MOQReq(**req) for req in request.requirements]
constraints = {
k: SupplierConstraints(**v)
for k, v in request.supplier_constraints.items()
}
# Aggregate
aggregated_orders = aggregator.aggregate_requirements(
requirements=requirements,
supplier_constraints=constraints
)
# Calculate efficiency
efficiency = aggregator.calculate_order_efficiency(aggregated_orders)
return MOQAggregationResponse(
aggregated_orders=[aggregator.export_to_dict(order) for order in aggregated_orders],
efficiency_metrics=efficiency
)
except Exception as e:
logger.error("Failed to aggregate for MOQ",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Analytics Endpoints
# ============================================================
@router.get(
route_builder.build_analytics_route("replenishment-plans"),
response_model=ReplenishmentAnalytics
)
async def get_replenishment_analytics(
tenant_id: UUID = Depends(get_current_tenant_id),
start_date: Optional[date] = None,
end_date: Optional[date] = None,
db: AsyncSession = Depends(get_db)
):
"""
Get replenishment planning analytics
"""
try:
from app.repositories.replenishment_repository import ReplenishmentAnalyticsRepository
repo = ReplenishmentAnalyticsRepository(db)
analytics = await repo.get_analytics(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
return analytics
except Exception as e:
logger.error("Failed to get replenishment analytics",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))