315 lines
14 KiB
Python
315 lines
14 KiB
Python
|
|
"""
|
||
|
|
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)}")
|