Improve te panel de control logic
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user