Files
bakery-ia/services/inventory/app/api/enterprise_inventory.py

315 lines
14 KiB
Python
Raw Normal View History

2025-12-17 20:50:22 +01:00
"""
Enterprise Inventory API Endpoints
APIs for enterprise-level inventory management across outlets
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field
import structlog
from app.services.enterprise_inventory_service import EnterpriseInventoryService
from shared.auth.tenant_access import verify_tenant_permission_dep
from shared.clients import get_inventory_client, get_tenant_client
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter()
# Pydantic models for request/response
class InventoryCoverageResponse(BaseModel):
outlet_id: str = Field(..., description="Outlet tenant ID")
outlet_name: str = Field(..., description="Outlet name")
overall_coverage: float = Field(..., description="Overall inventory coverage percentage (0-100)")
critical_items_count: int = Field(..., description="Number of items at critical stock levels")
high_risk_items_count: int = Field(..., description="Number of items at high risk of stockout")
medium_risk_items_count: int = Field(..., description="Number of items at medium risk")
low_risk_items_count: int = Field(..., description="Number of items at low risk")
fulfillment_rate: float = Field(..., description="Order fulfillment rate percentage (0-100)")
last_updated: str = Field(..., description="Last inventory update timestamp")
status: str = Field(..., description="Overall status: normal, warning, critical")
class ProductCoverageDetail(BaseModel):
product_id: str = Field(..., description="Product ID")
product_name: str = Field(..., description="Product name")
current_stock: int = Field(..., description="Current stock quantity")
safety_stock: int = Field(..., description="Safety stock threshold")
coverage_percentage: float = Field(..., description="Coverage percentage (current/safety)")
risk_level: str = Field(..., description="Risk level: critical, high, medium, low")
days_until_stockout: Optional[int] = Field(None, description="Estimated days until stockout")
class OutletInventoryDetailResponse(BaseModel):
outlet_id: str = Field(..., description="Outlet tenant ID")
outlet_name: str = Field(..., description="Outlet name")
overall_coverage: float = Field(..., description="Overall inventory coverage percentage")
products: List[ProductCoverageDetail] = Field(..., description="Product-level inventory details")
last_updated: str = Field(..., description="Last update timestamp")
class NetworkInventorySummary(BaseModel):
total_outlets: int = Field(..., description="Total number of outlets")
average_coverage: float = Field(..., description="Network average inventory coverage")
average_fulfillment_rate: float = Field(..., description="Network average fulfillment rate")
critical_outlets: int = Field(..., description="Number of outlets with critical status")
warning_outlets: int = Field(..., description="Number of outlets with warning status")
normal_outlets: int = Field(..., description="Number of outlets with normal status")
total_critical_items: int = Field(..., description="Total critical items across network")
network_health_score: float = Field(..., description="Overall network health score (0-100)")
class InventoryAlert(BaseModel):
alert_id: str = Field(..., description="Alert ID")
outlet_id: str = Field(..., description="Outlet ID")
outlet_name: str = Field(..., description="Outlet name")
product_id: Optional[str] = Field(None, description="Product ID if applicable")
product_name: Optional[str] = Field(None, description="Product name if applicable")
alert_type: str = Field(..., description="Type of alert: stockout_risk, low_coverage, etc.")
severity: str = Field(..., description="Severity: critical, high, medium, low")
current_coverage: float = Field(..., description="Current inventory coverage percentage")
threshold: float = Field(..., description="Threshold that triggered alert")
timestamp: str = Field(..., description="Alert timestamp")
message: str = Field(..., description="Alert message")
async def get_enterprise_inventory_service() -> "EnterpriseInventoryService":
"""Dependency injection for EnterpriseInventoryService"""
inventory_client = get_inventory_client(settings, "inventory-service")
tenant_client = get_tenant_client(settings, "inventory-service")
return EnterpriseInventoryService(
inventory_client=inventory_client,
tenant_client=tenant_client
)
@router.get("/tenants/{parent_id}/outlets/inventory-coverage",
response_model=List[InventoryCoverageResponse],
summary="Get inventory coverage for all outlets in network")
async def get_outlet_inventory_coverage(
parent_id: str,
min_coverage: Optional[float] = Query(None, description="Filter outlets with coverage below this threshold"),
risk_level: Optional[str] = Query(None, description="Filter by risk level: critical, high, medium, low"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get inventory coverage metrics for all child outlets in a parent tenant's network
This endpoint provides a comprehensive view of inventory health across all outlets,
enabling enterprise managers to identify stockout risks and prioritize inventory transfers.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access outlet inventory coverage"
)
# Get all child outlets for this parent
child_outlets = await enterprise_inventory_service.get_child_outlets(parent_id)
if not child_outlets:
return []
# Get inventory coverage for each outlet
coverage_data = []
for outlet in child_outlets:
outlet_id = outlet['id']
# Get inventory coverage data
coverage = await enterprise_inventory_service.get_inventory_coverage(outlet_id)
if coverage:
# Apply filters if specified
if min_coverage is not None and coverage['overall_coverage'] >= min_coverage:
continue
if risk_level is not None and coverage.get('status') != risk_level:
continue
coverage_data.append(coverage)
# Sort by coverage (lowest first) to prioritize critical outlets
coverage_data.sort(key=lambda x: x['overall_coverage'])
return coverage_data
except Exception as e:
logger.error("Failed to get outlet inventory coverage", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory coverage: {str(e)}")
@router.get("/tenants/{parent_id}/outlets/inventory-summary",
response_model=NetworkInventorySummary,
summary="Get network-wide inventory summary")
async def get_network_inventory_summary(
parent_id: str,
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get aggregated inventory summary across the entire network
Provides key metrics for network health monitoring and decision making.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access network inventory summary"
)
return await enterprise_inventory_service.get_network_inventory_summary(parent_id)
except Exception as e:
logger.error("Failed to get network inventory summary", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory summary: {str(e)}")
@router.get("/tenants/{parent_id}/outlets/{outlet_id}/inventory-details",
response_model=OutletInventoryDetailResponse,
summary="Get detailed inventory for specific outlet")
async def get_outlet_inventory_details(
parent_id: str,
outlet_id: str,
product_id: Optional[str] = Query(None, description="Filter by specific product ID"),
risk_level: Optional[str] = Query(None, description="Filter products by risk level"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get detailed product-level inventory data for a specific outlet
Enables drill-down analysis of inventory issues at the product level.
"""
try:
# Verify parent-child relationship
await enterprise_inventory_service.verify_parent_child_relationship(parent_id, outlet_id)
return await enterprise_inventory_service.get_outlet_inventory_details(outlet_id, product_id, risk_level)
except Exception as e:
logger.error("Failed to get outlet inventory details", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory details: {str(e)}")
@router.get("/tenants/{parent_id}/inventory-alerts",
response_model=List[InventoryAlert],
summary="Get real-time inventory alerts across network")
async def get_network_inventory_alerts(
parent_id: str,
severity: Optional[str] = Query(None, description="Filter by severity: critical, high, medium, low"),
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
limit: int = Query(50, description="Maximum number of alerts to return"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get real-time inventory alerts across all outlets
Provides actionable alerts for inventory management and stockout prevention.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access network inventory alerts"
)
alerts = await enterprise_inventory_service.get_inventory_alerts(parent_id)
# Apply filters
if severity:
alerts = [alert for alert in alerts if alert.get('severity') == severity]
if alert_type:
alerts = [alert for alert in alerts if alert.get('alert_type') == alert_type]
# Sort by severity (critical first) and timestamp (newest first)
severity_order = {'critical': 1, 'high': 2, 'medium': 3, 'low': 4}
alerts.sort(key=lambda x: (severity_order.get(x.get('severity', 'low'), 5), -int(x.get('timestamp', 0))))
return alerts[:limit]
except Exception as e:
logger.error("Failed to get inventory alerts", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get inventory alerts: {str(e)}")
@router.post("/tenants/{parent_id}/inventory-transfers/recommend",
summary="Get inventory transfer recommendations")
async def get_inventory_transfer_recommendations(
parent_id: str,
urgency: str = Query("medium", description="Urgency level: low, medium, high, critical"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get AI-powered inventory transfer recommendations
Analyzes inventory levels across outlets and suggests optimal transfers
to prevent stockouts and balance inventory.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can request transfer recommendations"
)
recommendations = await enterprise_inventory_service.get_transfer_recommendations(parent_id, urgency)
return {
'success': True,
'recommendations': recommendations,
'message': f'Generated {len(recommendations)} transfer recommendations'
}
except Exception as e:
logger.error("Failed to get transfer recommendations", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get recommendations: {str(e)}")
@router.get("/tenants/{parent_id}/inventory/coverage-trends",
summary="Get inventory coverage trends over time")
async def get_inventory_coverage_trends(
parent_id: str,
days: int = Query(30, description="Number of days to analyze"),
enterprise_inventory_service: EnterpriseInventoryService = Depends(get_enterprise_inventory_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get historical inventory coverage trends
Enables analysis of inventory performance over time.
"""
try:
# Verify this is a parent tenant
tenant_info = await enterprise_inventory_service.tenant_client.get_tenant(parent_id)
if tenant_info.get('tenant_type') != 'parent':
raise HTTPException(
status_code=403,
detail="Only parent tenants can access coverage trends"
)
trends = await enterprise_inventory_service.get_coverage_trends(parent_id, days)
return {
'success': True,
'trends': trends,
'period': f'Last {days} days'
}
except Exception as e:
logger.error("Failed to get coverage trends", error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to get coverage trends: {str(e)}")