397 lines
14 KiB
Python
397 lines
14 KiB
Python
|
|
# services/production/app/api/production_operations.py
|
||
|
|
"""
|
||
|
|
Production Operations API - Business operations for production management
|
||
|
|
Includes: batch start/complete, schedule finalize/optimize, capacity management, transformations, stats
|
||
|
|
"""
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||
|
|
from typing import Optional
|
||
|
|
from datetime import date, datetime, timedelta
|
||
|
|
from uuid import UUID
|
||
|
|
import structlog
|
||
|
|
|
||
|
|
from shared.auth.decorators import get_current_user_dep
|
||
|
|
from shared.routing import RouteBuilder
|
||
|
|
from app.services.production_service import ProductionService
|
||
|
|
from app.schemas.production import (
|
||
|
|
ProductionBatchResponse,
|
||
|
|
ProductionScheduleResponse
|
||
|
|
)
|
||
|
|
from app.core.config import settings
|
||
|
|
|
||
|
|
logger = structlog.get_logger()
|
||
|
|
route_builder = RouteBuilder('production')
|
||
|
|
router = APIRouter(tags=["production-operations"])
|
||
|
|
|
||
|
|
|
||
|
|
def get_production_service() -> ProductionService:
|
||
|
|
"""Dependency injection for production service"""
|
||
|
|
from app.core.database import database_manager
|
||
|
|
return ProductionService(database_manager, settings)
|
||
|
|
|
||
|
|
|
||
|
|
# ===== BATCH OPERATIONS =====
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_nested_resource_route("batches", "batch_id", "start"),
|
||
|
|
response_model=ProductionBatchResponse
|
||
|
|
)
|
||
|
|
async def start_production_batch(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
batch_id: UUID = Path(...),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Mark batch as started (updates actual_start_time)"""
|
||
|
|
try:
|
||
|
|
batch = await production_service.start_production_batch(tenant_id, batch_id)
|
||
|
|
|
||
|
|
logger.info("Started production batch",
|
||
|
|
batch_id=str(batch_id), tenant_id=str(tenant_id))
|
||
|
|
|
||
|
|
return ProductionBatchResponse.model_validate(batch)
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
logger.warning("Cannot start batch", error=str(e), batch_id=str(batch_id))
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error starting production batch",
|
||
|
|
error=str(e), batch_id=str(batch_id), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to start production batch")
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_nested_resource_route("batches", "batch_id", "complete"),
|
||
|
|
response_model=ProductionBatchResponse
|
||
|
|
)
|
||
|
|
async def complete_production_batch(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
batch_id: UUID = Path(...),
|
||
|
|
completion_data: Optional[dict] = None,
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Complete batch — auto-calculates yield, duration, cost summary"""
|
||
|
|
try:
|
||
|
|
batch = await production_service.complete_production_batch(tenant_id, batch_id, completion_data)
|
||
|
|
|
||
|
|
logger.info("Completed production batch",
|
||
|
|
batch_id=str(batch_id), tenant_id=str(tenant_id))
|
||
|
|
|
||
|
|
return ProductionBatchResponse.model_validate(batch)
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
logger.warning("Cannot complete batch", error=str(e), batch_id=str(batch_id))
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error completing production batch",
|
||
|
|
error=str(e), batch_id=str(batch_id), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to complete production batch")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_operations_route("batches/stats"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def get_production_batch_stats(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
start_date: Optional[date] = Query(None, description="Start date for stats"),
|
||
|
|
end_date: Optional[date] = Query(None, description="End date for stats"),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Aggregated stats: completed vs failed, avg yield, on-time rate"""
|
||
|
|
try:
|
||
|
|
# Default to last 30 days if no dates provided
|
||
|
|
if not start_date:
|
||
|
|
start_date = (datetime.now() - timedelta(days=30)).date()
|
||
|
|
if not end_date:
|
||
|
|
end_date = datetime.now().date()
|
||
|
|
|
||
|
|
stats = await production_service.get_batch_statistics(tenant_id, start_date, end_date)
|
||
|
|
|
||
|
|
logger.info("Retrieved production batch statistics",
|
||
|
|
tenant_id=str(tenant_id), start_date=start_date.isoformat(), end_date=end_date.isoformat())
|
||
|
|
|
||
|
|
return stats
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error getting production batch stats",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to get production batch stats")
|
||
|
|
|
||
|
|
|
||
|
|
# ===== SCHEDULE OPERATIONS =====
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_nested_resource_route("schedules", "schedule_id", "finalize"),
|
||
|
|
response_model=ProductionScheduleResponse
|
||
|
|
)
|
||
|
|
async def finalize_production_schedule(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
schedule_id: UUID = Path(...),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Lock schedule; prevents further changes"""
|
||
|
|
try:
|
||
|
|
schedule = await production_service.finalize_production_schedule(tenant_id, schedule_id)
|
||
|
|
|
||
|
|
logger.info("Finalized production schedule",
|
||
|
|
schedule_id=str(schedule_id), tenant_id=str(tenant_id))
|
||
|
|
|
||
|
|
return ProductionScheduleResponse.model_validate(schedule)
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
logger.warning("Cannot finalize schedule", error=str(e), schedule_id=str(schedule_id))
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error finalizing production schedule",
|
||
|
|
error=str(e), schedule_id=str(schedule_id), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to finalize production schedule")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_operations_route("schedules/optimize"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def optimize_production_schedule(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
target_date: date = Query(..., description="Date to optimize"),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Trigger AI-based rescheduling suggestion based on demand/capacity"""
|
||
|
|
try:
|
||
|
|
optimization_result = await production_service.optimize_schedule(tenant_id, target_date)
|
||
|
|
|
||
|
|
logger.info("Generated schedule optimization suggestions",
|
||
|
|
tenant_id=str(tenant_id), date=target_date.isoformat())
|
||
|
|
|
||
|
|
return optimization_result
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error optimizing production schedule",
|
||
|
|
error=str(e), tenant_id=str(tenant_id), date=target_date.isoformat())
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to optimize production schedule")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_operations_route("schedules/capacity-usage"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def get_schedule_capacity_usage(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
start_date: Optional[date] = Query(None),
|
||
|
|
end_date: Optional[date] = Query(None),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Get capacity usage report for scheduling period"""
|
||
|
|
try:
|
||
|
|
if not start_date:
|
||
|
|
start_date = datetime.now().date()
|
||
|
|
if not end_date:
|
||
|
|
end_date = start_date + timedelta(days=7)
|
||
|
|
|
||
|
|
usage_report = await production_service.get_capacity_usage_report(tenant_id, start_date, end_date)
|
||
|
|
|
||
|
|
logger.info("Retrieved capacity usage report",
|
||
|
|
tenant_id=str(tenant_id),
|
||
|
|
start_date=start_date.isoformat(),
|
||
|
|
end_date=end_date.isoformat())
|
||
|
|
|
||
|
|
return usage_report
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error getting capacity usage",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to get capacity usage")
|
||
|
|
|
||
|
|
|
||
|
|
# ===== CAPACITY MANAGEMENT =====
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_operations_route("capacity/status"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def get_capacity_status(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
target_date: Optional[date] = Query(None),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Get real-time capacity status"""
|
||
|
|
try:
|
||
|
|
if not target_date:
|
||
|
|
target_date = datetime.now().date()
|
||
|
|
|
||
|
|
status = await production_service.get_capacity_status(tenant_id, target_date)
|
||
|
|
|
||
|
|
logger.info("Retrieved capacity status",
|
||
|
|
tenant_id=str(tenant_id), date=target_date.isoformat())
|
||
|
|
|
||
|
|
return status
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error getting capacity status",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to get capacity status")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_operations_route("capacity/availability"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def check_resource_availability(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
target_date: date = Query(...),
|
||
|
|
required_capacity: float = Query(..., gt=0),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Check if capacity is available for scheduling"""
|
||
|
|
try:
|
||
|
|
availability = await production_service.check_resource_availability(
|
||
|
|
tenant_id, target_date, required_capacity
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info("Checked resource availability",
|
||
|
|
tenant_id=str(tenant_id),
|
||
|
|
date=target_date.isoformat(),
|
||
|
|
required=required_capacity)
|
||
|
|
|
||
|
|
return availability
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error checking resource availability",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to check resource availability")
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_operations_route("capacity/reserve"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def reserve_capacity(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
target_date: date = Query(...),
|
||
|
|
capacity_amount: float = Query(..., gt=0),
|
||
|
|
batch_id: UUID = Query(...),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Reserve capacity for a batch"""
|
||
|
|
try:
|
||
|
|
reservation = await production_service.reserve_capacity(
|
||
|
|
tenant_id, target_date, capacity_amount, batch_id
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info("Reserved production capacity",
|
||
|
|
tenant_id=str(tenant_id),
|
||
|
|
date=target_date.isoformat(),
|
||
|
|
amount=capacity_amount,
|
||
|
|
batch_id=str(batch_id))
|
||
|
|
|
||
|
|
return reservation
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
logger.warning("Cannot reserve capacity", error=str(e))
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error reserving capacity",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to reserve capacity")
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
route_builder.build_operations_route("capacity/bottlenecks"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def get_capacity_bottlenecks(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
days_ahead: int = Query(7, ge=1, le=30),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Identify capacity bottlenecks in upcoming period"""
|
||
|
|
try:
|
||
|
|
bottlenecks = await production_service.predict_capacity_bottlenecks(tenant_id, days_ahead)
|
||
|
|
|
||
|
|
logger.info("Retrieved capacity bottlenecks prediction",
|
||
|
|
tenant_id=str(tenant_id), days_ahead=days_ahead)
|
||
|
|
|
||
|
|
return bottlenecks
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error getting capacity bottlenecks",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to get capacity bottlenecks")
|
||
|
|
|
||
|
|
|
||
|
|
# ===== TRANSFORMATION OPERATIONS =====
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_operations_route("batches/complete-with-transformation"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def complete_batch_with_transformation(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
batch_id: UUID = Query(...),
|
||
|
|
transformation_data: dict = None,
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Complete batch and create product transformation record"""
|
||
|
|
try:
|
||
|
|
result = await production_service.complete_batch_with_transformation(
|
||
|
|
tenant_id, batch_id, transformation_data
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info("Completed batch with transformation",
|
||
|
|
tenant_id=str(tenant_id),
|
||
|
|
batch_id=str(batch_id))
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
logger.warning("Cannot complete batch with transformation", error=str(e))
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error completing batch with transformation",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to complete batch with transformation")
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
route_builder.build_operations_route("transform-par-baked"),
|
||
|
|
response_model=dict
|
||
|
|
)
|
||
|
|
async def transform_par_baked_products(
|
||
|
|
tenant_id: UUID = Path(...),
|
||
|
|
source_batch_id: UUID = Query(...),
|
||
|
|
target_quantity: float = Query(..., gt=0),
|
||
|
|
current_user: dict = Depends(get_current_user_dep),
|
||
|
|
production_service: ProductionService = Depends(get_production_service)
|
||
|
|
):
|
||
|
|
"""Transform par-baked products to fully baked"""
|
||
|
|
try:
|
||
|
|
result = await production_service.transform_par_baked_to_fresh(
|
||
|
|
tenant_id, source_batch_id, target_quantity
|
||
|
|
)
|
||
|
|
|
||
|
|
logger.info("Transformed par-baked products",
|
||
|
|
tenant_id=str(tenant_id),
|
||
|
|
source_batch_id=str(source_batch_id),
|
||
|
|
quantity=target_quantity)
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
except ValueError as e:
|
||
|
|
logger.warning("Cannot transform products", error=str(e))
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("Error transforming products",
|
||
|
|
error=str(e), tenant_id=str(tenant_id))
|
||
|
|
raise HTTPException(status_code=500, detail="Failed to transform products")
|