Start integrating the onboarding flow with backend 12

This commit is contained in:
Urtzi Alfaro
2025-09-07 17:25:30 +02:00
parent 9005286ada
commit b73f3b4993
32 changed files with 3172 additions and 3087 deletions

View File

@@ -38,20 +38,22 @@ class UpdateStepRequest(BaseModel):
completed: bool
data: Optional[Dict[str, Any]] = None
# Define the onboarding steps and their order
# Define the onboarding steps and their order - matching frontend step IDs
ONBOARDING_STEPS = [
"user_registered", # Step 1: User account created
"bakery_registered", # Step 2: Bakery/tenant created
"sales_data_uploaded", # Step 3: Historical sales data uploaded
"training_completed", # Step 4: AI model training completed
"dashboard_accessible" # Step 5: Ready to use dashboard
"user_registered", # Auto-completed: User account created
"setup", # Step 1: Basic bakery setup and tenant creation
"smart-inventory-setup", # Step 2: Sales data upload and inventory configuration
"suppliers", # Step 3: Suppliers configuration (optional)
"ml-training", # Step 4: AI model training
"completion" # Step 5: Onboarding completed, ready to use dashboard
]
STEP_DEPENDENCIES = {
"bakery_registered": ["user_registered"],
"sales_data_uploaded": ["user_registered", "bakery_registered"],
"training_completed": ["user_registered", "bakery_registered", "sales_data_uploaded"],
"dashboard_accessible": ["user_registered", "bakery_registered", "sales_data_uploaded", "training_completed"]
"setup": ["user_registered"],
"smart-inventory-setup": ["user_registered", "setup"],
"suppliers": ["user_registered", "setup", "smart-inventory-setup"], # Optional step
"ml-training": ["user_registered", "setup", "smart-inventory-setup"],
"completion": ["user_registered", "setup", "smart-inventory-setup", "ml-training"]
}
class OnboardingService:
@@ -216,6 +218,30 @@ class OnboardingService:
if not user_progress_data.get(required_step, {}).get("completed", False):
return False
# SPECIAL VALIDATION FOR ML TRAINING STEP
if step_name == "ml-training":
# Ensure that smart-inventory-setup was completed with sales data imported
smart_inventory_data = user_progress_data.get("smart-inventory-setup", {}).get("data", {})
# Check if sales data was imported successfully
sales_import_result = smart_inventory_data.get("salesImportResult", {})
has_sales_data_imported = (
sales_import_result.get("records_created", 0) > 0 or
sales_import_result.get("success", False) or
sales_import_result.get("imported", False)
)
if not has_sales_data_imported:
logger.warning(f"ML training blocked for user {user_id}: No sales data imported",
extra={"sales_import_result": sales_import_result})
return False
# Also check if inventory is configured
inventory_configured = smart_inventory_data.get("inventoryConfigured", False)
if not inventory_configured:
logger.warning(f"ML training blocked for user {user_id}: Inventory not configured")
return False
return True
async def _get_user_onboarding_data(self, user_id: str) -> Dict[str, Any]:

View File

@@ -25,9 +25,9 @@ ENV PYTHONPATH=/app
# Expose port
EXPOSE 8000
# Health check
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" || exit 1
CMD python -c "import requests; requests.get('http://localhost:8000/health/', timeout=5)" || exit 1
# Run the application
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -79,22 +79,25 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
query = """
WITH stock_analysis AS (
SELECT
i.*,
COALESCE(p.scheduled_quantity, 0) as tomorrow_needed,
COALESCE(s.avg_daily_usage, 0) as avg_daily_usage,
COALESCE(s.lead_time_days, 7) as lead_time_days,
i.id, i.name, i.tenant_id,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.low_stock_threshold as minimum_stock,
i.max_stock_level as maximum_stock,
i.reorder_point,
0 as tomorrow_needed,
0 as avg_daily_usage,
7 as lead_time_days,
CASE
WHEN i.current_stock < i.minimum_stock THEN 'critical'
WHEN i.current_stock < i.minimum_stock * 1.2 THEN 'low'
WHEN i.current_stock > i.maximum_stock THEN 'overstock'
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical'
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low'
WHEN i.max_stock_level IS NOT NULL AND COALESCE(SUM(s.current_quantity), 0) > i.max_stock_level THEN 'overstock'
ELSE 'normal'
END as status,
GREATEST(0, i.minimum_stock - i.current_stock) as shortage_amount
FROM inventory_items i
LEFT JOIN production_schedule p ON p.ingredient_id = i.id
AND p.date = CURRENT_DATE + INTERVAL '1 day'
LEFT JOIN supplier_items s ON s.ingredient_id = i.id
WHERE i.tenant_id = $1 AND i.active = true
GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
WHERE i.tenant_id = $1 AND i.is_active = true
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level, i.reorder_point
)
SELECT * FROM stock_analysis WHERE status != 'normal'
ORDER BY
@@ -212,15 +215,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
query = """
SELECT
i.id, i.name, i.current_stock, i.tenant_id,
b.id as batch_id, b.expiry_date, b.quantity,
EXTRACT(days FROM (b.expiry_date - CURRENT_DATE)) as days_to_expiry
FROM inventory_items i
JOIN inventory_batches b ON b.ingredient_id = i.id
WHERE b.expiry_date <= CURRENT_DATE + INTERVAL '7 days'
AND b.quantity > 0
AND b.status = 'active'
ORDER BY b.expiry_date ASC
i.id, i.name, i.tenant_id,
s.id as stock_id, s.expiration_date, s.current_quantity,
EXTRACT(days FROM (s.expiration_date - CURRENT_DATE)) as days_to_expiry
FROM ingredients i
JOIN stock s ON s.ingredient_id = i.id
WHERE s.expiration_date <= CURRENT_DATE + INTERVAL '7 days'
AND s.current_quantity > 0
AND s.is_available = true
AND s.expiration_date IS NOT NULL
ORDER BY s.expiration_date ASC
"""
from sqlalchemy import text
@@ -275,8 +279,8 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
{
'id': str(item['id']),
'name': item['name'],
'batch_id': str(item['batch_id']),
'quantity': float(item['quantity']),
'stock_id': str(item['stock_id']),
'quantity': float(item['current_quantity']),
'days_expired': abs(item['days_to_expiry'])
} for item in expired
]
@@ -294,9 +298,9 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
'actions': ['Usar inmediatamente', 'Promoción especial', 'Revisar recetas', 'Documentar'],
'metadata': {
'ingredient_id': str(item['id']),
'batch_id': str(item['batch_id']),
'stock_id': str(item['stock_id']),
'days_to_expiry': item['days_to_expiry'],
'quantity': float(item['quantity'])
'quantity': float(item['current_quantity'])
}
}, item_type='alert')
@@ -312,14 +316,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
query = """
SELECT
t.id, t.sensor_id, t.location, t.temperature,
t.max_threshold, t.tenant_id,
EXTRACT(minutes FROM (NOW() - t.first_breach_time)) as breach_duration_minutes
FROM temperature_readings t
WHERE t.temperature > t.max_threshold
AND t.breach_duration_minutes >= 30 -- Only after 30 minutes
AND t.last_alert_sent < NOW() - INTERVAL '15 minutes' -- Avoid spam
ORDER BY t.temperature DESC, t.breach_duration_minutes DESC
t.id, t.equipment_id as sensor_id, t.storage_location as location,
t.temperature_celsius as temperature,
t.target_temperature_max as max_threshold, t.tenant_id,
COALESCE(t.deviation_minutes, 0) as breach_duration_minutes
FROM temperature_logs t
WHERE t.temperature_celsius > COALESCE(t.target_temperature_max, 25)
AND NOT t.is_within_range
AND COALESCE(t.deviation_minutes, 0) >= 30 -- Only after 30 minutes
AND (t.recorded_at < NOW() - INTERVAL '15 minutes' OR t.alert_triggered = false) -- Avoid spam
ORDER BY t.temperature_celsius DESC, t.deviation_minutes DESC
"""
from sqlalchemy import text
@@ -371,11 +377,14 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
}
}, item_type='alert')
# Update last alert sent time to avoid spam
await self.db_manager.execute(
"UPDATE temperature_readings SET last_alert_sent = NOW() WHERE id = $1",
breach['id']
)
# Update alert triggered flag to avoid spam
from sqlalchemy import text
async with self.db_manager.get_session() as session:
await session.execute(
text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"),
{"id": breach['id']}
)
await session.commit()
except Exception as e:
logger.error("Error processing temperature breach",
@@ -391,24 +400,27 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
query = """
WITH usage_analysis AS (
SELECT
i.id, i.name, i.tenant_id, i.minimum_stock, i.maximum_stock,
i.current_stock,
AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'out'
i.id, i.name, i.tenant_id,
i.low_stock_threshold as minimum_stock,
i.max_stock_level as maximum_stock,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'production_use'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as avg_daily_usage,
COUNT(sm.id) FILTER (WHERE sm.movement_type = 'out'
COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as usage_days,
MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'out') as last_used
FROM inventory_items i
MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'production_use') as last_used
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
WHERE i.active = true AND i.tenant_id = $1
GROUP BY i.id
HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'out'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 5
WHERE i.is_active = true AND i.tenant_id = $1
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level
HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3
),
recommendations AS (
SELECT *,
CASE
WHEN avg_daily_usage * 7 > maximum_stock THEN 'increase_max'
WHEN avg_daily_usage * 7 > maximum_stock AND maximum_stock IS NOT NULL THEN 'increase_max'
WHEN avg_daily_usage * 3 < minimum_stock THEN 'decrease_min'
WHEN current_stock / NULLIF(avg_daily_usage, 0) > 14 THEN 'reduce_stock'
WHEN avg_daily_usage > 0 AND minimum_stock / avg_daily_usage < 3 THEN 'increase_min'
@@ -500,20 +512,21 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
async def generate_waste_reduction_recommendations(self):
"""Generate waste reduction recommendations"""
try:
# Analyze waste patterns
# Analyze waste patterns from stock movements
query = """
SELECT
i.id, i.name, i.tenant_id,
SUM(w.quantity) as total_waste_30d,
COUNT(w.id) as waste_incidents,
AVG(w.quantity) as avg_waste_per_incident,
w.waste_reason
FROM inventory_items i
JOIN waste_logs w ON w.ingredient_id = i.id
WHERE w.created_at > CURRENT_DATE - INTERVAL '30 days'
SUM(sm.quantity) as total_waste_30d,
COUNT(sm.id) as waste_incidents,
AVG(sm.quantity) as avg_waste_per_incident,
COALESCE(sm.reason_code, 'unknown') as waste_reason
FROM ingredients i
JOIN stock_movements sm ON sm.ingredient_id = i.id
WHERE sm.movement_type = 'waste'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days'
AND i.tenant_id = $1
GROUP BY i.id, w.waste_reason
HAVING SUM(w.quantity) > 5 -- More than 5kg wasted
GROUP BY i.id, i.name, i.tenant_id, sm.reason_code
HAVING SUM(sm.quantity) > 5 -- More than 5kg wasted
ORDER BY total_waste_30d DESC
"""
@@ -694,10 +707,14 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
"""Get stock information after hypothetical order"""
try:
query = """
SELECT id, name, current_stock, minimum_stock,
(current_stock - $2) as remaining
FROM inventory_items
WHERE id = $1
SELECT i.id, i.name,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.low_stock_threshold as minimum_stock,
(COALESCE(SUM(s.current_quantity), 0) - $2) as remaining
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
WHERE i.id = $1
GROUP BY i.id, i.name, i.low_stock_threshold
"""
result = await self.db_manager.fetchrow(query, ingredient_id, order_quantity)