464 lines
15 KiB
Python
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))
|