Imporve enterprise
This commit is contained in:
314
services/inventory/app/api/enterprise_inventory.py
Normal file
314
services/inventory/app/api/enterprise_inventory.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
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)}")
|
||||
@@ -32,7 +32,8 @@ from app.api import (
|
||||
analytics,
|
||||
sustainability,
|
||||
audit,
|
||||
ml_insights
|
||||
ml_insights,
|
||||
enterprise_inventory
|
||||
)
|
||||
from app.api.internal_alert_trigger import router as internal_alert_trigger_router
|
||||
from app.api.internal_demo import router as internal_demo_router
|
||||
@@ -217,6 +218,7 @@ service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
service.add_router(ml_insights.router) # ML insights endpoint
|
||||
service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning
|
||||
service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning
|
||||
service.add_router(enterprise_inventory.router) # Enterprise inventory endpoints
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
473
services/inventory/app/services/enterprise_inventory_service.py
Normal file
473
services/inventory/app/services/enterprise_inventory_service.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Enterprise Inventory Service
|
||||
Business logic for enterprise-level inventory management across outlets
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class EnterpriseInventoryService:
|
||||
"""
|
||||
Service for managing inventory across enterprise networks
|
||||
"""
|
||||
|
||||
def __init__(self, inventory_client, tenant_client):
|
||||
self.inventory_client = inventory_client
|
||||
self.tenant_client = tenant_client
|
||||
|
||||
async def get_child_outlets(self, parent_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all child outlets for a parent tenant
|
||||
"""
|
||||
try:
|
||||
# Get child tenants from tenant service
|
||||
children = await self.tenant_client.get_child_tenants(parent_id)
|
||||
|
||||
# Enrich with location data
|
||||
enriched_outlets = []
|
||||
for child in children:
|
||||
# Get location data for this outlet
|
||||
locations = await self.tenant_client.get_tenant_locations(child['id'])
|
||||
|
||||
outlet_info = {
|
||||
'id': child['id'],
|
||||
'name': child['name'],
|
||||
'subdomain': child.get('subdomain'),
|
||||
'location': locations[0] if locations else None
|
||||
}
|
||||
enriched_outlets.append(outlet_info)
|
||||
|
||||
return enriched_outlets
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get child outlets", parent_id=parent_id, error=str(e))
|
||||
raise Exception(f"Failed to get child outlets: {str(e)}")
|
||||
|
||||
async def get_inventory_coverage(self, outlet_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get inventory coverage metrics for a specific outlet
|
||||
"""
|
||||
try:
|
||||
# Get current inventory data
|
||||
inventory_data = await self.inventory_client.get_current_inventory(outlet_id)
|
||||
|
||||
if not inventory_data or not inventory_data.get('items'):
|
||||
return None
|
||||
|
||||
# Calculate coverage metrics
|
||||
total_items = len(inventory_data['items'])
|
||||
critical_count = 0
|
||||
high_risk_count = 0
|
||||
medium_risk_count = 0
|
||||
low_risk_count = 0
|
||||
total_coverage = 0
|
||||
|
||||
for item in inventory_data['items']:
|
||||
current_stock = item.get('current_stock', 0)
|
||||
safety_stock = item.get('safety_stock', 1) # Avoid division by zero
|
||||
|
||||
if safety_stock <= 0:
|
||||
safety_stock = 1
|
||||
|
||||
coverage = min(100, (current_stock / safety_stock) * 100)
|
||||
total_coverage += coverage
|
||||
|
||||
# Determine risk level
|
||||
if coverage < 30:
|
||||
critical_count += 1
|
||||
elif coverage < 50:
|
||||
high_risk_count += 1
|
||||
elif coverage < 70:
|
||||
medium_risk_count += 1
|
||||
else:
|
||||
low_risk_count += 1
|
||||
|
||||
# Calculate average coverage
|
||||
avg_coverage = total_coverage / total_items if total_items > 0 else 0
|
||||
|
||||
# Get fulfillment rate (simplified - in real implementation this would come from orders service)
|
||||
fulfillment_rate = await self._calculate_fulfillment_rate(outlet_id)
|
||||
|
||||
# Determine overall status
|
||||
status = self._determine_inventory_status(critical_count, high_risk_count, avg_coverage)
|
||||
|
||||
return {
|
||||
'outlet_id': outlet_id,
|
||||
'outlet_name': inventory_data.get('tenant_name', f'Outlet {outlet_id}'),
|
||||
'overall_coverage': round(avg_coverage, 1),
|
||||
'critical_items_count': critical_count,
|
||||
'high_risk_items_count': high_risk_count,
|
||||
'medium_risk_items_count': medium_risk_count,
|
||||
'low_risk_items_count': low_risk_count,
|
||||
'fulfillment_rate': round(fulfillment_rate, 1),
|
||||
'last_updated': datetime.now().isoformat(),
|
||||
'status': status
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory coverage", outlet_id=outlet_id, error=str(e))
|
||||
raise Exception(f"Failed to get inventory coverage: {str(e)}")
|
||||
|
||||
async def _calculate_fulfillment_rate(self, outlet_id: str) -> float:
|
||||
"""
|
||||
Calculate fulfillment rate for an outlet (simplified)
|
||||
In a real implementation, this would query the orders service
|
||||
"""
|
||||
# This is a placeholder - real implementation would:
|
||||
# 1. Get recent orders from orders service
|
||||
# 2. Calculate % successfully fulfilled
|
||||
# 3. Return the rate
|
||||
|
||||
# For demo purposes, return a reasonable default
|
||||
return 95.0
|
||||
|
||||
def _determine_inventory_status(self, critical_count: int, high_risk_count: int, avg_coverage: float) -> str:
|
||||
"""
|
||||
Determine overall inventory status based on risk factors
|
||||
"""
|
||||
if critical_count > 5 or (critical_count > 0 and avg_coverage < 40):
|
||||
return 'critical'
|
||||
elif high_risk_count > 3 or (high_risk_count > 0 and avg_coverage < 60):
|
||||
return 'warning'
|
||||
else:
|
||||
return 'normal'
|
||||
|
||||
async def get_network_inventory_summary(self, parent_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get aggregated inventory summary across the entire network
|
||||
"""
|
||||
try:
|
||||
# Get all child outlets
|
||||
child_outlets = await self.get_child_outlets(parent_id)
|
||||
|
||||
if not child_outlets:
|
||||
return {
|
||||
'total_outlets': 0,
|
||||
'average_coverage': 0,
|
||||
'average_fulfillment_rate': 0,
|
||||
'critical_outlets': 0,
|
||||
'warning_outlets': 0,
|
||||
'normal_outlets': 0,
|
||||
'total_critical_items': 0,
|
||||
'network_health_score': 0
|
||||
}
|
||||
|
||||
# Get coverage for each outlet
|
||||
coverage_data = []
|
||||
for outlet in child_outlets:
|
||||
coverage = await self.get_inventory_coverage(outlet['id'])
|
||||
if coverage:
|
||||
coverage_data.append(coverage)
|
||||
|
||||
if not coverage_data:
|
||||
return {
|
||||
'total_outlets': len(child_outlets),
|
||||
'average_coverage': 0,
|
||||
'average_fulfillment_rate': 0,
|
||||
'critical_outlets': 0,
|
||||
'warning_outlets': 0,
|
||||
'normal_outlets': len(child_outlets),
|
||||
'total_critical_items': 0,
|
||||
'network_health_score': 0
|
||||
}
|
||||
|
||||
# Calculate network metrics
|
||||
total_coverage = sum(c['overall_coverage'] for c in coverage_data)
|
||||
total_fulfillment = sum(c['fulfillment_rate'] for c in coverage_data)
|
||||
|
||||
avg_coverage = total_coverage / len(coverage_data)
|
||||
avg_fulfillment = total_fulfillment / len(coverage_data)
|
||||
|
||||
critical_outlets = sum(1 for c in coverage_data if c['status'] == 'critical')
|
||||
warning_outlets = sum(1 for c in coverage_data if c['status'] == 'warning')
|
||||
normal_outlets = sum(1 for c in coverage_data if c['status'] == 'normal')
|
||||
|
||||
total_critical_items = sum(c['critical_items_count'] for c in coverage_data)
|
||||
|
||||
# Calculate network health score (weighted average)
|
||||
network_health = round(avg_coverage * 0.6 + avg_fulfillment * 0.4, 1)
|
||||
|
||||
return {
|
||||
'total_outlets': len(child_outlets),
|
||||
'average_coverage': round(avg_coverage, 1),
|
||||
'average_fulfillment_rate': round(avg_fulfillment, 1),
|
||||
'critical_outlets': critical_outlets,
|
||||
'warning_outlets': warning_outlets,
|
||||
'normal_outlets': normal_outlets,
|
||||
'total_critical_items': total_critical_items,
|
||||
'network_health_score': network_health
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get network inventory summary", parent_id=parent_id, error=str(e))
|
||||
raise Exception(f"Failed to get network inventory summary: {str(e)}")
|
||||
|
||||
async def get_outlet_inventory_details(self, outlet_id: str, product_id: Optional[str] = None, risk_level: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed product-level inventory data for a specific outlet
|
||||
"""
|
||||
try:
|
||||
# Get current inventory data
|
||||
inventory_data = await self.inventory_client.get_current_inventory(outlet_id)
|
||||
|
||||
if not inventory_data or not inventory_data.get('items'):
|
||||
return {
|
||||
'outlet_id': outlet_id,
|
||||
'outlet_name': inventory_data.get('tenant_name', f'Outlet {outlet_id}'),
|
||||
'overall_coverage': 0,
|
||||
'products': [],
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Process product details
|
||||
products = []
|
||||
total_coverage = 0
|
||||
|
||||
for item in inventory_data['items']:
|
||||
# Filter by product_id if specified
|
||||
if product_id and item.get('product_id') != product_id:
|
||||
continue
|
||||
|
||||
current_stock = item.get('current_stock', 0)
|
||||
safety_stock = item.get('safety_stock', 1)
|
||||
|
||||
if safety_stock <= 0:
|
||||
safety_stock = 1
|
||||
|
||||
coverage = min(100, (current_stock / safety_stock) * 100)
|
||||
total_coverage += coverage
|
||||
|
||||
# Determine risk level
|
||||
if coverage < 30:
|
||||
risk = 'critical'
|
||||
elif coverage < 50:
|
||||
risk = 'high'
|
||||
elif coverage < 70:
|
||||
risk = 'medium'
|
||||
else:
|
||||
risk = 'low'
|
||||
|
||||
# Filter by risk level if specified
|
||||
if risk_level and risk != risk_level:
|
||||
continue
|
||||
|
||||
# Calculate days until stockout (simplified)
|
||||
daily_usage = item.get('average_daily_usage', 1)
|
||||
days_until_stockout = None
|
||||
|
||||
if daily_usage > 0:
|
||||
days_until_stockout = max(0, int((current_stock - safety_stock) / daily_usage))
|
||||
if days_until_stockout < 0:
|
||||
days_until_stockout = 0
|
||||
|
||||
product_detail = {
|
||||
'product_id': item.get('product_id'),
|
||||
'product_name': item.get('product_name', 'Unknown Product'),
|
||||
'current_stock': current_stock,
|
||||
'safety_stock': safety_stock,
|
||||
'coverage_percentage': round(coverage, 1),
|
||||
'risk_level': risk,
|
||||
'days_until_stockout': days_until_stockout
|
||||
}
|
||||
|
||||
products.append(product_detail)
|
||||
|
||||
# Calculate overall coverage
|
||||
avg_coverage = total_coverage / len(inventory_data['items']) if inventory_data['items'] else 0
|
||||
|
||||
return {
|
||||
'outlet_id': outlet_id,
|
||||
'outlet_name': inventory_data.get('tenant_name', f'Outlet {outlet_id}'),
|
||||
'overall_coverage': round(avg_coverage, 1),
|
||||
'products': products,
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get outlet inventory details", outlet_id=outlet_id, error=str(e))
|
||||
raise Exception(f"Failed to get outlet inventory details: {str(e)}")
|
||||
|
||||
async def get_inventory_alerts(self, parent_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get real-time inventory alerts across all outlets
|
||||
"""
|
||||
try:
|
||||
# Get all child outlets
|
||||
child_outlets = await self.get_child_outlets(parent_id)
|
||||
|
||||
alerts = []
|
||||
|
||||
for outlet in child_outlets:
|
||||
outlet_id = outlet['id']
|
||||
outlet_name = outlet['name']
|
||||
|
||||
# Get inventory coverage for this outlet
|
||||
coverage = await self.get_inventory_coverage(outlet_id)
|
||||
|
||||
if coverage:
|
||||
# Create alerts for critical items
|
||||
if coverage['critical_items_count'] > 0:
|
||||
alerts.append({
|
||||
'alert_id': str(uuid.uuid4()),
|
||||
'outlet_id': outlet_id,
|
||||
'outlet_name': outlet_name,
|
||||
'product_id': None,
|
||||
'product_name': None,
|
||||
'alert_type': 'low_coverage',
|
||||
'severity': 'critical',
|
||||
'current_coverage': coverage['overall_coverage'],
|
||||
'threshold': 30,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'message': f"Critical inventory coverage: {coverage['overall_coverage']}% (threshold: 30%)"
|
||||
})
|
||||
|
||||
# Create alerts for high risk items
|
||||
if coverage['high_risk_items_count'] > 0:
|
||||
alerts.append({
|
||||
'alert_id': str(uuid.uuid4()),
|
||||
'outlet_id': outlet_id,
|
||||
'outlet_name': outlet_name,
|
||||
'product_id': None,
|
||||
'product_name': None,
|
||||
'alert_type': 'stockout_risk',
|
||||
'severity': 'high',
|
||||
'current_coverage': coverage['overall_coverage'],
|
||||
'threshold': 50,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'message': f"High stockout risk: {coverage['overall_coverage']}% coverage"
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory alerts", parent_id=parent_id, error=str(e))
|
||||
raise Exception(f"Failed to get inventory alerts: {str(e)}")
|
||||
|
||||
async def get_transfer_recommendations(self, parent_id: str, urgency: str = "medium") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get AI-powered inventory transfer recommendations
|
||||
"""
|
||||
try:
|
||||
# Get inventory coverage for all outlets
|
||||
child_outlets = await self.get_child_outlets(parent_id)
|
||||
coverage_data = []
|
||||
|
||||
for outlet in child_outlets:
|
||||
coverage = await self.get_inventory_coverage(outlet['id'])
|
||||
if coverage:
|
||||
coverage_data.append(coverage)
|
||||
|
||||
# Simple recommendation algorithm (in real implementation, this would be more sophisticated)
|
||||
recommendations = []
|
||||
|
||||
# Find outlets with surplus and deficit
|
||||
surplus_outlets = [c for c in coverage_data if c['overall_coverage'] > 85]
|
||||
deficit_outlets = [c for c in coverage_data if c['overall_coverage'] < 60]
|
||||
|
||||
# Generate transfer recommendations
|
||||
for deficit in deficit_outlets:
|
||||
for surplus in surplus_outlets:
|
||||
# Calculate transfer amount (simplified)
|
||||
transfer_amount = min(10, (deficit['overall_coverage'] - 60) * -2) # Transfer 2% per missing %
|
||||
|
||||
if transfer_amount > 0:
|
||||
recommendations.append({
|
||||
'recommendation_id': str(uuid.uuid4()),
|
||||
'from_outlet_id': surplus['outlet_id'],
|
||||
'from_outlet_name': surplus['outlet_name'],
|
||||
'to_outlet_id': deficit['outlet_id'],
|
||||
'to_outlet_name': deficit['outlet_name'],
|
||||
'transfer_amount': transfer_amount,
|
||||
'priority': self._calculate_priority(deficit, urgency),
|
||||
'reason': f"Balance inventory: {surplus['outlet_name']} has {surplus['overall_coverage']}% coverage, {deficit['outlet_name']} has {deficit['overall_coverage']}% coverage",
|
||||
'estimated_impact': f"Improve {deficit['outlet_name']} coverage by ~{transfer_amount}%"
|
||||
})
|
||||
|
||||
# Sort by priority
|
||||
recommendations.sort(key=lambda x: x['priority'], reverse=True)
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get transfer recommendations", parent_id=parent_id, error=str(e))
|
||||
raise Exception(f"Failed to get transfer recommendations: {str(e)}")
|
||||
|
||||
def _calculate_priority(self, deficit_coverage: Dict[str, Any], urgency: str) -> int:
|
||||
"""
|
||||
Calculate priority score for transfer recommendation
|
||||
"""
|
||||
priority_scores = {
|
||||
'critical': 4,
|
||||
'high': 3,
|
||||
'medium': 2,
|
||||
'low': 1
|
||||
}
|
||||
|
||||
urgency_score = priority_scores.get(urgency, 2)
|
||||
|
||||
# Higher priority for lower coverage
|
||||
coverage_score = max(1, 5 - int(deficit_coverage['overall_coverage'] / 20))
|
||||
|
||||
return urgency_score * coverage_score
|
||||
|
||||
async def get_coverage_trends(self, parent_id: str, days: int = 30) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical inventory coverage trends
|
||||
"""
|
||||
try:
|
||||
# In a real implementation, this would query historical data
|
||||
# For demo purposes, generate some sample trend data
|
||||
|
||||
trends = []
|
||||
end_date = datetime.now()
|
||||
|
||||
for i in range(days):
|
||||
date = end_date - timedelta(days=i)
|
||||
|
||||
# Generate sample data with some variation
|
||||
base_coverage = 75
|
||||
variation = (i % 7) - 3 # Weekly pattern
|
||||
daily_variation = (i % 3) - 1 # Daily noise
|
||||
|
||||
coverage = max(50, min(95, base_coverage + variation + daily_variation))
|
||||
|
||||
trends.append({
|
||||
'date': date.strftime('%Y-%m-%d'),
|
||||
'average_coverage': round(coverage, 1),
|
||||
'min_coverage': max(40, coverage - 15),
|
||||
'max_coverage': min(95, coverage + 10)
|
||||
})
|
||||
|
||||
# Sort by date (oldest first)
|
||||
trends.sort(key=lambda x: x['date'])
|
||||
|
||||
return trends
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get coverage trends", parent_id=parent_id, error=str(e))
|
||||
raise Exception(f"Failed to get coverage trends: {str(e)}")
|
||||
|
||||
async def verify_parent_child_relationship(self, parent_id: str, child_id: str) -> bool:
|
||||
"""
|
||||
Verify that a child tenant belongs to a parent tenant
|
||||
"""
|
||||
try:
|
||||
# Get child tenant info
|
||||
child_info = await self.tenant_client.get_tenant(child_id)
|
||||
|
||||
if child_info.get('parent_tenant_id') != parent_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Child tenant does not belong to specified parent"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to verify parent-child relationship", parent_id=parent_id, child_id=child_id, error=str(e))
|
||||
raise Exception(f"Failed to verify relationship: {str(e)}")
|
||||
Reference in New Issue
Block a user