""" 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 import structlog logger = structlog.get_logger() router = APIRouter(prefix="/replenishment-plans", tags=["Replenishment Planning"]) # ============================================================ # Replenishment Plan Endpoints # ============================================================ @router.post("/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("", 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("/{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("/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("/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("/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("/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("/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("/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("/analytics", 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))