Files
bakery-ia/services/orchestrator/app/services/enterprise_dashboard_service.py
2025-11-30 16:29:38 +01:00

648 lines
25 KiB
Python

"""
Enterprise Dashboard Service for Orchestrator
Handles aggregated metrics and data for enterprise tier parent tenants
"""
import asyncio
from typing import Dict, Any, List
from datetime import date, datetime, timedelta
import structlog
from decimal import Decimal
# Import clients
from shared.clients.tenant_client import TenantServiceClient
from shared.clients.forecast_client import ForecastServiceClient
from shared.clients.production_client import ProductionServiceClient
from shared.clients.sales_client import SalesServiceClient
from shared.clients.inventory_client import InventoryServiceClient
from shared.clients.distribution_client import DistributionServiceClient
from shared.clients.procurement_client import ProcurementServiceClient
logger = structlog.get_logger()
class EnterpriseDashboardService:
"""
Service for providing enterprise dashboard data for parent tenants
"""
def __init__(
self,
tenant_client: TenantServiceClient,
forecast_client: ForecastServiceClient,
production_client: ProductionServiceClient,
sales_client: SalesServiceClient,
inventory_client: InventoryServiceClient,
distribution_client: DistributionServiceClient,
procurement_client: ProcurementServiceClient
):
self.tenant_client = tenant_client
self.forecast_client = forecast_client
self.production_client = production_client
self.sales_client = sales_client
self.inventory_client = inventory_client
self.distribution_client = distribution_client
self.procurement_client = procurement_client
async def get_network_summary(
self,
parent_tenant_id: str
) -> Dict[str, Any]:
"""
Get network summary metrics for enterprise dashboard
Args:
parent_tenant_id: Parent tenant ID
Returns:
Dict with aggregated network metrics
"""
logger.info("Getting network summary for parent tenant", parent_tenant_id=parent_tenant_id)
# Get child tenants
child_tenants = await self.tenant_client.get_child_tenants(parent_tenant_id)
child_tenant_ids = [child['id'] for child in (child_tenants or [])]
# Fetch metrics in parallel
tasks = [
self._get_child_count(parent_tenant_id),
self._get_network_sales(parent_tenant_id, child_tenant_ids),
self._get_production_volume(parent_tenant_id),
self._get_pending_internal_transfers(parent_tenant_id),
self._get_active_shipments(parent_tenant_id)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle results and errors
child_count = results[0] if not isinstance(results[0], Exception) else 0
network_sales = results[1] if not isinstance(results[1], Exception) else 0
production_volume = results[2] if not isinstance(results[2], Exception) else 0
pending_transfers = results[3] if not isinstance(results[3], Exception) else 0
active_shipments = results[4] if not isinstance(results[4], Exception) else 0
return {
'parent_tenant_id': parent_tenant_id,
'child_tenant_count': child_count,
'network_sales_30d': float(network_sales),
'production_volume_30d': float(production_volume),
'pending_internal_transfers_count': pending_transfers,
'active_shipments_count': active_shipments,
'last_updated': datetime.utcnow().isoformat()
}
async def _get_child_count(self, parent_tenant_id: str) -> int:
"""Get count of child tenants"""
try:
child_tenants = await self.tenant_client.get_child_tenants(parent_tenant_id)
return len(child_tenants)
except Exception as e:
logger.warning(f"Could not get child count for parent tenant {parent_tenant_id}: {e}")
return 0
async def _get_network_sales(self, parent_tenant_id: str, child_tenant_ids: List[str]) -> float:
"""Get total network sales for the last 30 days"""
try:
total_sales = Decimal("0.00")
start_date = date.today() - timedelta(days=30)
end_date = date.today()
# Include parent tenant sales
try:
parent_sales = await self.sales_client.get_sales_summary(
tenant_id=parent_tenant_id,
start_date=start_date,
end_date=end_date
)
total_sales += Decimal(str(parent_sales.get('total_revenue', 0)))
except Exception as e:
logger.warning(f"Could not get sales for parent tenant {parent_tenant_id}: {e}")
# Add child tenant sales
for child_id in child_tenant_ids:
try:
child_sales = await self.sales_client.get_sales_summary(
tenant_id=child_id,
start_date=start_date,
end_date=end_date
)
total_sales += Decimal(str(child_sales.get('total_revenue', 0)))
except Exception as e:
logger.warning(f"Could not get sales for child tenant {child_id}: {e}")
return float(total_sales)
except Exception as e:
logger.error(f"Error getting network sales: {e}")
return 0.0
async def _get_production_volume(self, parent_tenant_id: str) -> float:
"""Get total production volume for the parent tenant (central production)"""
try:
production_summary = await self.production_client.get_dashboard_summary(
tenant_id=parent_tenant_id
)
# Return total production value
return float(production_summary.get('total_value', 0))
except Exception as e:
logger.warning(f"Could not get production volume for parent tenant {parent_tenant_id}: {e}")
return 0.0
async def _get_pending_internal_transfers(self, parent_tenant_id: str) -> int:
"""Get count of pending internal transfer orders from parent to children"""
try:
# Get pending internal purchase orders for parent tenant
pending_pos = await self.procurement_client.get_approved_internal_purchase_orders(
parent_tenant_id=parent_tenant_id,
status="pending" # or whatever status indicates pending delivery
)
return len(pending_pos) if pending_pos else 0
except Exception as e:
logger.warning(f"Could not get pending internal transfers for parent tenant {parent_tenant_id}: {e}")
return 0
async def _get_active_shipments(self, parent_tenant_id: str) -> int:
"""Get count of active shipments for today"""
try:
today = date.today()
shipments = await self.distribution_client.get_shipments_for_date(
parent_tenant_id,
today
)
# Filter for active shipments (not delivered/cancelled)
active_statuses = ['pending', 'in_transit', 'packed']
active_shipments = [s for s in shipments if s.get('status') in active_statuses]
return len(active_shipments)
except Exception as e:
logger.warning(f"Could not get active shipments for parent tenant {parent_tenant_id}: {e}")
return 0
async def get_children_performance(
self,
parent_tenant_id: str,
metric: str = "sales",
period_days: int = 30
) -> Dict[str, Any]:
"""
Get anonymized performance ranking of child tenants
Args:
parent_tenant_id: Parent tenant ID
metric: Metric to rank by ('sales', 'inventory_value', 'order_frequency')
period_days: Number of days to look back
Returns:
Dict with anonymized ranking data
"""
logger.info("Getting children performance",
parent_tenant_id=parent_tenant_id,
metric=metric,
period_days=period_days)
child_tenants = await self.tenant_client.get_child_tenants(parent_tenant_id)
# Gather performance data for each child
performance_data = []
for child in (child_tenants or []):
child_id = child['id']
child_name = child['name']
metric_value = 0
try:
if metric == 'sales':
start_date = date.today() - timedelta(days=period_days)
end_date = date.today()
sales_summary = await self.sales_client.get_sales_summary(
tenant_id=child_id,
start_date=start_date,
end_date=end_date
)
metric_value = float(sales_summary.get('total_revenue', 0))
elif metric == 'inventory_value':
inventory_summary = await self.inventory_client.get_inventory_summary(
tenant_id=child_id
)
metric_value = float(inventory_summary.get('total_value', 0))
elif metric == 'order_frequency':
# Count orders placed in the period
orders = await self.sales_client.get_sales_orders(
tenant_id=child_id,
start_date=start_date,
end_date=end_date
)
metric_value = len(orders) if orders else 0
except Exception as e:
logger.warning(f"Could not get performance data for child {child_id}: {e}")
continue
performance_data.append({
'tenant_id': child_id,
'original_name': child_name,
'metric_value': metric_value
})
# Sort by metric value and anonymize
performance_data.sort(key=lambda x: x['metric_value'], reverse=True)
# Anonymize data (no tenant names, just ranks)
anonymized_data = []
for rank, data in enumerate(performance_data, 1):
anonymized_data.append({
'rank': rank,
'tenant_id': data['tenant_id'],
'anonymized_name': f"Outlet {rank}",
'metric_value': data['metric_value']
})
return {
'parent_tenant_id': parent_tenant_id,
'metric': metric,
'period_days': period_days,
'rankings': anonymized_data,
'total_children': len(performance_data),
'last_updated': datetime.utcnow().isoformat()
}
async def get_distribution_overview(
self,
parent_tenant_id: str,
target_date: date = None
) -> Dict[str, Any]:
"""
Get distribution overview for enterprise dashboard
Args:
parent_tenant_id: Parent tenant ID
target_date: Date to get distribution data for (default: today)
Returns:
Dict with distribution metrics and route information
"""
if target_date is None:
target_date = date.today()
logger.info("Getting distribution overview",
parent_tenant_id=parent_tenant_id,
date=target_date)
try:
# Get all routes for the target date
routes = await self.distribution_client.get_delivery_routes(
parent_tenant_id=parent_tenant_id,
date_from=target_date,
date_to=target_date
)
# Get all shipments for the target date
shipments = await self.distribution_client.get_shipments_for_date(
parent_tenant_id,
target_date
)
# Aggregate by status
status_counts = {}
for shipment in shipments:
status = shipment.get('status', 'unknown')
status_counts[status] = status_counts.get(status, 0) + 1
# Prepare route sequences for map visualization
route_sequences = []
for route in routes:
route_sequences.append({
'route_id': route.get('id'),
'route_number': route.get('route_number'),
'status': route.get('status', 'unknown'),
'total_distance_km': route.get('total_distance_km', 0),
'stops': route.get('route_sequence', []),
'estimated_duration_minutes': route.get('estimated_duration_minutes', 0)
})
return {
'parent_tenant_id': parent_tenant_id,
'target_date': target_date.isoformat(),
'route_count': len(routes),
'shipment_count': len(shipments),
'status_counts': status_counts,
'route_sequences': route_sequences,
'last_updated': datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error getting distribution overview: {e}", exc_info=True)
return {
'parent_tenant_id': parent_tenant_id,
'target_date': target_date.isoformat(),
'route_count': 0,
'shipment_count': 0,
'status_counts': {},
'route_sequences': [],
'last_updated': datetime.utcnow().isoformat(),
'error': str(e)
}
async def get_enterprise_forecast_summary(
self,
parent_tenant_id: str,
days_ahead: int = 7
) -> Dict[str, Any]:
"""
Get aggregated forecast summary for the enterprise network
Args:
parent_tenant_id: Parent tenant ID
days_ahead: Number of days ahead to forecast
Returns:
Dict with aggregated forecast data
"""
try:
end_date = date.today() + timedelta(days=days_ahead)
start_date = date.today()
# Get aggregated forecast from the forecasting service
forecast_data = await self.forecast_client.get_aggregated_forecast(
parent_tenant_id=parent_tenant_id,
start_date=start_date,
end_date=end_date
)
# Aggregate the forecast data for the summary
total_demand = 0
daily_summary = {}
if not forecast_data:
logger.warning("No forecast data returned", parent_tenant_id=parent_tenant_id)
return {
'parent_tenant_id': parent_tenant_id,
'days_forecast': days_ahead,
'total_predicted_demand': 0,
'daily_summary': {},
'last_updated': datetime.utcnow().isoformat()
}
for forecast_date_str, products in forecast_data.get('aggregated_forecasts', {}).items():
day_total = sum(item.get('predicted_demand', 0) for item in products.values())
total_demand += day_total
daily_summary[forecast_date_str] = {
'predicted_demand': day_total,
'product_count': len(products)
}
return {
'parent_tenant_id': parent_tenant_id,
'days_forecast': days_ahead,
'total_predicted_demand': total_demand,
'daily_summary': daily_summary,
'last_updated': datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error getting enterprise forecast summary: {e}", exc_info=True)
return {
'parent_tenant_id': parent_tenant_id,
'days_forecast': days_ahead,
'total_predicted_demand': 0,
'daily_summary': {},
'last_updated': datetime.utcnow().isoformat(),
'error': str(e)
}
async def get_network_performance_metrics(
self,
parent_tenant_id: str,
start_date: date,
end_date: date
) -> Dict[str, Any]:
"""
Get aggregated performance metrics across the enterprise network
Args:
parent_tenant_id: Parent tenant ID
start_date: Start date for metrics
end_date: End date for metrics
Returns:
Dict with aggregated network metrics
"""
try:
# Get all child tenants
child_tenants = await self.tenant_client.get_child_tenants(parent_tenant_id)
child_tenant_ids = [child['id'] for child in (child_tenants or [])]
# Include parent in tenant list for complete network metrics
all_tenant_ids = [parent_tenant_id] + child_tenant_ids
# Parallel fetch of metrics for all tenants
tasks = []
for tenant_id in all_tenant_ids:
# Create individual tasks for each metric
sales_task = self._get_tenant_sales(tenant_id, start_date, end_date)
production_task = self._get_tenant_production(tenant_id, start_date, end_date)
inventory_task = self._get_tenant_inventory(tenant_id)
# Gather all tasks for this tenant
tenant_tasks = asyncio.gather(sales_task, production_task, inventory_task, return_exceptions=True)
tasks.append(tenant_tasks)
results = await asyncio.gather(*tasks, return_exceptions=True)
# Aggregate metrics
total_network_sales = Decimal("0.00")
total_network_production = Decimal("0.00")
total_network_inventory_value = Decimal("0.00")
metrics_error_count = 0
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.error(f"Error getting metrics for tenant {all_tenant_ids[i]}: {result}")
metrics_error_count += 1
continue
if isinstance(result, list) and len(result) == 3:
sales, production, inventory = result
total_network_sales += Decimal(str(sales or 0))
total_network_production += Decimal(str(production or 0))
total_network_inventory_value += Decimal(str(inventory or 0))
return {
'parent_tenant_id': parent_tenant_id,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'total_network_sales': float(total_network_sales),
'total_network_production': float(total_network_production),
'total_network_inventory_value': float(total_network_inventory_value),
'included_tenant_count': len(all_tenant_ids),
'child_tenant_count': len(child_tenant_ids),
'metrics_error_count': metrics_error_count,
'coverage_percentage': (
(len(all_tenant_ids) - metrics_error_count) / len(all_tenant_ids) * 100
if all_tenant_ids else 0
)
}
except Exception as e:
logger.error(f"Error getting network performance metrics: {e}", exc_info=True)
raise
async def _get_tenant_sales(self, tenant_id: str, start_date: date, end_date: date) -> float:
"""Helper to get sales for a specific tenant"""
try:
sales_data = await self.sales_client.get_sales_summary(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
return float(sales_data.get('total_revenue', 0))
except Exception as e:
logger.warning(f"Could not get sales for tenant {tenant_id}: {e}")
return 0
async def _get_tenant_production(self, tenant_id: str, start_date: date, end_date: date) -> float:
"""Helper to get production for a specific tenant"""
try:
production_data = await self.production_client.get_dashboard_summary(
tenant_id=tenant_id
)
return float(production_data.get('total_value', 0))
except Exception as e:
logger.warning(f"Could not get production for tenant {tenant_id}: {e}")
return 0
async def _get_tenant_inventory(self, tenant_id: str) -> float:
"""Helper to get inventory value for a specific tenant"""
try:
inventory_data = await self.inventory_client.get_inventory_summary(tenant_id=tenant_id)
return float(inventory_data.get('total_value', 0))
except Exception as e:
logger.warning(f"Could not get inventory for tenant {tenant_id}: {e}")
return 0
async def initialize_enterprise_demo(
self,
parent_tenant_id: str,
child_tenant_ids: List[str],
session_id: str
) -> Dict[str, Any]:
"""
Initialize enterprise demo data including parent-child relationships and distribution setup
Args:
parent_tenant_id: Parent tenant ID
child_tenant_ids: List of child tenant IDs
session_id: Demo session ID
Returns:
Dict with initialization results
"""
logger.info("Initializing enterprise demo",
parent_tenant_id=parent_tenant_id,
child_tenant_ids=child_tenant_ids)
try:
# Step 1: Set up parent-child tenant relationships
await self._setup_parent_child_relationships(
parent_tenant_id=parent_tenant_id,
child_tenant_ids=child_tenant_ids
)
# Step 2: Initialize distribution for the parent
await self._setup_distribution_for_enterprise(
parent_tenant_id=parent_tenant_id,
child_tenant_ids=child_tenant_ids
)
# Step 3: Generate initial internal transfer orders
await self._generate_initial_internal_transfers(
parent_tenant_id=parent_tenant_id,
child_tenant_ids=child_tenant_ids
)
logger.info("Enterprise demo initialized successfully",
parent_tenant_id=parent_tenant_id)
return {
'status': 'success',
'parent_tenant_id': parent_tenant_id,
'child_tenant_count': len(child_tenant_ids),
'session_id': session_id,
'initialized_at': datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error initializing enterprise demo: {e}", exc_info=True)
raise
async def _setup_parent_child_relationships(
self,
parent_tenant_id: str,
child_tenant_ids: List[str]
):
"""Set up parent-child tenant relationships"""
try:
for child_id in child_tenant_ids:
# Update child tenant to have parent reference
await self.tenant_client.update_tenant(
tenant_id=child_id,
updates={
'parent_tenant_id': parent_tenant_id,
'tenant_type': 'child',
'hierarchy_path': f"{parent_tenant_id}.{child_id}"
}
)
# Update parent tenant
await self.tenant_client.update_tenant(
tenant_id=parent_tenant_id,
updates={
'tenant_type': 'parent',
'hierarchy_path': parent_tenant_id # Root path
}
)
logger.info("Parent-child relationships established",
parent_tenant_id=parent_tenant_id,
child_count=len(child_tenant_ids))
except Exception as e:
logger.error(f"Error setting up parent-child relationships: {e}", exc_info=True)
raise
async def _setup_distribution_for_enterprise(
self,
parent_tenant_id: str,
child_tenant_ids: List[str]
):
"""Set up distribution routes and schedules for the enterprise network"""
try:
# In a real implementation, this would call the distribution service
# to set up default delivery routes and schedules between parent and children
logger.info("Setting up distribution for enterprise network",
parent_tenant_id=parent_tenant_id,
child_count=len(child_tenant_ids))
except Exception as e:
logger.error(f"Error setting up distribution: {e}", exc_info=True)
raise
async def _generate_initial_internal_transfers(
self,
parent_tenant_id: str,
child_tenant_ids: List[str]
):
"""Generate initial internal transfer orders for demo"""
try:
for child_id in child_tenant_ids:
# Generate initial internal purchase orders from parent to child
# This would typically be done through the procurement service
logger.info("Generated initial internal transfer order",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_id)
except Exception as e:
logger.error(f"Error generating initial internal transfers: {e}", exc_info=True)
raise