2025-08-21 20:28:14 +02:00
|
|
|
# shared/clients/production_client.py
|
|
|
|
|
"""
|
|
|
|
|
Production Service Client for Inter-Service Communication
|
|
|
|
|
Provides access to production planning and batch management from other services
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import structlog
|
|
|
|
|
from typing import Dict, Any, Optional, List
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
from shared.clients.base_service_client import BaseServiceClient
|
|
|
|
|
from shared.config.base import BaseServiceSettings
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductionServiceClient(BaseServiceClient):
|
|
|
|
|
"""Client for communicating with the Production Service"""
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-11-05 13:34:56 +01:00
|
|
|
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
|
|
|
|
|
super().__init__(calling_service_name, config)
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
def get_service_base_path(self) -> str:
|
|
|
|
|
return "/api/v1"
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# PRODUCTION PLANNING
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-10-30 21:08:07 +01:00
|
|
|
async def generate_schedule(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
forecast_data: Dict[str, Any],
|
|
|
|
|
inventory_data: Optional[Dict[str, Any]] = None,
|
|
|
|
|
recipes_data: Optional[Dict[str, Any]] = None,
|
|
|
|
|
target_date: Optional[str] = None,
|
|
|
|
|
planning_horizon_days: int = 1
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Generate production schedule (called by Orchestrator).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID
|
|
|
|
|
forecast_data: Forecast data from forecasting service
|
|
|
|
|
inventory_data: Optional inventory snapshot (NEW - to avoid duplicate fetching)
|
|
|
|
|
recipes_data: Optional recipes snapshot (NEW - to avoid duplicate fetching)
|
|
|
|
|
target_date: Optional target date
|
|
|
|
|
planning_horizon_days: Number of days to plan
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with schedule_id, batches_created, etc.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
request_data = {
|
|
|
|
|
"forecast_data": forecast_data,
|
|
|
|
|
"target_date": target_date,
|
|
|
|
|
"planning_horizon_days": planning_horizon_days
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# NEW: Include cached data if provided
|
|
|
|
|
if inventory_data:
|
|
|
|
|
request_data["inventory_data"] = inventory_data
|
|
|
|
|
if recipes_data:
|
|
|
|
|
request_data["recipes_data"] = recipes_data
|
|
|
|
|
|
|
|
|
|
result = await self.post(
|
2025-11-05 13:34:56 +01:00
|
|
|
"production/operations/generate-schedule",
|
2025-10-30 21:08:07 +01:00
|
|
|
data=request_data,
|
|
|
|
|
tenant_id=tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info(
|
|
|
|
|
"Generated production schedule",
|
|
|
|
|
schedule_id=result.get('schedule_id'),
|
|
|
|
|
batches_created=result.get('batches_created', 0),
|
|
|
|
|
tenant_id=tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Error generating production schedule",
|
|
|
|
|
error=str(e),
|
|
|
|
|
tenant_id=tenant_id
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_production_requirements(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get production requirements for procurement planning"""
|
|
|
|
|
try:
|
|
|
|
|
params = {}
|
|
|
|
|
if date:
|
|
|
|
|
params["date"] = date
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
result = await self.get("production/requirements", tenant_id=tenant_id, params=params)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production requirements from production service",
|
2025-08-21 20:28:14 +02:00
|
|
|
date=date, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production requirements",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_daily_requirements(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get daily production requirements"""
|
|
|
|
|
try:
|
|
|
|
|
params = {}
|
|
|
|
|
if date:
|
|
|
|
|
params["date"] = date
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
result = await self.get("production/daily-requirements", tenant_id=tenant_id, params=params)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved daily production requirements from production service",
|
2025-08-21 20:28:14 +02:00
|
|
|
date=date, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting daily production requirements",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_production_schedule(self, tenant_id: str, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get production schedule for a date range"""
|
|
|
|
|
try:
|
|
|
|
|
params = {}
|
|
|
|
|
if start_date:
|
|
|
|
|
params["start_date"] = start_date
|
|
|
|
|
if end_date:
|
|
|
|
|
params["end_date"] = end_date
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
result = await self.get("production/schedules", tenant_id=tenant_id, params=params)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production schedule from production service",
|
2025-08-21 20:28:14 +02:00
|
|
|
start_date=start_date, end_date=end_date, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production schedule",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# BATCH MANAGEMENT
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_active_batches(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
|
|
|
|
"""Get currently active production batches"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.get("production/batches/active", tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
batches = result.get('batches', []) if result else []
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved active production batches from production service",
|
2025-08-21 20:28:14 +02:00
|
|
|
batches_count=len(batches), tenant_id=tenant_id)
|
|
|
|
|
return batches
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting active production batches",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return []
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def create_production_batch(self, tenant_id: str, batch_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Create a new production batch"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.post("production/batches", data=batch_data, tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Created production batch",
|
|
|
|
|
batch_id=result.get('id'),
|
2025-08-21 20:28:14 +02:00
|
|
|
product_id=batch_data.get('product_id'),
|
|
|
|
|
tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error creating production batch",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def update_batch_status(self, tenant_id: str, batch_id: str, status: str, actual_quantity: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Update production batch status"""
|
|
|
|
|
try:
|
|
|
|
|
data = {"status": status}
|
|
|
|
|
if actual_quantity is not None:
|
|
|
|
|
data["actual_quantity"] = actual_quantity
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
result = await self.put(f"production/batches/{batch_id}/status", data=data, tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Updated production batch status",
|
2025-08-21 20:28:14 +02:00
|
|
|
batch_id=batch_id, status=status, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error updating production batch status",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), batch_id=batch_id, tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_batch_details(self, tenant_id: str, batch_id: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get detailed information about a production batch"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.get(f"production/batches/{batch_id}", tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production batch details",
|
2025-08-21 20:28:14 +02:00
|
|
|
batch_id=batch_id, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production batch details",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), batch_id=batch_id, tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# CAPACITY MANAGEMENT
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_capacity_status(self, tenant_id: str, date: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get production capacity status for a specific date"""
|
|
|
|
|
try:
|
|
|
|
|
params = {}
|
|
|
|
|
if date:
|
|
|
|
|
params["date"] = date
|
2025-10-06 15:27:01 +02:00
|
|
|
|
|
|
|
|
result = await self.get("production/capacity/status", tenant_id=tenant_id, params=params)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production capacity status",
|
2025-08-21 20:28:14 +02:00
|
|
|
date=date, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production capacity status",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def check_capacity_availability(self, tenant_id: str, requirements: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Check if production capacity is available for requirements"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.post("production/capacity/check-availability",
|
|
|
|
|
{"requirements": requirements},
|
2025-08-21 20:28:14 +02:00
|
|
|
tenant_id=tenant_id)
|
|
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Checked production capacity availability",
|
2025-08-21 20:28:14 +02:00
|
|
|
requirements_count=len(requirements), tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error checking production capacity availability",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# QUALITY CONTROL
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def record_quality_check(self, tenant_id: str, batch_id: str, quality_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Record quality control results for a batch"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.post(f"production/batches/{batch_id}/quality-check",
|
|
|
|
|
data=quality_data,
|
2025-08-21 20:28:14 +02:00
|
|
|
tenant_id=tenant_id)
|
|
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Recorded quality check for production batch",
|
2025-08-21 20:28:14 +02:00
|
|
|
batch_id=batch_id, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error recording quality check",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), batch_id=batch_id, tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_yield_metrics(self, tenant_id: str, start_date: str, end_date: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get production yield metrics for analysis"""
|
|
|
|
|
try:
|
|
|
|
|
params = {
|
|
|
|
|
"start_date": start_date,
|
|
|
|
|
"end_date": end_date
|
|
|
|
|
}
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.get("production/analytics/yield-metrics", tenant_id=tenant_id, params=params)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production yield metrics",
|
2025-08-21 20:28:14 +02:00
|
|
|
start_date=start_date, end_date=end_date, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production yield metrics",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# DASHBOARD AND ANALYTICS
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_dashboard_summary(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get production dashboard summary data"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.get("production/dashboard/summary", tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production dashboard summary",
|
2025-08-21 20:28:14 +02:00
|
|
|
tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production dashboard summary",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_efficiency_metrics(self, tenant_id: str, period: str = "last_30_days") -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Get production efficiency metrics"""
|
|
|
|
|
try:
|
|
|
|
|
params = {"period": period}
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.get("production/analytics/efficiency", tenant_id=tenant_id, params=params)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production efficiency metrics",
|
2025-08-21 20:28:14 +02:00
|
|
|
period=period, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production efficiency metrics",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# ALERTS AND NOTIFICATIONS
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def get_production_alerts(self, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
|
|
|
|
|
"""Get production-related alerts"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.get("production/alerts", tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
alerts = result.get('alerts', []) if result else []
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Retrieved production alerts",
|
2025-08-21 20:28:14 +02:00
|
|
|
alerts_count=len(alerts), tenant_id=tenant_id)
|
|
|
|
|
return alerts
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error getting production alerts",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return []
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def acknowledge_alert(self, tenant_id: str, alert_id: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""Acknowledge a production-related alert"""
|
|
|
|
|
try:
|
2025-10-06 15:27:01 +02:00
|
|
|
result = await self.post(f"production/alerts/{alert_id}/acknowledge", data={}, tenant_id=tenant_id)
|
2025-08-21 20:28:14 +02:00
|
|
|
if result:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.info("Acknowledged production alert",
|
2025-08-21 20:28:14 +02:00
|
|
|
alert_id=alert_id, tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
2025-10-06 15:27:01 +02:00
|
|
|
logger.error("Error acknowledging production alert",
|
2025-08-21 20:28:14 +02:00
|
|
|
error=str(e), alert_id=alert_id, tenant_id=tenant_id)
|
|
|
|
|
return None
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-10-23 07:44:54 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# WASTE AND SUSTAINABILITY ANALYTICS
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
|
|
|
async def get_waste_analytics(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
start_date: str,
|
|
|
|
|
end_date: str
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Get production waste analytics for sustainability reporting
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID
|
|
|
|
|
start_date: Start date (ISO format)
|
|
|
|
|
end_date: End date (ISO format)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with waste analytics data:
|
|
|
|
|
- total_production_waste: Total waste in kg
|
|
|
|
|
- total_defects: Total defect waste in kg
|
|
|
|
|
- total_planned: Total planned production in kg
|
|
|
|
|
- total_actual: Total actual production in kg
|
|
|
|
|
- ai_assisted_batches: Number of AI-assisted batches
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
params = {
|
|
|
|
|
"start_date": start_date,
|
|
|
|
|
"end_date": end_date
|
|
|
|
|
}
|
|
|
|
|
result = await self.get("production/waste-analytics", tenant_id=tenant_id, params=params)
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("Retrieved production waste analytics",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error getting production waste analytics",
|
|
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def get_baseline(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Get baseline waste percentage for SDG compliance calculations
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with baseline data:
|
|
|
|
|
- waste_percentage: Baseline waste percentage
|
|
|
|
|
- period: Information about the baseline period
|
|
|
|
|
- data_available: Whether real data is available
|
|
|
|
|
- total_production_kg: Total production during baseline
|
|
|
|
|
- total_waste_kg: Total waste during baseline
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
result = await self.get("production/baseline", tenant_id=tenant_id)
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("Retrieved production baseline data",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
data_available=result.get('data_available', False))
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error getting production baseline",
|
|
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
|
|
|
|
|
2025-11-05 13:34:56 +01:00
|
|
|
# ================================================================
|
|
|
|
|
# ML INSIGHTS: Yield Prediction
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
|
|
|
async def trigger_yield_prediction(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
recipe_ids: Optional[List[str]] = None,
|
|
|
|
|
lookback_days: int = 90,
|
|
|
|
|
min_history_runs: int = 30
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Trigger yield prediction for production recipes.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant UUID
|
|
|
|
|
recipe_ids: Specific recipe IDs to analyze. If None, analyzes all recipes
|
|
|
|
|
lookback_days: Days of historical production to analyze (30-365)
|
|
|
|
|
min_history_runs: Minimum production runs required (10-100)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with prediction results including insights posted
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
data = {
|
|
|
|
|
"recipe_ids": recipe_ids,
|
|
|
|
|
"lookback_days": lookback_days,
|
|
|
|
|
"min_history_runs": min_history_runs
|
|
|
|
|
}
|
|
|
|
|
result = await self.post("production/ml/insights/predict-yields", data=data, tenant_id=tenant_id)
|
|
|
|
|
if result:
|
|
|
|
|
logger.info("Triggered yield prediction",
|
|
|
|
|
recipes_analyzed=result.get('recipes_analyzed', 0),
|
|
|
|
|
insights_posted=result.get('total_insights_posted', 0),
|
|
|
|
|
tenant_id=tenant_id)
|
|
|
|
|
return result
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error triggering yield prediction",
|
|
|
|
|
error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
|
|
|
|
|
2025-11-07 22:12:21 +00:00
|
|
|
# ================================================================
|
|
|
|
|
# DASHBOARD METHODS
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
2025-12-05 20:07:01 +01:00
|
|
|
async def get_production_summary_batch(
|
|
|
|
|
self,
|
|
|
|
|
tenant_ids: List[str]
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Get production summaries for multiple tenants in a single request.
|
|
|
|
|
|
|
|
|
|
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_ids: List of tenant IDs to fetch
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict mapping tenant_id -> production summary
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
if not tenant_ids:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
if len(tenant_ids) > 100:
|
|
|
|
|
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
|
|
|
|
|
tenant_ids = tenant_ids[:100]
|
|
|
|
|
|
|
|
|
|
result = await self.post(
|
|
|
|
|
"production/batch/production-summary",
|
|
|
|
|
data={"tenant_ids": tenant_ids},
|
|
|
|
|
tenant_id=tenant_ids[0] # Use first tenant for auth context
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
summaries = result if isinstance(result, dict) else {}
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Batch retrieved production summaries",
|
|
|
|
|
requested=len(tenant_ids),
|
|
|
|
|
found=len(summaries)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return summaries
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Error batch fetching production summaries",
|
|
|
|
|
error=str(e),
|
|
|
|
|
tenant_count=len(tenant_ids)
|
|
|
|
|
)
|
|
|
|
|
return {}
|
|
|
|
|
|
2025-11-07 22:12:21 +00:00
|
|
|
async def get_todays_batches(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Get today's production batches for dashboard timeline
|
|
|
|
|
|
2025-11-21 16:15:09 +01:00
|
|
|
For demo compatibility: Queries all recent batches and filters for actionable ones
|
|
|
|
|
scheduled for today, since demo session dates are adjusted relative to session creation time.
|
|
|
|
|
|
2025-11-07 22:12:21 +00:00
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID
|
|
|
|
|
|
|
|
|
|
Returns:
|
2025-11-08 06:56:30 +00:00
|
|
|
Dict with ProductionBatchListResponse: {"batches": [...], "total_count": n, "page": 1, "page_size": n}
|
2025-11-07 22:12:21 +00:00
|
|
|
"""
|
|
|
|
|
try:
|
2025-11-21 16:15:09 +01:00
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
|
|
|
|
|
# Get today's date range (start of day to end of day in UTC)
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
|
today_end = today_start + timedelta(days=1)
|
|
|
|
|
|
|
|
|
|
# Query all batches without date/status filter for demo compatibility
|
|
|
|
|
# The dashboard will filter for PENDING, IN_PROGRESS, or SCHEDULED
|
|
|
|
|
result = await self.get(
|
2025-11-08 06:56:30 +00:00
|
|
|
"/production/batches",
|
|
|
|
|
tenant_id=tenant_id,
|
2025-11-21 16:15:09 +01:00
|
|
|
params={"page_size": 100}
|
2025-11-07 22:12:21 +00:00
|
|
|
)
|
2025-11-21 16:15:09 +01:00
|
|
|
|
|
|
|
|
if result and "batches" in result:
|
|
|
|
|
# Filter for actionable batches scheduled for TODAY
|
|
|
|
|
actionable_statuses = {"PENDING", "IN_PROGRESS", "SCHEDULED"}
|
|
|
|
|
filtered_batches = []
|
|
|
|
|
|
|
|
|
|
for batch in result["batches"]:
|
|
|
|
|
# Check if batch is actionable
|
|
|
|
|
if batch.get("status") not in actionable_statuses:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Check if batch is scheduled for today
|
|
|
|
|
# Include batches that START today OR END today (for overnight batches)
|
|
|
|
|
planned_start = batch.get("planned_start_time")
|
|
|
|
|
planned_end = batch.get("planned_end_time")
|
|
|
|
|
|
|
|
|
|
include_batch = False
|
|
|
|
|
|
|
|
|
|
if planned_start:
|
|
|
|
|
# Parse the start date string
|
|
|
|
|
if isinstance(planned_start, str):
|
|
|
|
|
planned_start = datetime.fromisoformat(planned_start.replace('Z', '+00:00'))
|
|
|
|
|
|
|
|
|
|
# Include if batch starts today
|
|
|
|
|
if today_start <= planned_start < today_end:
|
|
|
|
|
include_batch = True
|
|
|
|
|
|
|
|
|
|
# Also check if batch ends today (for overnight batches)
|
|
|
|
|
if not include_batch and planned_end:
|
|
|
|
|
if isinstance(planned_end, str):
|
|
|
|
|
planned_end = datetime.fromisoformat(planned_end.replace('Z', '+00:00'))
|
|
|
|
|
|
|
|
|
|
# Include if batch ends today (even if it started yesterday)
|
|
|
|
|
if today_start <= planned_end < today_end:
|
|
|
|
|
include_batch = True
|
|
|
|
|
|
|
|
|
|
if include_batch:
|
|
|
|
|
filtered_batches.append(batch)
|
|
|
|
|
|
|
|
|
|
# Return filtered result
|
|
|
|
|
return {
|
|
|
|
|
**result,
|
|
|
|
|
"batches": filtered_batches,
|
|
|
|
|
"total_count": len(filtered_batches)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
2025-11-07 22:12:21 +00:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error fetching today's batches", error=str(e), tenant_id=tenant_id)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def get_production_batches_by_status(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
status: str,
|
|
|
|
|
limit: int = 100
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Get production batches filtered by status for dashboard
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID
|
|
|
|
|
status: Batch status (e.g., "ON_HOLD", "IN_PROGRESS")
|
|
|
|
|
limit: Maximum number of batches to return
|
|
|
|
|
|
|
|
|
|
Returns:
|
2025-11-08 06:56:30 +00:00
|
|
|
Dict with ProductionBatchListResponse: {"batches": [...], "total_count": n, "page": 1, "page_size": n}
|
2025-11-07 22:12:21 +00:00
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
return await self.get(
|
2025-11-08 06:56:30 +00:00
|
|
|
"/production/batches",
|
2025-11-07 22:12:21 +00:00
|
|
|
tenant_id=tenant_id,
|
2025-11-08 06:56:30 +00:00
|
|
|
params={"status": status, "page_size": limit}
|
2025-11-07 22:12:21 +00:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error fetching production batches", error=str(e),
|
|
|
|
|
status=status, tenant_id=tenant_id)
|
|
|
|
|
return None
|
|
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# UTILITY METHODS
|
|
|
|
|
# ================================================================
|
2025-10-06 15:27:01 +02:00
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
async def health_check(self) -> bool:
|
|
|
|
|
"""Check if production service is healthy"""
|
|
|
|
|
try:
|
|
|
|
|
result = await self.get("../health") # Health endpoint is not tenant-scoped
|
|
|
|
|
return result is not None
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Production service health check failed", error=str(e))
|
|
|
|
|
return False
|
|
|
|
|
|
2025-12-13 23:57:54 +01:00
|
|
|
# ================================================================
|
|
|
|
|
# INTERNAL TRIGGER METHODS
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
|
|
|
async def trigger_production_alerts_internal(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Trigger production alerts for a tenant (internal service use only).
|
|
|
|
|
|
|
|
|
|
This method calls the internal endpoint which is protected by X-Internal-Service header.
|
|
|
|
|
Includes both production alerts and equipment maintenance checks.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID to trigger alerts for
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with trigger results or None if failed
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Call internal endpoint via gateway using tenant-scoped URL pattern
|
|
|
|
|
# Endpoint: /api/v1/tenants/{tenant_id}/production/internal/alerts/trigger
|
|
|
|
|
result = await self._make_request(
|
|
|
|
|
method="POST",
|
|
|
|
|
endpoint="production/internal/alerts/trigger",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
data={},
|
|
|
|
|
headers={"X-Internal-Service": "demo-session"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info(
|
|
|
|
|
"Production alerts triggered successfully via internal endpoint",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
alerts_generated=result.get("alerts_generated", 0)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Production alerts internal endpoint returned no result",
|
|
|
|
|
tenant_id=tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Error triggering production alerts via internal endpoint",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# ================================================================
|
|
|
|
|
# INTERNAL AI INSIGHTS METHODS
|
|
|
|
|
# ================================================================
|
|
|
|
|
|
|
|
|
|
async def trigger_yield_insights_internal(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Trigger yield improvement insights for a tenant (internal service use only).
|
|
|
|
|
|
|
|
|
|
This method calls the internal endpoint which is protected by X-Internal-Service header.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant ID to trigger insights for
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with trigger results or None if failed
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
result = await self._make_request(
|
|
|
|
|
method="POST",
|
|
|
|
|
endpoint="production/internal/ml/generate-yield-insights",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
data={"tenant_id": tenant_id},
|
|
|
|
|
headers={"X-Internal-Service": "demo-session"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
logger.info(
|
|
|
|
|
"Yield insights triggered successfully via internal endpoint",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
insights_posted=result.get("insights_posted", 0)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Yield insights internal endpoint returned no result",
|
|
|
|
|
tenant_id=tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Error triggering yield insights via internal endpoint",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
2025-08-21 20:28:14 +02:00
|
|
|
|
|
|
|
|
# Factory function for dependency injection
|
|
|
|
|
def create_production_client(config: BaseServiceSettings) -> ProductionServiceClient:
|
|
|
|
|
"""Create production service client instance"""
|
2025-10-06 15:27:01 +02:00
|
|
|
return ProductionServiceClient(config)
|