474 lines
20 KiB
Python
474 lines
20 KiB
Python
"""
|
|
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)}")
|