Improve te panel de control logic

This commit is contained in:
Urtzi Alfaro
2025-11-21 16:15:09 +01:00
parent 2ee94fb4b1
commit 3242c8d837
21 changed files with 2805 additions and 696 deletions

View File

@@ -456,6 +456,9 @@ class ProductionServiceClient(BaseServiceClient):
"""
Get today's production batches for dashboard timeline
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.
Args:
tenant_id: Tenant ID
@@ -463,13 +466,67 @@ class ProductionServiceClient(BaseServiceClient):
Dict with ProductionBatchListResponse: {"batches": [...], "total_count": n, "page": 1, "page_size": n}
"""
try:
from datetime import date
today = date.today()
return await self.get(
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(
"/production/batches",
tenant_id=tenant_id,
params={"start_date": today.isoformat(), "end_date": today.isoformat(), "page_size": 100}
params={"page_size": 100}
)
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
except Exception as e:
logger.error("Error fetching today's batches", error=str(e), tenant_id=tenant_id)
return None

View File

@@ -125,33 +125,118 @@ class ProductionBatchReasoningData(BaseModel):
def create_po_reasoning_low_stock(
supplier_name: str,
product_names: list,
current_stock: float,
required_stock: float,
days_until_stockout: int,
product_names: list, # Kept for backward compatibility
current_stock: float = None, # Kept for backward compatibility
required_stock: float = None, # Kept for backward compatibility
days_until_stockout: int = None, # Kept for backward compatibility
threshold_percentage: int = 20,
affected_products: Optional[list] = None,
estimated_lost_orders: Optional[int] = None
estimated_lost_orders: Optional[int] = None,
# New enhanced parameters
product_details: Optional[list] = None,
supplier_lead_time_days: Optional[int] = None,
order_urgency: Optional[str] = None,
affected_production_batches: Optional[list] = None,
estimated_production_loss_eur: Optional[float] = None
) -> Dict[str, Any]:
"""
Create reasoning data for low stock detection
Create reasoning data for low stock detection with supply chain intelligence
Supports two modes:
1. Legacy mode: Uses product_names, current_stock, days_until_stockout (simple)
2. Enhanced mode: Uses product_details with per-product depletion analysis
Args:
supplier_name: Name of the supplier
product_names: List of product names in the order
current_stock: Current stock level
required_stock: Required stock level
days_until_stockout: Days until stock runs out
product_names: List of product names (legacy, for backward compatibility)
current_stock: Current stock level (legacy)
required_stock: Required stock level (legacy)
days_until_stockout: Days until stock runs out (legacy)
threshold_percentage: Stock threshold percentage
affected_products: Products that will be affected
estimated_lost_orders: Estimated number of lost orders
affected_products: Products that will be affected (legacy)
estimated_lost_orders: Estimated number of lost orders (legacy)
product_details: List of dicts with per-product analysis (NEW):
[
{
"product_name": str,
"current_stock_kg": float,
"daily_consumption_kg": float,
"days_until_depletion": float,
"reorder_point_kg": float,
"safety_stock_days": int,
"criticality": str # "critical", "urgent", "important", "normal"
},
...
]
supplier_lead_time_days: Supplier delivery lead time in days (NEW)
order_urgency: Overall order urgency: "critical", "urgent", "important", "normal" (NEW)
affected_production_batches: List of batch numbers that will be impacted (NEW)
estimated_production_loss_eur: Estimated financial loss if not ordered (NEW)
Returns:
Reasoning data dictionary
Reasoning data dictionary with enhanced supply chain intelligence
"""
return {
"type": PurchaseOrderReasoningType.LOW_STOCK_DETECTION.value,
"parameters": {
# Determine mode based on parameters
enhanced_mode = product_details is not None
if enhanced_mode:
# Enhanced mode: Use detailed per-product analysis
# Extract critical products for summary
critical_products = [
p for p in product_details
if p.get("criticality") in ["critical", "urgent"]
]
# Find most critical depletion time
min_depletion_days = min(
[p.get("days_until_depletion", 999) for p in product_details],
default=7
)
min_depletion_hours = round(min_depletion_days * 24, 1)
# Calculate safety margin with supplier lead time
safety_margin_days = None
if supplier_lead_time_days is not None:
safety_margin_days = min_depletion_days - supplier_lead_time_days
# All product names for backward compatibility
all_product_names = [p.get("product_name", "Product") for p in product_details]
parameters = {
"supplier_name": supplier_name,
"supplier_lead_time_days": supplier_lead_time_days or 2,
"product_details": product_details,
"product_names": all_product_names, # For i18n join operations
"product_count": len(product_details),
"critical_products": [p.get("product_name") for p in critical_products],
"critical_product_count": len(critical_products),
"min_depletion_days": round(min_depletion_days, 2),
"min_depletion_hours": min_depletion_hours,
"safety_margin_days": round(safety_margin_days, 2) if safety_margin_days is not None else None,
"order_urgency": order_urgency or "normal",
"affected_batches": affected_production_batches or [],
"affected_batches_count": len(affected_production_batches) if affected_production_batches else 0,
"potential_loss_eur": round(estimated_production_loss_eur, 2) if estimated_production_loss_eur else 0,
"threshold_percentage": threshold_percentage
}
# Enhanced consequence calculation
severity = ConsequenceSeverity.CRITICAL.value if order_urgency == "critical" else \
ConsequenceSeverity.HIGH.value if order_urgency in ["urgent", "important"] else \
ConsequenceSeverity.MEDIUM.value
consequence = {
"type": "stockout_risk_detailed",
"severity": severity,
"impact_days": round(min_depletion_days, 2),
"affected_production_batches": affected_production_batches or [],
"estimated_production_loss_eur": round(estimated_production_loss_eur, 2) if estimated_production_loss_eur else 0,
"critical_items": [p.get("product_name") for p in critical_products]
}
else:
# Legacy mode: Use simple parameters (backward compatibility)
parameters = {
"supplier_name": supplier_name,
"product_names": product_names,
"product_count": len(product_names),
@@ -159,18 +244,25 @@ def create_po_reasoning_low_stock(
"required_stock": required_stock,
"days_until_stockout": days_until_stockout,
"threshold_percentage": threshold_percentage,
"stock_percentage": round((current_stock / required_stock * 100), 1) if required_stock > 0 else 0
},
"consequence": {
"stock_percentage": round((current_stock / required_stock * 100), 1) if (required_stock and required_stock > 0) else 0
}
consequence = {
"type": "stockout_risk",
"severity": ConsequenceSeverity.HIGH.value if days_until_stockout <= 2 else ConsequenceSeverity.MEDIUM.value,
"impact_days": days_until_stockout,
"severity": ConsequenceSeverity.HIGH.value if days_until_stockout and days_until_stockout <= 2 else ConsequenceSeverity.MEDIUM.value,
"impact_days": days_until_stockout or 7,
"affected_products": affected_products or [],
"estimated_lost_orders": estimated_lost_orders or 0
},
}
return {
"type": PurchaseOrderReasoningType.LOW_STOCK_DETECTION.value,
"parameters": parameters,
"consequence": consequence,
"metadata": {
"trigger_source": "orchestrator_auto",
"ai_assisted": True
"ai_assisted": True,
"enhanced_mode": enhanced_mode
}
}