New enterprise feature
This commit is contained in:
@@ -53,6 +53,16 @@ The **Orchestrator Service** automates daily operational workflows by coordinati
|
||||
- **Split-Brain Prevention** - Ensure only one leader
|
||||
- **Leader Health** - Continuous health monitoring
|
||||
|
||||
### 🆕 Enterprise Tier: Network Dashboard & Orchestration (NEW)
|
||||
- **Aggregated Network Metrics** - Single dashboard view consolidating all child outlet data
|
||||
- **Production Coordination** - Central production facility gets visibility into network-wide demand
|
||||
- **Distribution Integration** - Dashboard displays active delivery routes and shipment status
|
||||
- **Network Demand Forecasting** - Aggregated demand forecasts across all retail outlets
|
||||
- **Multi-Location Performance** - Compare performance metrics across all locations
|
||||
- **Child Outlet Visibility** - Drill down into individual outlet performance
|
||||
- **Enterprise KPIs** - Network-level metrics: total production, total sales, network-wide waste reduction
|
||||
- **Subscription Gating** - Enterprise dashboard requires Enterprise tier subscription
|
||||
|
||||
## Business Value
|
||||
|
||||
### For Bakery Owners
|
||||
@@ -119,6 +129,13 @@ The **Orchestrator Service** automates daily operational workflows by coordinati
|
||||
- `GET /api/v1/orchestrator/metrics` - Workflow metrics
|
||||
- `GET /api/v1/orchestrator/statistics` - Execution statistics
|
||||
|
||||
### 🆕 Enterprise Network Dashboard (NEW)
|
||||
- `GET /api/v1/{parent_tenant}/orchestrator/enterprise/dashboard` - Get aggregated enterprise network dashboard
|
||||
- `GET /api/v1/{parent_tenant}/orchestrator/enterprise/network-summary` - Get network-wide summary metrics
|
||||
- `GET /api/v1/{parent_tenant}/orchestrator/enterprise/production-overview` - Get production coordination overview
|
||||
- `GET /api/v1/{parent_tenant}/orchestrator/enterprise/distribution-status` - Get current distribution/delivery status
|
||||
- `GET /api/v1/{parent_tenant}/orchestrator/enterprise/child-performance` - Compare performance across child outlets
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Main Tables
|
||||
@@ -693,6 +710,10 @@ python main.py
|
||||
|
||||
### Dependencies
|
||||
- **All Services** - Calls service APIs to execute workflows
|
||||
- **🆕 Tenant Service** (NEW) - Fetch tenant hierarchy for enterprise dashboards
|
||||
- **🆕 Forecasting Service** (NEW) - Fetch network-aggregated demand forecasts
|
||||
- **🆕 Distribution Service** (NEW) - Fetch active delivery routes and shipment status
|
||||
- **🆕 Production Service** (NEW) - Fetch production metrics across network
|
||||
- **Redis** - Leader election and caching
|
||||
- **PostgreSQL** - Workflow history
|
||||
- **RabbitMQ** - Event publishing
|
||||
@@ -700,6 +721,7 @@ python main.py
|
||||
### Dependents
|
||||
- **All Services** - Benefit from automated workflows
|
||||
- **Monitoring** - Tracks workflow execution
|
||||
- **🆕 Frontend Enterprise Dashboard** (NEW) - Displays aggregated network metrics for parent tenants
|
||||
|
||||
## Business Value for VUE Madrid
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import structlog
|
||||
import asyncio
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -27,7 +27,7 @@ from shared.clients import (
|
||||
)
|
||||
from shared.clients.procurement_client import ProcurementServiceClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Initialize service clients
|
||||
inventory_client = get_inventory_client(settings, "orchestrator")
|
||||
@@ -598,10 +598,39 @@ async def get_execution_progress(
|
||||
|
||||
async def fetch_pending_approvals():
|
||||
try:
|
||||
po_data = await procurement_client.get_pending_purchase_orders(tenant_id, limit=100) or []
|
||||
return len(po_data) if isinstance(po_data, list) else 0
|
||||
po_data = await procurement_client.get_pending_purchase_orders(tenant_id, limit=100)
|
||||
|
||||
if po_data is None:
|
||||
logger.error(
|
||||
"Procurement client returned None for pending POs",
|
||||
tenant_id=tenant_id,
|
||||
context="likely HTTP 404 error - check URL construction"
|
||||
)
|
||||
return 0
|
||||
|
||||
if not isinstance(po_data, list):
|
||||
logger.error(
|
||||
"Unexpected response format from procurement client",
|
||||
tenant_id=tenant_id,
|
||||
response_type=type(po_data).__name__,
|
||||
response_value=str(po_data)[:200]
|
||||
)
|
||||
return 0
|
||||
|
||||
logger.info(
|
||||
"Successfully fetched pending purchase orders",
|
||||
tenant_id=tenant_id,
|
||||
count=len(po_data)
|
||||
)
|
||||
return len(po_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch pending approvals: {e}")
|
||||
logger.error(
|
||||
"Exception while fetching pending approvals",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return 0
|
||||
|
||||
# Execute in parallel
|
||||
|
||||
201
services/orchestrator/app/api/enterprise_dashboard.py
Normal file
201
services/orchestrator/app/api/enterprise_dashboard.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Enterprise Dashboard API Endpoints for Orchestrator Service
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import date
|
||||
import structlog
|
||||
|
||||
from app.services.enterprise_dashboard_service import EnterpriseDashboardService
|
||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
||||
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
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/enterprise", tags=["enterprise"])
|
||||
|
||||
|
||||
# Add dependency injection function
|
||||
from app.services.enterprise_dashboard_service import EnterpriseDashboardService
|
||||
from shared.clients import (
|
||||
get_tenant_client,
|
||||
get_forecast_client,
|
||||
get_production_client,
|
||||
get_sales_client,
|
||||
get_inventory_client,
|
||||
get_procurement_client
|
||||
)
|
||||
# TODO: Add distribution client when available
|
||||
# from shared.clients import get_distribution_client
|
||||
|
||||
def get_enterprise_dashboard_service() -> EnterpriseDashboardService:
|
||||
from app.core.config import settings
|
||||
tenant_client = get_tenant_client(settings)
|
||||
forecast_client = get_forecast_client(settings)
|
||||
production_client = get_production_client(settings)
|
||||
sales_client = get_sales_client(settings)
|
||||
inventory_client = get_inventory_client(settings)
|
||||
distribution_client = None # TODO: Add when distribution service is ready
|
||||
procurement_client = get_procurement_client(settings)
|
||||
|
||||
return EnterpriseDashboardService(
|
||||
tenant_client=tenant_client,
|
||||
forecast_client=forecast_client,
|
||||
production_client=production_client,
|
||||
sales_client=sales_client,
|
||||
inventory_client=inventory_client,
|
||||
distribution_client=distribution_client,
|
||||
procurement_client=procurement_client
|
||||
)
|
||||
|
||||
@router.get("/network-summary")
|
||||
async def get_network_summary(
|
||||
tenant_id: str,
|
||||
enterprise_service: EnterpriseDashboardService = Depends(get_enterprise_dashboard_service),
|
||||
verified_tenant: str = Depends(verify_tenant_access_dep)
|
||||
):
|
||||
"""
|
||||
Get network summary metrics for enterprise dashboard
|
||||
"""
|
||||
try:
|
||||
# Verify user has network access
|
||||
tenant_info = await enterprise_service.tenant_client.get_tenant(tenant_id)
|
||||
if not tenant_info:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=403, detail="Only parent tenants can access enterprise dashboard")
|
||||
|
||||
result = await enterprise_service.get_network_summary(parent_tenant_id=tenant_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting network summary: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to get network summary")
|
||||
|
||||
|
||||
@router.get("/children-performance")
|
||||
async def get_children_performance(
|
||||
tenant_id: str,
|
||||
metric: str = "sales",
|
||||
period_days: int = 30,
|
||||
enterprise_service: EnterpriseDashboardService = Depends(get_enterprise_dashboard_service),
|
||||
verified_tenant: str = Depends(verify_tenant_access_dep)
|
||||
):
|
||||
"""
|
||||
Get anonymized performance ranking of child tenants
|
||||
"""
|
||||
try:
|
||||
# Verify user has network access
|
||||
tenant_info = await enterprise_service.tenant_client.get_tenant(tenant_id)
|
||||
if not tenant_info:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=403, detail="Only parent tenants can access enterprise dashboard")
|
||||
|
||||
result = await enterprise_service.get_children_performance(
|
||||
parent_tenant_id=tenant_id,
|
||||
metric=metric,
|
||||
period_days=period_days
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting children performance: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to get children performance")
|
||||
|
||||
|
||||
@router.get("/distribution-overview")
|
||||
async def get_distribution_overview(
|
||||
tenant_id: str,
|
||||
target_date: Optional[date] = None,
|
||||
enterprise_service: EnterpriseDashboardService = Depends(get_enterprise_dashboard_service),
|
||||
verified_tenant: str = Depends(verify_tenant_access_dep)
|
||||
):
|
||||
"""
|
||||
Get distribution overview for enterprise dashboard
|
||||
"""
|
||||
try:
|
||||
# Verify user has network access
|
||||
tenant_info = await enterprise_service.tenant_client.get_tenant(tenant_id)
|
||||
if not tenant_info:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=403, detail="Only parent tenants can access enterprise dashboard")
|
||||
|
||||
if target_date is None:
|
||||
target_date = date.today()
|
||||
|
||||
result = await enterprise_service.get_distribution_overview(
|
||||
parent_tenant_id=tenant_id,
|
||||
target_date=target_date
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting distribution overview: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to get distribution overview")
|
||||
|
||||
|
||||
@router.get("/forecast-summary")
|
||||
async def get_enterprise_forecast_summary(
|
||||
tenant_id: str,
|
||||
days_ahead: int = 7,
|
||||
enterprise_service: EnterpriseDashboardService = Depends(get_enterprise_dashboard_service),
|
||||
verified_tenant: str = Depends(verify_tenant_access_dep)
|
||||
):
|
||||
"""
|
||||
Get aggregated forecast summary for the enterprise network
|
||||
"""
|
||||
try:
|
||||
# Verify user has network access
|
||||
tenant_info = await enterprise_service.tenant_client.get_tenant(tenant_id)
|
||||
if not tenant_info:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=403, detail="Only parent tenants can access enterprise dashboard")
|
||||
|
||||
result = await enterprise_service.get_enterprise_forecast_summary(
|
||||
parent_tenant_id=tenant_id,
|
||||
days_ahead=days_ahead
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting enterprise forecast summary: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to get enterprise forecast summary")
|
||||
|
||||
|
||||
@router.get("/network-performance")
|
||||
async def get_network_performance_metrics(
|
||||
tenant_id: str,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
enterprise_service: EnterpriseDashboardService = Depends(get_enterprise_dashboard_service),
|
||||
verified_tenant: str = Depends(verify_tenant_access_dep)
|
||||
):
|
||||
"""
|
||||
Get aggregated performance metrics across the tenant network
|
||||
"""
|
||||
try:
|
||||
# Verify user has network access
|
||||
tenant_info = await enterprise_service.tenant_client.get_tenant(tenant_id)
|
||||
if not tenant_info:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
if tenant_info.get('tenant_type') != 'parent':
|
||||
raise HTTPException(status_code=403, detail="Only parent tenants can access enterprise dashboard")
|
||||
|
||||
if not start_date:
|
||||
start_date = date.today()
|
||||
if not end_date:
|
||||
end_date = date.today()
|
||||
|
||||
result = await enterprise_service.get_network_performance_metrics(
|
||||
parent_tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting network performance metrics: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to get network performance metrics")
|
||||
@@ -23,12 +23,11 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Internal API key for service-to-service communication
|
||||
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
|
||||
|
||||
|
||||
async def ensure_unique_run_number(db: AsyncSession, base_run_number: str) -> str:
|
||||
"""Ensure the run number is unique by appending a suffix if needed"""
|
||||
@@ -53,7 +52,7 @@ async def ensure_unique_run_number(db: AsyncSession, base_run_number: str) -> st
|
||||
|
||||
def verify_internal_api_key(x_internal_api_key: str = Header(...)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
if x_internal_api_key != INTERNAL_API_KEY:
|
||||
if x_internal_api_key != settings.INTERNAL_API_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@@ -95,11 +95,43 @@ service.setup_standard_endpoints()
|
||||
# BUSINESS: Orchestration operations
|
||||
from app.api.orchestration import router as orchestration_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.enterprise_dashboard import router as enterprise_dashboard_router
|
||||
from app.api.internal import router as internal_router
|
||||
service.add_router(orchestration_router)
|
||||
service.add_router(dashboard_router)
|
||||
service.add_router(enterprise_dashboard_router)
|
||||
service.add_router(internal_router)
|
||||
|
||||
# Add enterprise dashboard service to dependencies
|
||||
from app.services.enterprise_dashboard_service import EnterpriseDashboardService
|
||||
from shared.clients import (
|
||||
get_tenant_client,
|
||||
get_forecast_client,
|
||||
get_production_client,
|
||||
get_sales_client,
|
||||
get_inventory_client,
|
||||
get_procurement_client
|
||||
)
|
||||
|
||||
def get_enterprise_dashboard_service() -> EnterpriseDashboardService:
|
||||
tenant_client = get_tenant_client(settings)
|
||||
forecast_client = get_forecast_client(settings)
|
||||
production_client = get_production_client(settings)
|
||||
sales_client = get_sales_client(settings)
|
||||
inventory_client = get_inventory_client(settings)
|
||||
distribution_client = None # TODO: Add when distribution service is ready
|
||||
procurement_client = get_procurement_client(settings)
|
||||
|
||||
return EnterpriseDashboardService(
|
||||
tenant_client=tenant_client,
|
||||
forecast_client=forecast_client,
|
||||
production_client=production_client,
|
||||
sales_client=sales_client,
|
||||
inventory_client=inventory_client,
|
||||
distribution_client=distribution_client,
|
||||
procurement_client=procurement_client
|
||||
)
|
||||
|
||||
# INTERNAL: Service-to-service endpoints
|
||||
from app.api import internal_demo
|
||||
service.add_router(internal_demo.router)
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
"""
|
||||
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:
|
||||
start_date = date.today() - timedelta(days=30)
|
||||
end_date = date.today()
|
||||
|
||||
production_summary = await self.production_client.get_production_summary(
|
||||
tenant_id=parent_tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
# 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 = {}
|
||||
|
||||
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_production_summary(
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
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
|
||||
0
services/orchestrator/main.py
Normal file
0
services/orchestrator/main.py
Normal file
@@ -56,8 +56,8 @@ structlog.configure(
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Fixed Demo Tenant IDs (must match tenant service)
|
||||
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
|
||||
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
|
||||
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
|
||||
|
||||
# BASE_REFERENCE_DATE now imported from shared utilities to ensure consistency
|
||||
# between seeding and cloning operations
|
||||
@@ -154,7 +154,7 @@ def weighted_choice(choices: list) -> dict:
|
||||
|
||||
def generate_run_number(tenant_id: uuid.UUID, index: int, run_type: str) -> str:
|
||||
"""Generate a unique run number"""
|
||||
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_SAN_PABLO else "LE"
|
||||
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_PROFESSIONAL else "LE"
|
||||
type_code = run_type[0:3].upper()
|
||||
current_year = datetime.now(timezone.utc).year
|
||||
return f"ORCH-{tenant_prefix}-{type_code}-{current_year}-{index:03d}"
|
||||
@@ -593,25 +593,25 @@ async def seed_all(db: AsyncSession):
|
||||
|
||||
results = []
|
||||
|
||||
# Seed San Pablo (Individual Bakery)
|
||||
result_san_pablo = await generate_orchestration_for_tenant(
|
||||
# Seed Professional Bakery (single location)
|
||||
result_professional = await generate_orchestration_for_tenant(
|
||||
db,
|
||||
DEMO_TENANT_SAN_PABLO,
|
||||
"Panadería San Pablo (Individual Bakery)",
|
||||
DEMO_TENANT_PROFESSIONAL,
|
||||
"Panadería Artesana Madrid (Professional)",
|
||||
"individual_bakery",
|
||||
config
|
||||
)
|
||||
results.append(result_san_pablo)
|
||||
results.append(result_professional)
|
||||
|
||||
# Seed La Espiga (Central Bakery)
|
||||
result_la_espiga = await generate_orchestration_for_tenant(
|
||||
# Seed Enterprise Parent (central production - Obrador)
|
||||
result_enterprise_parent = await generate_orchestration_for_tenant(
|
||||
db,
|
||||
DEMO_TENANT_LA_ESPIGA,
|
||||
"Panadería La Espiga (Central Bakery)",
|
||||
"central_bakery",
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
"Panadería Central - Obrador Madrid (Enterprise Parent)",
|
||||
"enterprise_chain",
|
||||
config
|
||||
)
|
||||
results.append(result_la_espiga)
|
||||
results.append(result_enterprise_parent)
|
||||
|
||||
total_runs = sum(r["runs_created"] for r in results)
|
||||
total_steps = sum(r["steps_created"] for r in results)
|
||||
|
||||
Reference in New Issue
Block a user