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

@@ -21,6 +21,7 @@ import random
from datetime import datetime, timezone, timedelta, date
from pathlib import Path
from decimal import Decimal
from typing import List, Dict, Any
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -60,6 +61,48 @@ BASE_SUPPLIER_IDS = [
uuid.UUID("40000000-0000-0000-0000-000000000005"), # Lesaffre Ibérica (low trust)
]
# Supplier lead times (days) for realistic supply chain modeling
SUPPLIER_LEAD_TIMES = {
"Molinos San José S.L.": 2, # 2-day delivery (trusted, local)
"Lácteos del Valle S.A.": 3, # 3-day delivery (regional)
"Lesaffre Ibérica": 4 # 4-day delivery (national)
}
# Daily consumption rates (kg/day) for realistic stock depletion modeling
# These match real bakery production needs
DAILY_CONSUMPTION_RATES = {
"Harina de Trigo T55": 50.0,
"Harina Integral Ecológica": 15.0,
"Mantequilla sin Sal 82% MG": 8.0,
"Huevos Frescos Categoría A": 100.0, # units, not kg, but modeled as kg for consistency
"Levadura Seca": 2.5,
"Sal Fina": 3.0,
"Aceite de Oliva Virgen": 5.0,
"Azúcar Moreno": 6.0,
"Semillas de Girasol": 2.0,
"Miel de Azahar": 1.5,
"Chocolate Negro 70%": 4.0,
"Nueces Peladas": 3.5,
"Pasas Sultanas": 2.5
}
# Reorder points (kg) - when to trigger PO
REORDER_POINTS = {
"Harina de Trigo T55": 150.0, # Critical ingredient
"Harina Integral Ecológica": 50.0,
"Mantequilla sin Sal 82% MG": 25.0,
"Huevos Frescos Categoría A": 300.0,
"Levadura Seca": 10.0,
"Sal Fina": 20.0,
"Aceite de Oliva Virgen": 15.0,
"Azúcar Moreno": 20.0,
"Semillas de Girasol": 10.0,
"Miel de Azahar": 5.0,
"Chocolate Negro 70%": 15.0,
"Nueces Peladas": 12.0,
"Pasas Sultanas": 10.0
}
def get_demo_supplier_ids(tenant_id: uuid.UUID):
"""
Generate tenant-specific supplier IDs using XOR strategy with hardcoded base IDs.
@@ -96,6 +139,106 @@ def get_demo_supplier_ids(tenant_id: uuid.UUID):
return suppliers
def get_simulated_stock_level(product_name: str, make_critical: bool = False) -> float:
"""
Simulate current stock level for demo purposes
Args:
product_name: Name of the product
make_critical: If True, create critically low stock (< 1 day)
Returns:
Simulated current stock in kg
"""
daily_consumption = DAILY_CONSUMPTION_RATES.get(product_name, 5.0)
if make_critical:
# Critical: 0.5-6 hours worth of stock
return round(daily_consumption * random.uniform(0.02, 0.25), 2)
else:
# Normal low stock: 1-3 days worth
return round(daily_consumption * random.uniform(1.0, 3.0), 2)
def calculate_product_urgency(
product_name: str,
current_stock: float,
supplier_lead_time_days: int,
reorder_point: float = None
) -> Dict[str, Any]:
"""
Calculate urgency metrics for a product based on supply chain dynamics
Args:
product_name: Name of the product
current_stock: Current stock level in kg
supplier_lead_time_days: Supplier delivery lead time in days
reorder_point: Reorder point threshold (optional)
Returns:
Dictionary with urgency metrics
"""
daily_consumption = DAILY_CONSUMPTION_RATES.get(product_name, 5.0)
reorder_pt = reorder_point or REORDER_POINTS.get(product_name, 50.0)
# Calculate days until depletion
if daily_consumption > 0:
days_until_depletion = current_stock / daily_consumption
else:
days_until_depletion = 999.0
# Calculate safety margin (days until depletion - supplier lead time)
safety_margin_days = days_until_depletion - supplier_lead_time_days
# Determine criticality based on safety margin
if safety_margin_days <= 0:
criticality = "critical" # Already late or will run out before delivery!
order_urgency_reason = f"Stock depletes in {round(days_until_depletion, 1)} days, but delivery takes {supplier_lead_time_days} days"
elif safety_margin_days <= 0.5:
criticality = "urgent" # Must order TODAY
order_urgency_reason = f"Only {round(safety_margin_days * 24, 1)} hours margin before stockout"
elif safety_margin_days <= 1:
criticality = "important" # Should order today
order_urgency_reason = f"Only {round(safety_margin_days, 1)} day margin"
else:
criticality = "normal"
order_urgency_reason = "Standard replenishment"
return {
"product_name": product_name,
"current_stock_kg": round(current_stock, 2),
"daily_consumption_kg": round(daily_consumption, 2),
"days_until_depletion": round(days_until_depletion, 2),
"reorder_point_kg": round(reorder_pt, 2),
"safety_stock_days": 3, # Standard 3-day safety stock
"safety_margin_days": round(safety_margin_days, 2),
"criticality": criticality,
"urgency_reason": order_urgency_reason
}
def determine_overall_po_urgency(product_details: List[Dict[str, Any]]) -> str:
"""
Determine overall PO urgency based on most critical product
Args:
product_details: List of product urgency dictionaries
Returns:
Overall urgency: "critical", "urgent", "important", or "normal"
"""
criticalities = [p.get("criticality", "normal") for p in product_details]
if "critical" in criticalities:
return "critical"
elif "urgent" in criticalities:
return "urgent"
elif "important" in criticalities:
return "important"
else:
return "normal"
async def create_purchase_order(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -131,7 +274,7 @@ async def create_purchase_order(
# Generate reasoning for JTBD dashboard (if columns exist after migration)
days_until_delivery = (required_delivery - created_at).days
# Generate structured reasoning_data for i18n support
# Generate structured reasoning_data with supply chain intelligence
reasoning_data = None
try:
@@ -142,18 +285,57 @@ async def create_purchase_order(
if not product_names:
product_names = ["Demo Product"]
# Get supplier lead time
supplier_lead_time = SUPPLIER_LEAD_TIMES.get(supplier.name, 3)
if status == PurchaseOrderStatus.pending_approval:
# Low stock detection reasoning
days_until_stockout = days_until_delivery + 2
# Enhanced low stock detection with per-product urgency analysis
product_details = []
estimated_loss = 0.0
for i, item in enumerate(items_list):
product_name = item.get('name', item.get('product_name', f"Product {i+1}"))
# Simulate current stock - make first item critical for demo impact
make_critical = (i == 0) and (priority == "urgent")
current_stock = get_simulated_stock_level(product_name, make_critical=make_critical)
# Calculate product-specific urgency
urgency_info = calculate_product_urgency(
product_name=product_name,
current_stock=current_stock,
supplier_lead_time_days=supplier_lead_time,
reorder_point=item.get('reorder_point')
)
product_details.append(urgency_info)
# Estimate production loss for critical items
if urgency_info["criticality"] in ["critical", "urgent"]:
# Rough estimate: lost production value
estimated_loss += item.get("unit_price", 1.0) * item.get("quantity", 10) * 1.5
# Determine overall urgency
overall_urgency = determine_overall_po_urgency(product_details)
# Find affected production batches (demo: simulate batch names)
affected_batches = []
critical_products = [p for p in product_details if p["criticality"] in ["critical", "urgent"]]
if critical_products:
# Simulate batch numbers that would be affected
affected_batches = ["BATCH-TODAY-001", "BATCH-TODAY-002"] if overall_urgency == "critical" else \
["BATCH-TOMORROW-001"] if overall_urgency == "urgent" else []
# Create enhanced reasoning with detailed supply chain intelligence
reasoning_data = create_po_reasoning_low_stock(
supplier_name=supplier.name,
product_names=product_names,
current_stock=random.uniform(20, 50), # Demo: low stock
required_stock=random.uniform(100, 200), # Demo: needed stock
days_until_stockout=days_until_stockout,
threshold_percentage=20,
affected_products=product_names[:2] if len(product_names) > 1 else product_names,
estimated_lost_orders=random.randint(5, 15) if days_until_stockout <= 3 else None
product_names=product_names, # Legacy compatibility
# Enhanced parameters
product_details=product_details,
supplier_lead_time_days=supplier_lead_time,
order_urgency=overall_urgency,
affected_production_batches=affected_batches,
estimated_production_loss_eur=estimated_loss if estimated_loss > 0 else None
)
elif auto_approved:
# Supplier contract/auto-approval reasoning
@@ -165,6 +347,7 @@ async def create_purchase_order(
)
except Exception as e:
logger.warning(f"Failed to generate reasoning_data: {e}")
logger.exception(e)
pass
# Create PO
@@ -298,17 +481,17 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
)
pos_created.append(po2)
# 3. PENDING_APPROVAL - Large amount (created yesterday)
# 3. PENDING_APPROVAL - URGENT: Critical stock for tomorrow's Croissant production
po3 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
db, tenant_id, supplier_high_trust,
PurchaseOrderStatus.pending_approval,
Decimal("250.00"),
created_offset_days=-1,
priority="normal",
Decimal("450.00"),
created_offset_days=0,
priority="urgent",
items_data=[
{"name": "Harina de Fuerza T65", "quantity": 500, "unit_price": 0.95, "uom": "kg"},
{"name": "Mantequilla Premium", "quantity": 80, "unit_price": 5.20, "uom": "kg"},
{"name": "Huevos Categoría A", "quantity": 600, "unit_price": 0.22, "uom": "unidad"}
{"name": "Harina de Trigo T55", "quantity": 100, "unit_price": 0.85, "uom": "kg"},
{"name": "Mantequilla sin Sal 82% MG", "quantity": 30, "unit_price": 6.50, "uom": "kg"},
{"name": "Huevos Frescos Categoría A", "quantity": 200, "unit_price": 0.25, "uom": "unidad"}
]
)
pos_created.append(po3)