88 KiB
🚀 Arquitectura Definitiva de Sesión de Demo — Alta Fidelidad, Baja Latencia
Resumen Ejecutivo
Este documento especifica los requisitos técnicos completos para el sistema de demostraciones técnicas hiperrealistas y deterministas de Bakery-IA, basado en la implementación real actual del proyecto.
Objetivo principal: Cada sesión debe simular un entorno productivo operativo —con datos interrelacionados, coherentes y contextualmente creíbles—, sin dependencia de infraestructura batch (Jobs, CronJobs, scripts externos).
Características clave:
- ✅ Creación instantánea (5–15 s) mediante llamadas HTTP paralelas
- ✅ Totalmente reproducible con garantías de integridad cruzada
- ✅ Datos temporales dinámicos ajustados al momento de creación de la sesión
- ✅ 70% menos código que la arquitectura anterior basada en Kubernetes Jobs
- ✅ 3-6x más rápido que el enfoque anterior
📋 Tabla de Contenidos
- Fase 0: Análisis y Alineación con Modelos de Base de Datos
- Arquitectura de Microservicios
- Garantía de Integridad Transversal
- Determinismo Temporal
- Modelo de Datos Base (SSOT)
- Estado Semilla del Orquestador
- Limpieza de Sesión
- Escenarios de Demo
- Verificación Técnica
🔍 FASE 0: ANÁLISIS Y ALINEACIÓN CON MODELOS REALES DE BASE DE DATOS
📌 Objetivo
Derivar esquemas de datos exactos y actualizados para cada servicio, a partir de sus modelos de base de datos en producción, y usarlos como contrato de validación para los archivos JSON de demo.
✨ Principio: Los datos de demostración deben ser estructuralmente aceptables por los ORM/servicios tal como están — sin transformaciones ad-hoc ni supresión de restricciones.
✅ Actividades Obligatorias
1. Extracción de Modelos Fuente-de-Verdad
Para cada servicio con clonación, extraer modelos reales desde:
Archivos de modelos existentes:
/services/tenant/app/models/tenants.py
/services/auth/app/models/users.py
/services/inventory/app/models/inventory.py
/services/production/app/models/production.py
/services/recipes/app/models/recipes.py
/services/procurement/app/models/procurement.py
/services/suppliers/app/models/suppliers.py
/services/orders/app/models/orders.py
/services/sales/app/models/sales.py
/services/forecasting/app/models/forecasting.py
/services/orchestrator/app/models/orchestrator.py
Documentar para cada modelo:
- Campos obligatorios (
NOT NULL,nullable=False) - Tipos de dato exactos (
UUID,DateTime(timezone=True),Float,Enum) - Claves foráneas internas (con nombre de columna y tabla destino)
- Referencias cross-service (UUIDs sin FK constraints)
- Índices únicos (ej.:
unique=True, índices compuestos) - Validaciones de negocio (ej.:
quantity >= 0) - Valores por defecto (ej.:
default=uuid.uuid4,default=ProductionStatus.PENDING)
2. Ejemplo de Modelo Real: ProductionBatch
Archivo: /services/production/app/models/production.py:68-150
class ProductionBatch(Base):
"""Production batch model for tracking individual production runs"""
__tablename__ = "production_batches"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
batch_number = Column(String(50), nullable=False, unique=True, index=True)
# Product and recipe information (cross-service references)
product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # → inventory
product_name = Column(String(255), nullable=False)
recipe_id = Column(UUID(as_uuid=True), nullable=True) # → recipes
# Production planning (REQUIRED temporal fields)
planned_start_time = Column(DateTime(timezone=True), nullable=False)
planned_end_time = Column(DateTime(timezone=True), nullable=False)
planned_quantity = Column(Float, nullable=False)
planned_duration_minutes = Column(Integer, nullable=False)
# Actual production tracking (OPTIONAL - only for started batches)
actual_start_time = Column(DateTime(timezone=True), nullable=True)
actual_end_time = Column(DateTime(timezone=True), nullable=True)
actual_quantity = Column(Float, nullable=True)
# Status and priority (REQUIRED with defaults)
status = Column(
SQLEnum(ProductionStatus),
nullable=False,
default=ProductionStatus.PENDING,
index=True
)
priority = Column(
SQLEnum(ProductionPriority),
nullable=False,
default=ProductionPriority.MEDIUM
)
# Process stage tracking (OPTIONAL)
current_process_stage = Column(SQLEnum(ProcessStage), nullable=True, index=True)
# Quality metrics (OPTIONAL)
yield_percentage = Column(Float, nullable=True)
quality_score = Column(Float, nullable=True)
waste_quantity = Column(Float, nullable=True)
# Equipment and staff (JSON arrays of UUIDs)
equipment_used = Column(JSON, nullable=True) # [uuid1, uuid2, ...]
staff_assigned = Column(JSON, nullable=True) # [uuid1, uuid2, ...]
# Cross-service order tracking
order_id = Column(UUID(as_uuid=True), nullable=True) # → orders service
forecast_id = Column(UUID(as_uuid=True), nullable=True) # → forecasting
# Reasoning data for i18n support
reasoning_data = Column(JSON, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
Reglas de validación derivadas:
planned_start_time < planned_end_timeplanned_quantity > 0actual_quantity <= planned_quantity * 1.1(permite 10% sobre-producción)status = IN_PROGRESS→actual_start_timedebe existirstatus = COMPLETED→actual_end_timedebe existirequipment_useddebe contener al menos 1 UUID válido
3. Generación de Esquemas de Validación (JSON Schema)
Para cada modelo, crear JSON Schema Draft 7+ en:
shared/demo/schemas/{service_name}/{model_name}.schema.json
Ejemplo para ProductionBatch:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.bakery-ia.com/demo/production/batch/v1",
"type": "object",
"title": "ProductionBatch",
"description": "Production batch for demo cloning",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "Unique batch identifier"
},
"tenant_id": {
"type": "string",
"format": "uuid",
"description": "Tenant owner (replaced during cloning)"
},
"batch_number": {
"type": "string",
"pattern": "^BATCH-[0-9]{8}-[A-Z0-9]{6}$",
"description": "Unique batch code"
},
"product_id": {
"type": "string",
"format": "uuid",
"description": "Cross-service ref to inventory.Ingredient (type=FINISHED_PRODUCT)"
},
"product_name": {
"type": "string",
"minLength": 1,
"maxLength": 255
},
"recipe_id": {
"type": ["string", "null"],
"format": "uuid",
"description": "Cross-service ref to recipes.Recipe"
},
"planned_start_time": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 datetime with timezone"
},
"planned_end_time": {
"type": "string",
"format": "date-time"
},
"planned_quantity": {
"type": "number",
"minimum": 0.1,
"description": "Quantity in product's unit of measure"
},
"planned_duration_minutes": {
"type": "integer",
"minimum": 1
},
"actual_start_time": {
"type": ["string", "null"],
"format": "date-time",
"description": "Set when status becomes IN_PROGRESS"
},
"actual_end_time": {
"type": ["string", "null"],
"format": "date-time",
"description": "Set when status becomes COMPLETED"
},
"status": {
"type": "string",
"enum": ["PENDING", "IN_PROGRESS", "COMPLETED", "CANCELLED", "ON_HOLD", "QUALITY_CHECK", "FAILED"],
"default": "PENDING"
},
"priority": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH", "URGENT"],
"default": "MEDIUM"
},
"current_process_stage": {
"type": ["string", "null"],
"enum": ["mixing", "proofing", "shaping", "baking", "cooling", "packaging", "finishing", null]
},
"equipment_used": {
"type": ["array", "null"],
"items": { "type": "string", "format": "uuid" },
"minItems": 1,
"description": "Array of Equipment IDs"
},
"staff_assigned": {
"type": ["array", "null"],
"items": { "type": "string", "format": "uuid" }
}
},
"required": [
"id", "tenant_id", "batch_number", "product_id", "product_name",
"planned_start_time", "planned_end_time", "planned_quantity",
"planned_duration_minutes", "status", "priority"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": { "status": { "const": "IN_PROGRESS" } }
},
"then": {
"required": ["actual_start_time"]
}
},
{
"if": {
"properties": { "status": { "const": "COMPLETED" } }
},
"then": {
"required": ["actual_start_time", "actual_end_time", "actual_quantity"]
}
}
]
}
4. Creación de Fixtures Base con Validación CI/CD
Ubicación actual de datos semilla:
services/{service}/scripts/demo/{entity}_es.json
Archivos existentes (legacy - referenciar para migración):
/services/auth/scripts/demo/usuarios_staff_es.json
/services/suppliers/scripts/demo/proveedores_es.json
/services/recipes/scripts/demo/recetas_es.json
/services/inventory/scripts/demo/ingredientes_es.json
/services/inventory/scripts/demo/stock_lotes_es.json
/services/production/scripts/demo/equipos_es.json
/services/production/scripts/demo/lotes_produccion_es.json
/services/production/scripts/demo/plantillas_calidad_es.json
/services/orders/scripts/demo/clientes_es.json
/services/orders/scripts/demo/pedidos_config_es.json
/services/forecasting/scripts/demo/previsiones_config_es.json
Nueva estructura propuesta:
shared/demo/fixtures/
├── schemas/ # JSON Schemas for validation
│ ├── production/
│ │ ├── batch.schema.json
│ │ ├── equipment.schema.json
│ │ └── quality_check.schema.json
│ ├── inventory/
│ │ ├── ingredient.schema.json
│ │ └── stock.schema.json
│ └── ...
├── professional/ # Professional tier seed data
│ ├── 01-tenant.json
│ ├── 02-auth.json
│ ├── 03-inventory.json
│ ├── 04-recipes.json
│ ├── 05-suppliers.json
│ ├── 06-production.json
│ ├── 07-procurement.json
│ ├── 08-orders.json
│ ├── 09-sales.json
│ └── 10-forecasting.json
└── enterprise/ # Enterprise tier seed data
├── parent/
│ └── ...
└── children/
├── madrid.json
├── barcelona.json
└── valencia.json
Integración CI/CD (GitHub Actions):
# .github/workflows/validate-demo-data.yml
name: Validate Demo Data
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js (for ajv-cli)
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install ajv-cli
run: npm install -g ajv-cli
- name: Validate Professional Tier Data
run: |
for schema in shared/demo/schemas/*/*.schema.json; do
service=$(basename $(dirname $schema))
model=$(basename $schema .schema.json)
# Find corresponding JSON file
json_file="shared/demo/fixtures/professional/*-${service}.json"
if ls $json_file 1> /dev/null 2>&1; then
echo "Validating ${service}/${model}..."
ajv validate -s "$schema" -d "$json_file" --strict=false
fi
done
- name: Validate Enterprise Tier Data
run: |
# Similar validation for enterprise tier
echo "Validating enterprise tier..."
- name: Check Cross-Service References
run: |
# Custom script to validate UUIDs exist across services
python scripts/validate_cross_refs.py
Script de validación de referencias cruzadas:
# scripts/validate_cross_refs.py
"""
Validates cross-service UUID references in demo data.
Ensures referential integrity without database constraints.
"""
import json
from pathlib import Path
from typing import Dict, Set
import sys
def load_all_fixtures(tier: str = "professional") -> Dict[str, any]:
"""Load all JSON fixtures for a tier"""
fixtures_dir = Path(f"shared/demo/fixtures/{tier}")
data = {}
for json_file in sorted(fixtures_dir.glob("*.json")):
service = json_file.stem.split('-', 1)[1] # Remove number prefix
with open(json_file, 'r') as f:
data[service] = json.load(f)
return data
def extract_ids(data: dict, entity_type: str) -> Set[str]:
"""Extract all IDs for an entity type"""
entities = data.get(entity_type, [])
return {e['id'] for e in entities}
def validate_references(data: Dict[str, any]) -> bool:
"""Validate all cross-service references"""
errors = []
# Extract all available IDs
ingredient_ids = extract_ids(data.get('inventory', {}), 'ingredients')
recipe_ids = extract_ids(data.get('recipes', {}), 'recipes')
equipment_ids = extract_ids(data.get('production', {}), 'equipment')
supplier_ids = extract_ids(data.get('suppliers', {}), 'suppliers')
# Validate ProductionBatch references
for batch in data.get('production', {}).get('batches', []):
# Check product_id exists in inventory
if batch['product_id'] not in ingredient_ids:
errors.append(
f"Batch {batch['batch_number']}: "
f"product_id {batch['product_id']} not found in inventory"
)
# Check recipe_id exists in recipes
if batch.get('recipe_id') and batch['recipe_id'] not in recipe_ids:
errors.append(
f"Batch {batch['batch_number']}: "
f"recipe_id {batch['recipe_id']} not found in recipes"
)
# Check equipment_used exists
for eq_id in batch.get('equipment_used', []):
if eq_id not in equipment_ids:
errors.append(
f"Batch {batch['batch_number']}: "
f"equipment {eq_id} not found in equipment"
)
# Validate Recipe ingredient references
for recipe in data.get('recipes', {}).get('recipes', []):
for ingredient in recipe.get('ingredients', []):
if ingredient['ingredient_id'] not in ingredient_ids:
errors.append(
f"Recipe {recipe['name']}: "
f"ingredient_id {ingredient['ingredient_id']} not found"
)
# Validate Stock supplier references
for stock in data.get('inventory', {}).get('stock', []):
if stock.get('supplier_id') and stock['supplier_id'] not in supplier_ids:
errors.append(
f"Stock {stock['batch_number']}: "
f"supplier_id {stock['supplier_id']} not found"
)
# Print errors
if errors:
print("❌ Cross-reference validation FAILED:")
for error in errors:
print(f" - {error}")
return False
print("✅ All cross-service references are valid")
return True
if __name__ == "__main__":
professional_data = load_all_fixtures("professional")
if not validate_references(professional_data):
sys.exit(1)
print("✅ Demo data validation passed")
5. Gestión de Evolución de Modelos
Crear CHANGELOG.md por servicio:
# Production Service - Demo Data Changelog
## 2025-12-13
- **BREAKING**: Added required field `reasoning_data` (JSON) to ProductionBatch
- Migration: Set to `null` for existing batches
- Demo data: Added reasoning structure for i18n support
- Updated JSON schema: `production/batch.schema.json` v1 → v2
## 2025-12-01
- Added optional field `shelf_life_days` (int, nullable) to Ingredient model
- Demo data: Updated ingredientes_es.json with shelf_life values
- JSON schema: `inventory/ingredient.schema.json` remains v1 (backward compatible)
Versionado de esquemas:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.bakery-ia.com/demo/production/batch/v2",
"version": "2.0.0",
"changelog": "https://github.com/bakery-ia/schemas/blob/main/CHANGELOG.md#production-batch-v2",
...
}
Compatibilidad hacia atrás:
- Nuevos campos deben ser
nullable=Trueo tener valoresdefault - Nunca eliminar campos sin ciclo de deprecación
- Mantener versiones antiguas de schemas durante al menos 2 releases
🏗 ARQUITECTURA DE MICROSERVICIOS
Inventario de Servicios (19 Total)
Archivo de referencia: /services/demo_session/app/services/clone_orchestrator.py:42-106
| Servicio | Puerto | Rol | Clonación | URL Kubernetes | Timeout |
|---|---|---|---|---|---|
| tenant | 8000 | Gestión de tenants y suscripciones | ✅ Requerido | http://tenant-service:8000 |
10s |
| auth | 8001 | Autenticación y usuarios | ✅ Requerido | http://auth-service:8001 |
10s |
| inventory | 8002 | Ingredientes y stock | ✅ Opcional | http://inventory-service:8002 |
30s |
| production | 8003 | Lotes y equipos de producción | ✅ Opcional | http://production-service:8003 |
30s |
| recipes | 8004 | Recetas y BOM | ✅ Opcional | http://recipes-service:8004 |
15s |
| procurement | 8005 | Órdenes de compra | ✅ Opcional | http://procurement-service:8005 |
25s |
| suppliers | 8006 | Proveedores | ✅ Opcional | http://suppliers-service:8006 |
20s |
| orders | 8007 | Pedidos de clientes | ✅ Opcional | http://orders-service:8007 |
15s |
| sales | 8008 | Historial de ventas | ✅ Opcional | http://sales-service:8008 |
30s |
| forecasting | 8009 | Previsión de demanda | ✅ Opcional | http://forecasting-service:8009 |
15s |
| distribution | 8010 | Logística y distribución | ❌ Futuro | http://distribution-service:8010 |
- |
| pos | 8011 | Integración TPV | ❌ No necesario | http://pos-service:8011 |
- |
| orchestrator | 8012 | Orquestación de workflows | ✅ Opcional | http://orchestrator-service:8012 |
15s |
| ai_insights | 8013 | Insights generados por IA | ❌ Calculados post-clone | http://ai-insights-service:8013 |
- |
| training | 8014 | Entrenamiento ML | ❌ No necesario | http://training-service:8014 |
- |
| alert_processor | 8015 | Procesamiento de alertas | ❌ Disparado post-clone | http://alert-processor-service:8015 |
- |
| notification | 8016 | Notificaciones (email/WhatsApp) | ❌ No necesario | http://notification-service:8016 |
- |
| external | 8017 | Datos externos (clima/tráfico) | ❌ No necesario | http://external-service:8017 |
- |
| demo_session | 8018 | Orquestación de demos | N/A | http://demo-session-service:8018 |
- |
Flujo de Clonación
Archivo de referencia: /services/demo_session/app/services/clone_orchestrator.py
POST /api/demo/sessions
↓
DemoSessionManager.create_session()
↓
CloneOrchestrator.clone_all_services(
base_tenant_id="a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
virtual_tenant_id=<nuevo UUID>,
demo_account_type="professional",
session_id="demo_abc123",
session_created_at="2025-12-13T10:00:00Z"
)
↓
┌─────────────────────────────────────────────────────────────┐
│ FASE 1: Clonación de Tenant Padre (Paralelo) │
└─────────────────────────────────────────────────────────────┘
║
╠═► POST tenant-service:8000/internal/demo/clone
╠═► POST auth-service:8001/internal/demo/clone
╠═► POST inventory-service:8002/internal/demo/clone
╠═► POST recipes-service:8004/internal/demo/clone
╠═► POST suppliers-service:8006/internal/demo/clone
╠═► POST production-service:8003/internal/demo/clone
╠═► POST procurement-service:8005/internal/demo/clone
╠═► POST orders-service:8007/internal/demo/clone
╠═► POST sales-service:8008/internal/demo/clone
╠═► POST forecasting-service:8009/internal/demo/clone
╚═► POST orchestrator-service:8012/internal/demo/clone
↓
[Esperar a que todos completen - asyncio.gather()]
↓
┌─────────────────────────────────────────────────────────────┐
│ FASE 2: [Solo Enterprise] Clonación de Outlets Hijos │
└─────────────────────────────────────────────────────────────┘
║
╠═► Para cada child_outlet (Madrid, Barcelona, Valencia):
║ ↓
║ POST tenant-service:8000/internal/demo/create-child
║ ↓
║ [Clonar servicios para child_tenant_id - paralelo]
║ ╠═► POST inventory-service/internal/demo/clone
║ ╠═► POST production-service/internal/demo/clone
║ ╠═► POST orders-service/internal/demo/clone
║ ╚═► ...
↓
┌─────────────────────────────────────────────────────────────┐
│ FASE 3: Generación de Alertas Post-Clonación │
└─────────────────────────────────────────────────────────────┘
║
╠═► POST procurement/internal/delivery-tracking/trigger
╠═► POST inventory/internal/alerts/trigger
╚═► POST production/internal/alerts/trigger
↓
┌─────────────────────────────────────────────────────────────┐
│ FASE 4: [Professional/Enterprise] Generación de Insights IA │
└─────────────────────────────────────────────────────────────┘
║
╠═► POST procurement/internal/insights/price/trigger
╠═► POST inventory/internal/insights/safety-stock/trigger
╚═► POST production/internal/insights/yield/trigger
↓
┌─────────────────────────────────────────────────────────────┐
│ FASE 5: Actualización de Estado de Sesión │
└─────────────────────────────────────────────────────────────┘
║
╠═► UPDATE demo_sessions SET status='READY', cloning_completed_at=NOW()
╚═► RETURN session + credentials
Tiempos esperados:
- Professional: 5-10 segundos
- Enterprise: 10-15 segundos (3 child outlets)
Comparación con arquitectura anterior:
- Professional antiguo: 30-40 segundos
- Enterprise antiguo: 60-75 segundos
- Mejora: 3-6x más rápido
🔐 GARANTÍA DE INTEGRIDAD TRANSVERSAL
Principio Rector
Ningún ID referenciado puede existir en un servicio sin que su entidad origen exista en su servicio propietario, bajo el mismo tenant virtual.
Estrategia de Referencias Cross-Service
Importante: No existen claves foráneas (FK) entre servicios - solo UUIDs almacenados.
Archivo de referencia: Cada servicio implementa su propia lógica de validación en /services/{service}/app/api/internal_demo.py
Tabla de Integridad Obligatoria
| Entidad (Servicio A) | Campo | Referencia | Entidad (Servicio B) | Validación Requerida |
|---|---|---|---|---|
ProductionBatch (production) |
recipe_id |
UUID | Recipe (recipes) |
✅ Debe existir con tenant_id = virtual_tenant_id y is_active = true |
ProductionBatch (production) |
product_id |
UUID | Ingredient (inventory) |
✅ Debe ser tipo FINISHED_PRODUCT del mismo tenant |
ProductionBatch (production) |
equipment_used |
UUID[] | Equipment (production) |
✅ Todos los IDs deben existir con status = OPERATIONAL |
ProductionBatch (production) |
order_id |
UUID | CustomerOrder (orders) |
✅ Debe existir y tener status != CANCELLED |
ProductionBatch (production) |
forecast_id |
UUID | Forecast (forecasting) |
✅ Debe existir para el mismo product_id |
Recipe (recipes) |
finished_product_id |
UUID | Ingredient (inventory) |
✅ Debe ser tipo FINISHED_PRODUCT |
RecipeIngredient (recipes) |
ingredient_id |
UUID | Ingredient (inventory) |
✅ Debe existir y tener product_type = INGREDIENT |
Stock (inventory) |
ingredient_id |
UUID | Ingredient (inventory) |
✅ FK interna - validación automática |
Stock (inventory) |
supplier_id |
UUID | Supplier (suppliers) |
✅ Debe existir con contrato vigente |
PurchaseOrder (procurement) |
supplier_id |
UUID | Supplier (suppliers) |
✅ Debe existir y estar activo |
PurchaseOrderItem (procurement) |
ingredient_id |
UUID | Ingredient (inventory) |
✅ Debe ser tipo INGREDIENT (no producto terminado) |
PurchaseOrderItem (procurement) |
purchase_order_id |
UUID | PurchaseOrder (procurement) |
✅ FK interna - validación automática |
ProcurementRequirement (procurement) |
plan_id |
UUID | ProcurementPlan (procurement) |
✅ FK interna - validación automática |
ProcurementRequirement (procurement) |
ingredient_id |
UUID | Ingredient (inventory) |
✅ Debe existir |
CustomerOrder (orders) |
customer_id |
UUID | Customer (orders) |
✅ FK interna - validación automática |
OrderItem (orders) |
customer_order_id |
UUID | CustomerOrder (orders) |
✅ FK interna - validación automática |
OrderItem (orders) |
product_id |
UUID | Ingredient (inventory) |
✅ Debe ser tipo FINISHED_PRODUCT |
QualityCheck (production) |
batch_id |
UUID | ProductionBatch (production) |
✅ FK interna - validación automática |
QualityCheck (production) |
template_id |
UUID | QualityCheckTemplate (production) |
✅ FK interna - validación automática |
SalesData (sales) |
product_id |
UUID | Ingredient (inventory) |
✅ Debe ser tipo FINISHED_PRODUCT |
Forecast (forecasting) |
product_id |
UUID | Ingredient (inventory) |
✅ Debe existir |
Mecanismo de Validación
Opción 1: Validación Pre-Clonación (Recomendada)
Validar todas las referencias en memoria al cargar los datos base, asumiendo que los archivos JSON están validados por CI/CD.
# En clone_orchestrator.py o pre-clone validation script
def validate_cross_service_refs(data: Dict[str, Any]) -> None:
"""
Validates all cross-service references before cloning.
Raises ValidationError if any reference is invalid.
"""
errors = []
# Extract available IDs
ingredient_ids = {i['id'] for i in data['inventory']['ingredients']}
recipe_ids = {r['id'] for r in data['recipes']['recipes']}
equipment_ids = {e['id'] for e in data['production']['equipment']}
# Validate ProductionBatch references
for batch in data['production']['batches']:
if batch['product_id'] not in ingredient_ids:
errors.append(f"Batch {batch['batch_number']}: product_id not found")
if batch.get('recipe_id') and batch['recipe_id'] not in recipe_ids:
errors.append(f"Batch {batch['batch_number']}: recipe_id not found")
for eq_id in batch.get('equipment_used', []):
if eq_id not in equipment_ids:
errors.append(f"Batch {batch['batch_number']}: equipment {eq_id} not found")
if errors:
raise ValidationError(f"Cross-service reference validation failed:\n" + "\n".join(errors))
Opción 2: Validación Runtime (Solo si datos no están pre-validados)
# En internal_demo.py de cada servicio
async def validate_cross_service_reference(
service_url: str,
entity_type: str,
entity_id: UUID,
tenant_id: UUID
) -> bool:
"""
Check if a cross-service reference exists.
Args:
service_url: URL of the service to check (e.g., "http://inventory-service:8000")
entity_type: Type of entity (e.g., "ingredient", "recipe")
entity_id: UUID of the entity
tenant_id: Tenant ID to filter by
Returns:
True if entity exists, False otherwise
"""
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.head(
f"{service_url}/internal/demo/exists",
params={
"entity_type": entity_type,
"id": str(entity_id),
"tenant_id": str(tenant_id)
},
headers={"X-Internal-API-Key": settings.INTERNAL_API_KEY}
)
return response.status_code == 200
# Uso en clonación
if batch.recipe_id:
if not await validate_cross_service_reference(
"http://recipes-service:8000",
"recipe",
batch.recipe_id,
virtual_tenant_id
):
raise IntegrityError(
f"Recipe {batch.recipe_id} not found for batch {batch.batch_number}"
)
Endpoint de existencia (implementar en cada servicio):
# En services/{service}/app/api/internal_demo.py
@router.head("/exists", dependencies=[Depends(verify_internal_key)])
async def check_entity_exists(
entity_type: str,
id: UUID,
tenant_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Check if an entity exists for a tenant.
Returns 200 if exists, 404 if not.
"""
if entity_type == "recipe":
result = await db.execute(
select(Recipe)
.where(Recipe.id == id)
.where(Recipe.tenant_id == tenant_id)
)
entity = result.scalar_one_or_none()
# ... otros entity_types
if entity:
return Response(status_code=200)
else:
return Response(status_code=404)
📅 DETERMINISMO TEMPORAL
Línea Base Fija
Archivo de referencia: /shared/utils/demo_dates.py:11-42
def get_base_reference_date(session_created_at: Optional[datetime] = None) -> datetime:
"""
Get the base reference date for demo data.
If session_created_at is provided, calculate relative to it.
Otherwise, use current time (for backwards compatibility with seed scripts).
Returns:
Base reference date at 6 AM UTC
"""
if session_created_at:
if session_created_at.tzinfo is None:
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
# Reference is session creation time at 6 AM that day
return session_created_at.replace(
hour=6, minute=0, second=0, microsecond=0
)
# Fallback for seed scripts: use today at 6 AM
now = datetime.now(timezone.utc)
return now.replace(hour=6, minute=0, second=0, microsecond=0)
Concepto:
- Todos los datos de seed están definidos respecto a una marca de tiempo fija: 6:00 AM UTC del día de creación de la sesión
- Esta marca se calcula dinámicamente en tiempo de clonación
Transformación Dinámica
Archivo de referencia: /shared/utils/demo_dates.py:45-93
def adjust_date_for_demo(
original_date: Optional[datetime],
session_created_at: datetime,
base_reference_date: datetime = BASE_REFERENCE_DATE
) -> Optional[datetime]:
"""
Adjust a date from seed data to be relative to demo session creation time.
Example:
# Seed data created on 2025-12-13 06:00
# Stock expiration: 2025-12-28 06:00 (15 days from seed date)
# Demo session created: 2025-12-16 10:00
# Base reference: 2025-12-16 06:00
# Result: 2025-12-31 10:00 (15 days from session date)
"""
if original_date is None:
return None
# Ensure timezone-aware datetimes
if original_date.tzinfo is None:
original_date = original_date.replace(tzinfo=timezone.utc)
if session_created_at.tzinfo is None:
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
if base_reference_date.tzinfo is None:
base_reference_date = base_reference_date.replace(tzinfo=timezone.utc)
# Calculate offset from base reference
offset = original_date - base_reference_date
# Apply offset to session creation date
return session_created_at + offset
En tiempo de clonación:
Δt = session_created_at - base_reference_date
new_timestamp = original_timestamp + Δt
Aplicación por Tipo de Dato
| Tipo | Campos Afectados | Regla de Transformación | Archivo de Referencia |
|---|---|---|---|
| Orden de Compra | created_at, order_date, expected_delivery_date, approval_deadline |
+Δt. Si expected_delivery_date cae en fin de semana → desplazar al lunes siguiente |
procurement/app/api/internal_demo.py |
| Lote de producción | planned_start_time, planned_end_time, actual_start_time |
+Δt. actual_start_time = null para lotes futuros; actual_start_time = planned_start_time para lotes en curso |
production/app/api/internal_demo.py |
| Stock | received_date, expiration_date |
+Δt. expiration_date = received_date + shelf_life_days |
inventory/app/api/internal_demo.py |
| Pedido cliente | order_date, delivery_date |
+Δt, manteniendo días laborables (lunes-viernes) |
orders/app/api/internal_demo.py |
| Alerta | triggered_at, acknowledged_at, resolved_at |
Solo triggered_at se transforma. resolved_at se calcula dinámicamente si está resuelta |
alert_processor/app/consumer/event_consumer.py |
| Forecast | forecast_date, prediction_date |
+Δt, alineado a inicio de semana (lunes) |
forecasting/app/api/internal_demo.py |
| Entrega | expected_date, actual_date |
+Δt, con ajuste por horario comercial (8-20h) |
procurement/app/api/internal_demo.py |
Funciones Avanzadas de Ajuste Temporal
Archivo de referencia: /shared/utils/demo_dates.py:264-341
shift_to_session_time
def shift_to_session_time(
original_offset_days: int,
original_hour: int,
original_minute: int,
session_created_at: datetime,
base_reference: Optional[datetime] = None
) -> datetime:
"""
Shift a time from seed data to demo session time with same-day preservation.
Ensures that:
1. Items scheduled for "today" (offset_days=0) remain on the same day as session creation
2. Future items stay in the future, past items stay in the past
3. Times don't shift to invalid moments (e.g., past times for pending items)
Examples:
# Session created at noon, item originally scheduled for morning
>>> session = datetime(2025, 12, 12, 12, 0, tzinfo=timezone.utc)
>>> result = shift_to_session_time(0, 6, 0, session) # Today at 06:00
>>> # Returns today at 13:00 (shifted forward to stay in future)
# Session created at noon, item originally scheduled for evening
>>> result = shift_to_session_time(0, 18, 0, session) # Today at 18:00
>>> # Returns today at 18:00 (already in future)
"""
# ... (implementación en demo_dates.py)
ensure_future_time
def ensure_future_time(
target_time: datetime,
reference_time: datetime,
min_hours_ahead: float = 1.0
) -> datetime:
"""
Ensure a target time is in the future relative to reference time.
If target_time is in the past or too close to reference_time,
shift it forward by at least min_hours_ahead.
"""
# ... (implementación en demo_dates.py)
calculate_edge_case_times
Archivo de referencia: /shared/utils/demo_dates.py:421-477
def calculate_edge_case_times(session_created_at: datetime) -> dict:
"""
Calculate deterministic edge case times for demo sessions.
These times are designed to always create specific demo scenarios:
- One late delivery (should have arrived hours ago)
- One overdue production batch (should have started hours ago)
- One in-progress batch (started recently)
- One upcoming batch (starts soon)
- One arriving-soon delivery (arrives in a few hours)
Returns:
{
'late_delivery_expected': session - 4h,
'overdue_batch_planned_start': session - 2h,
'in_progress_batch_actual_start': session - 1h45m,
'upcoming_batch_planned_start': session + 1h30m,
'arriving_soon_delivery_expected': session + 2h30m,
'evening_batch_planned_start': today 17:00,
'tomorrow_morning_planned_start': tomorrow 05:00
}
"""
Casos Extremos (Edge Cases) Requeridos para UI/UX
| Escenario | Configuración en Datos Base | Resultado Post-Transformación |
|---|---|---|
| OC retrasada | expected_delivery_date = BASE_TS - 1d, status = "PENDING" |
expected_delivery_date = session_created_at - 4h → alerta roja "Retraso de proveedor" |
| Lote atrasado | planned_start_time = BASE_TS - 2h, status = "PENDING", actual_start_time = null |
planned_start_time = session_created_at - 2h → alerta amarilla "Producción retrasada" |
| Lote en curso | planned_start_time = BASE_TS - 1h, status = "IN_PROGRESS", actual_start_time = BASE_TS - 1h45m |
actual_start_time = session_created_at - 1h45m → producción activa visible |
| Lote próximo | planned_start_time = BASE_TS + 1.5h, status = "PENDING" |
planned_start_time = session_created_at + 1.5h → próximo en planificación |
| Stock agotado + OC pendiente | Ingredient.stock = 0, reorder_point = 10, PurchaseOrder.status = "PENDING_APPROVAL" |
✅ Alerta de inventario no se dispara (evita duplicidad) |
| Stock agotado sin OC | Ingredient.stock = 0, reorder_point = 10, ninguna OC abierta |
❗ Alerta de inventario sí se dispara: "Bajo stock — acción requerida" |
| Stock próximo a caducar | expiration_date = BASE_TS + 2d |
expiration_date = session_created_at + 2d → alerta naranja "Caducidad próxima" |
Implementación en Clonación
Ejemplo real del servicio de producción:
# services/production/app/api/internal_demo.py (simplificado)
from shared.utils.demo_dates import (
adjust_date_for_demo,
calculate_edge_case_times,
ensure_future_time,
get_base_reference_date
)
@router.post("/clone")
async def clone_production_data(
request: DemoCloneRequest,
db: AsyncSession = Depends(get_db)
):
session_created_at = datetime.fromisoformat(request.session_created_at)
base_reference = get_base_reference_date(session_created_at)
edge_times = calculate_edge_case_times(session_created_at)
# Clone equipment (no date adjustment needed)
for equipment in base_equipment:
new_equipment = Equipment(
id=uuid.uuid4(),
tenant_id=request.virtual_tenant_id,
# ... copy fields
install_date=adjust_date_for_demo(
equipment.install_date,
session_created_at,
base_reference
)
)
db.add(new_equipment)
# Clone production batches with edge cases
batches = []
# Edge case 1: Overdue batch (should have started 2h ago)
batches.append({
"planned_start_time": edge_times["overdue_batch_planned_start"],
"planned_end_time": edge_times["overdue_batch_planned_start"] + timedelta(hours=3),
"status": ProductionStatus.PENDING,
"actual_start_time": None,
"priority": ProductionPriority.URGENT
})
# Edge case 2: In-progress batch
batches.append({
"planned_start_time": edge_times["in_progress_batch_actual_start"],
"planned_end_time": edge_times["upcoming_batch_planned_start"],
"status": ProductionStatus.IN_PROGRESS,
"actual_start_time": edge_times["in_progress_batch_actual_start"],
"priority": ProductionPriority.HIGH,
"current_process_stage": ProcessStage.BAKING
})
# Edge case 3: Upcoming batch
batches.append({
"planned_start_time": edge_times["upcoming_batch_planned_start"],
"planned_end_time": edge_times["upcoming_batch_planned_start"] + timedelta(hours=2),
"status": ProductionStatus.PENDING,
"actual_start_time": None,
"priority": ProductionPriority.MEDIUM
})
# Clone remaining batches from seed data
for base_batch in seed_batches:
adjusted_start = adjust_date_for_demo(
base_batch.planned_start_time,
session_created_at,
base_reference
)
# Ensure future batches stay in the future
if base_batch.status == ProductionStatus.PENDING:
adjusted_start = ensure_future_time(adjusted_start, session_created_at, min_hours_ahead=1.0)
batches.append({
"planned_start_time": adjusted_start,
"planned_end_time": adjust_date_for_demo(
base_batch.planned_end_time,
session_created_at,
base_reference
),
"status": base_batch.status,
"actual_start_time": adjust_date_for_demo(
base_batch.actual_start_time,
session_created_at,
base_reference
) if base_batch.actual_start_time else None,
# ... other fields
})
for batch_data in batches:
new_batch = ProductionBatch(
id=uuid.uuid4(),
tenant_id=request.virtual_tenant_id,
**batch_data
)
db.add(new_batch)
await db.commit()
return {
"service": "production",
"status": "completed",
"records_cloned": len(batches) + len(equipment_list),
"details": {
"batches": len(batches),
"equipment": len(equipment_list),
"edge_cases_created": 3
}
}
🧱 MODELO DE DATOS BASE — FUENTE ÚNICA DE VERDAD (SSOT)
Ubicación Actual vs. Propuesta
Archivos existentes (legacy):
/services/{service}/scripts/demo/{entity}_es.json
Nueva estructura propuesta:
shared/demo/
├── schemas/ # JSON Schemas para validación
│ ├── production/
│ │ ├── batch.schema.json
│ │ ├── equipment.schema.json
│ │ └── quality_check.schema.json
│ ├── inventory/
│ │ ├── ingredient.schema.json
│ │ └── stock.schema.json
│ ├── recipes/
│ │ ├── recipe.schema.json
│ │ └── recipe_ingredient.schema.json
│ └── ... (más servicios)
├── fixtures/
│ ├── professional/
│ │ ├── 01-tenant.json # Metadata del tenant base
│ │ ├── 02-auth.json # Usuarios demo
│ │ ├── 03-inventory.json # Ingredientes + stock
│ │ ├── 04-recipes.json # Recetas
│ │ ├── 05-suppliers.json # Proveedores
│ │ ├── 06-production.json # Equipos + lotes
│ │ ├── 07-procurement.json # OCs + planes
│ │ ├── 08-orders.json # Clientes + pedidos
│ │ ├── 09-sales.json # Historial ventas
│ │ └── 10-forecasting.json # Previsiones
│ └── enterprise/
│ ├── parent/
│ │ └── ... (misma estructura)
│ └── children/
│ ├── madrid.json # Datos específicos Madrid
│ ├── barcelona.json # Datos específicos Barcelona
│ └── valencia.json # Datos específicos Valencia
└── metadata/
├── tenant_configs.json # IDs base por tier
├── demo_users.json # Usuarios hardcoded
└── cross_refs_map.json # Mapa de dependencias
IDs Fijos por Tier
Archivo de referencia: /services/demo_session/app/core/config.py:38-72
DEMO_ACCOUNTS = {
"professional": {
"email": "demo.professional@panaderiaartesana.com",
"name": "Panadería Artesana Madrid - Demo",
"subdomain": "demo-artesana",
"base_tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
"subscription_tier": "professional",
"tenant_type": "standalone"
},
"enterprise": {
"email": "demo.enterprise@panaderiacentral.com",
"name": "Panadería Central - Demo Enterprise",
"subdomain": "demo-central",
"base_tenant_id": "80000000-0000-4000-a000-000000000001",
"subscription_tier": "enterprise",
"tenant_type": "parent",
"children": [
{
"name": "Madrid Centro",
"base_tenant_id": "A0000000-0000-4000-a000-000000000001",
"location": {
"city": "Madrid",
"zone": "Centro",
"latitude": 40.4168,
"longitude": -3.7038
}
},
{
"name": "Barcelona Gràcia",
"base_tenant_id": "B0000000-0000-4000-a000-000000000001",
"location": {
"city": "Barcelona",
"zone": "Gràcia",
"latitude": 41.4036,
"longitude": 2.1561
}
},
{
"name": "Valencia Ruzafa",
"base_tenant_id": "C0000000-0000-4000-a000-000000000001",
"location": {
"city": "Valencia",
"zone": "Ruzafa",
"latitude": 39.4623,
"longitude": -0.3645
}
}
]
}
}
Usuarios Demo Hardcoded
# Estos IDs están hardcoded en tenant/app/api/internal_demo.py
DEMO_OWNER_IDS = {
"professional": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López
"enterprise": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz
}
STAFF_USERS = {
"professional": [
{
"user_id": "50000000-0000-0000-0000-000000000001",
"role": "baker",
"name": "Juan Panadero"
},
{
"user_id": "50000000-0000-0000-0000-000000000002",
"role": "sales",
"name": "Ana Ventas"
},
{
"user_id": "50000000-0000-0000-0000-000000000003",
"role": "quality_control",
"name": "Pedro Calidad"
},
{
"user_id": "50000000-0000-0000-0000-000000000004",
"role": "admin",
"name": "Laura Admin"
},
{
"user_id": "50000000-0000-0000-0000-000000000005",
"role": "warehouse",
"name": "Carlos Almacén"
},
{
"user_id": "50000000-0000-0000-0000-000000000006",
"role": "production_manager",
"name": "Isabel Producción"
}
],
"enterprise": [
{
"user_id": "50000000-0000-0000-0000-000000000011",
"role": "production_manager",
"name": "Roberto Producción"
},
{
"user_id": "50000000-0000-0000-0000-000000000012",
"role": "quality_control",
"name": "Marta Calidad"
},
{
"user_id": "50000000-0000-0000-0000-000000000013",
"role": "logistics",
"name": "Javier Logística"
},
{
"user_id": "50000000-0000-0000-0000-000000000014",
"role": "sales",
"name": "Carmen Ventas"
},
{
"user_id": "50000000-0000-0000-0000-000000000015",
"role": "procurement",
"name": "Luis Compras"
},
{
"user_id": "50000000-0000-0000-0000-000000000016",
"role": "maintenance",
"name": "Miguel Mantenimiento"
}
]
}
Transformación de IDs Durante Clonación
Cada base_tenant_id en los archivos JSON se reemplaza por virtual_tenant_id durante la clonación.
Ejemplo de datos base:
// shared/demo/fixtures/professional/06-production.json
{
"equipment": [
{
"id": "eq-00000000-0001-0000-0000-000000000001",
"tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6", // ← Reemplazado
"name": "Horno de leña principal",
"type": "OVEN",
"model": "WoodFire Pro 3000",
"status": "OPERATIONAL",
"install_date": "2024-01-15T06:00:00Z"
}
],
"batches": [
{
"id": "batch-00000000-0001-0000-0000-000000000001",
"tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6", // ← Reemplazado
"batch_number": "BATCH-20251213-000001",
"product_id": "prod-00000000-0001-0000-0000-000000000001", // ref a inventory
"product_name": "Baguette Tradicional",
"recipe_id": "recipe-00000000-0001-0000-0000-000000000001", // ref a recipes
"planned_start_time": "2025-12-13T08:00:00Z", // ← Ajustado por demo_dates
"planned_end_time": "2025-12-13T11:00:00Z",
"planned_quantity": 100.0,
"planned_duration_minutes": 180,
"status": "PENDING",
"priority": "HIGH",
"equipment_used": [
"eq-00000000-0001-0000-0000-000000000001" // ref a equipment (mismo servicio)
]
}
]
}
Transformación durante clonación:
# En production/app/api/internal_demo.py
virtual_tenant_id = uuid.UUID("new-virtual-uuid-here")
# Transformar equipo
new_equipment_id = uuid.uuid4()
equipment_id_map[old_equipment_id] = new_equipment_id
new_equipment = Equipment(
id=new_equipment_id,
tenant_id=virtual_tenant_id, # ← REEMPLAZADO
# ... resto de campos copiados
)
# Transformar lote
new_batch = ProductionBatch(
id=uuid.uuid4(),
tenant_id=virtual_tenant_id, # ← REEMPLAZADO
batch_number=f"BATCH-{datetime.now():%Y%m%d}-{random_suffix}", # Nuevo número
product_id=batch_data["product_id"], # ← Mantener cross-service ref
recipe_id=batch_data["recipe_id"], # ← Mantener cross-service ref
equipment_used=[equipment_id_map[eq_id] for eq_id in batch_data["equipment_used"]], # ← Mapear IDs internos
# ...
)
🔄 ESTADO SEMILLA DEL ORQUESTADOR
Condiciones Iniciales Garantizadas
El estado inicial de la demo no es arbitrario: refleja el output de la última ejecución del Orquestador en producción simulada.
| Sistema | Estado Esperado | Justificación |
|---|---|---|
| Inventario | - 3 ingredientes en stock < reorder_point- 2 con OC pendiente (no disparan alerta) - 1 sin OC (dispara alerta roja) |
Realismo operativo - problemas de abastecimiento |
| Producción | - 1 lote "atrasado" (inicio planeado: hace 2h, status: PENDING) - 1 lote "en curso" (inicio real: hace 1h45m, status: IN_PROGRESS) - 2 programados para hoy (futuros) |
Simula operación diaria con variedad de estados |
| Procurement | - 2 OCs pendientes (1 aprobada por IA, 1 en revisión humana) - 1 OC retrasada (entrega esperada: hace 4h) - 3 OCs completadas (entregadas hace 1-7 días) |
Escenarios de toma de decisión y seguimiento |
| Calidad | - 3 controles completados (2 PASSED, 1 FAILED → lote en cuarentena) - 1 control pendiente (lote en QUALITY_CHECK) |
Flujo de calidad realista |
| Pedidos | - 5 clientes con pedidos recientes (últimos 30 días) - 2 pedidos pendientes de entrega (delivery_date: hoy/mañana) - 8 pedidos completados |
Actividad comercial realista |
| Forecasting | - Previsiones para próximos 7 días (generadas "ayer") - Precisión histórica: 88-92% (calculada vs sales reales) |
Datos de IA/ML creíbles |
Datos Específicos para Edge Cases
Archivo: shared/demo/fixtures/professional/06-production.json (ejemplo)
{
"batches": [
{
"id": "batch-edge-overdue-0001",
"tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
"batch_number": "BATCH-OVERDUE-0001",
"product_id": "prod-baguette-traditional",
"product_name": "Baguette Tradicional",
"recipe_id": "recipe-baguette-traditional",
"planned_start_time": "BASE_TS - 2h", // Marcador - se ajusta en clonación
"planned_end_time": "BASE_TS + 1h",
"planned_quantity": 100.0,
"planned_duration_minutes": 180,
"actual_start_time": null,
"actual_end_time": null,
"status": "PENDING",
"priority": "URGENT",
"equipment_used": ["eq-oven-main"],
"reasoning_data": {
"delay_reason": "equipment_maintenance",
"delay_reason_i18n_key": "production.delay.equipment_maintenance"
}
},
{
"id": "batch-edge-in-progress-0001",
"batch_number": "BATCH-IN-PROGRESS-0001",
"product_id": "prod-croissant-butter",
"product_name": "Croissant de Mantequilla",
"recipe_id": "recipe-croissant-butter",
"planned_start_time": "BASE_TS - 1h45m",
"planned_end_time": "BASE_TS + 1h15m",
"planned_quantity": 50.0,
"planned_duration_minutes": 180,
"actual_start_time": "BASE_TS - 1h45m",
"actual_end_time": null,
"actual_quantity": null,
"status": "IN_PROGRESS",
"priority": "HIGH",
"current_process_stage": "BAKING",
"equipment_used": ["eq-oven-main"],
"staff_assigned": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "batch-edge-upcoming-0001",
"batch_number": "BATCH-UPCOMING-0001",
"product_id": "prod-whole-wheat-bread",
"product_name": "Pan Integral",
"recipe_id": "recipe-whole-wheat",
"planned_start_time": "BASE_TS + 1h30m",
"planned_end_time": "BASE_TS + 4h30m",
"planned_quantity": 80.0,
"planned_duration_minutes": 180,
"status": "PENDING",
"priority": "MEDIUM",
"equipment_used": ["eq-oven-secondary"]
}
]
}
Nota: Los marcadores BASE_TS ± Xh se resuelven durante la clonación usando calculate_edge_case_times().
🧹 LIMPIEZA DE SESIÓN — ATOMICIDAD Y RESILIENCIA
Trigger Principal
API Endpoint:
DELETE /api/demo/sessions/{session_id}
Implementación:
# services/demo_session/app/api/sessions.py
@router.delete("/{session_id}")
async def destroy_demo_session(
session_id: str,
db: AsyncSession = Depends(get_db)
):
"""
Destroy a demo session and all associated data.
This triggers parallel deletion across all services.
"""
session = await get_session_by_id(db, session_id)
# Update status to DESTROYING
session.status = DemoSessionStatus.DESTROYING
await db.commit()
# Trigger cleanup
cleanup_service = DemoCleanupService(redis_manager=redis)
result = await cleanup_service.cleanup_session(session)
if result["success"]:
session.status = DemoSessionStatus.DESTROYED
else:
session.status = DemoSessionStatus.FAILED
session.error_details = result["errors"]
await db.commit()
return {
"session_id": session_id,
"status": session.status,
"records_deleted": result.get("total_deleted", 0),
"duration_ms": result.get("duration_ms", 0)
}
Limpieza Paralela por Servicio
Archivo de referencia: services/demo_session/app/services/cleanup_service.py (a crear basado en clone_orchestrator)
# services/demo_session/app/services/cleanup_service.py
class DemoCleanupService:
"""Orchestrates parallel demo data deletion via direct HTTP calls"""
async def cleanup_session(self, session: DemoSession) -> Dict[str, Any]:
"""
Delete all data for a demo session across all services.
Returns:
{
"success": bool,
"total_deleted": int,
"duration_ms": int,
"details": {service: {records_deleted, duration_ms}},
"errors": []
}
"""
start_time = time.time()
virtual_tenant_id = session.virtual_tenant_id
# Define services to clean (same as cloning)
services = [
ServiceDefinition("tenant", "http://tenant-service:8000", required=True),
ServiceDefinition("auth", "http://auth-service:8001", required=True),
ServiceDefinition("inventory", "http://inventory-service:8002"),
ServiceDefinition("production", "http://production-service:8003"),
# ... etc
]
# Delete from all services in parallel
tasks = [
self._delete_from_service(svc, virtual_tenant_id)
for svc in services
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Aggregate results
total_deleted = 0
details = {}
errors = []
for svc, result in zip(services, results):
if isinstance(result, Exception):
errors.append(f"{svc.name}: {str(result)}")
details[svc.name] = {"status": "error", "error": str(result)}
else:
total_deleted += result.get("records_deleted", {}).get("total", 0)
details[svc.name] = result
# Delete from Redis
await self._delete_redis_cache(virtual_tenant_id)
# Delete child tenants if enterprise
if session.demo_account_type == "enterprise":
child_metadata = session.session_metadata.get("children", [])
for child in child_metadata:
child_tenant_id = child["virtual_tenant_id"]
await self._delete_from_all_services(child_tenant_id)
duration_ms = int((time.time() - start_time) * 1000)
return {
"success": len(errors) == 0,
"total_deleted": total_deleted,
"duration_ms": duration_ms,
"details": details,
"errors": errors
}
async def _delete_from_service(
self,
service: ServiceDefinition,
virtual_tenant_id: UUID
) -> Dict[str, Any]:
"""Delete all data from a single service"""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.delete(
f"{service.url}/internal/demo/tenant/{virtual_tenant_id}",
headers={"X-Internal-API-Key": self.internal_api_key}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
# Already deleted or never existed - idempotent
return {
"service": service.name,
"status": "not_found",
"records_deleted": {"total": 0}
}
else:
raise Exception(f"HTTP {response.status_code}: {response.text}")
async def _delete_redis_cache(self, virtual_tenant_id: UUID):
"""Delete all Redis keys for a virtual tenant"""
pattern = f"*:{virtual_tenant_id}:*"
keys = await self.redis_manager.keys(pattern)
if keys:
await self.redis_manager.delete(*keys)
Endpoint de Limpieza en Cada Servicio
Contrato estándar:
# services/{service}/app/api/internal_demo.py
@router.delete(
"/tenant/{virtual_tenant_id}",
dependencies=[Depends(verify_internal_key)]
)
async def delete_demo_tenant_data(
virtual_tenant_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Delete all demo data for a virtual tenant.
This endpoint is idempotent - safe to call multiple times.
Returns:
{
"service": "production",
"status": "deleted",
"virtual_tenant_id": "uuid-here",
"records_deleted": {
"batches": 50,
"equipment": 12,
"quality_checks": 183,
"total": 245
},
"duration_ms": 567
}
"""
start_time = time.time()
records_deleted = {
"batches": 0,
"equipment": 0,
"quality_checks": 0,
"quality_templates": 0,
"production_schedules": 0,
"production_capacity": 0
}
try:
# Delete in reverse dependency order
# 1. Delete quality checks (depends on batches)
result = await db.execute(
delete(QualityCheck)
.where(QualityCheck.tenant_id == virtual_tenant_id)
)
records_deleted["quality_checks"] = result.rowcount
# 2. Delete production batches
result = await db.execute(
delete(ProductionBatch)
.where(ProductionBatch.tenant_id == virtual_tenant_id)
)
records_deleted["batches"] = result.rowcount
# 3. Delete equipment
result = await db.execute(
delete(Equipment)
.where(Equipment.tenant_id == virtual_tenant_id)
)
records_deleted["equipment"] = result.rowcount
# 4. Delete quality check templates
result = await db.execute(
delete(QualityCheckTemplate)
.where(QualityCheckTemplate.tenant_id == virtual_tenant_id)
)
records_deleted["quality_templates"] = result.rowcount
# 5. Delete production schedules
result = await db.execute(
delete(ProductionSchedule)
.where(ProductionSchedule.tenant_id == virtual_tenant_id)
)
records_deleted["production_schedules"] = result.rowcount
# 6. Delete production capacity records
result = await db.execute(
delete(ProductionCapacity)
.where(ProductionCapacity.tenant_id == virtual_tenant_id)
)
records_deleted["production_capacity"] = result.rowcount
await db.commit()
records_deleted["total"] = sum(records_deleted.values())
logger.info(
"demo_data_deleted",
service="production",
virtual_tenant_id=str(virtual_tenant_id),
records_deleted=records_deleted
)
return {
"service": "production",
"status": "deleted",
"virtual_tenant_id": str(virtual_tenant_id),
"records_deleted": records_deleted,
"duration_ms": int((time.time() - start_time) * 1000)
}
except Exception as e:
await db.rollback()
logger.error(
"demo_data_deletion_failed",
service="production",
virtual_tenant_id=str(virtual_tenant_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Failed to delete demo data: {str(e)}"
)
Registro de Auditoría
Modelo:
# services/demo_session/app/models/cleanup_audit.py
class DemoCleanupAudit(Base):
"""Audit log for demo session cleanup operations"""
__tablename__ = "demo_cleanup_audit"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id = Column(String(255), nullable=False, index=True)
virtual_tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
started_at = Column(DateTime(timezone=True), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
duration_ms = Column(Integer, nullable=True)
total_records_deleted = Column(Integer, default=0)
service_results = Column(JSON, nullable=True) # Details per service
status = Column(String(50), nullable=False) # SUCCESS, PARTIAL, FAILED
error_details = Column(JSON, nullable=True)
retry_count = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
Logging:
# Al iniciar limpieza
audit = DemoCleanupAudit(
session_id=session.session_id,
virtual_tenant_id=session.virtual_tenant_id,
started_at=datetime.now(timezone.utc),
status="IN_PROGRESS"
)
db.add(audit)
await db.commit()
# Al completar
audit.completed_at = datetime.now(timezone.utc)
audit.duration_ms = int((audit.completed_at - audit.started_at).total_seconds() * 1000)
audit.total_records_deleted = cleanup_result["total_deleted"]
audit.service_results = cleanup_result["details"]
audit.status = "SUCCESS" if cleanup_result["success"] else "PARTIAL"
audit.error_details = cleanup_result.get("errors")
await db.commit()
CronJob de Limpieza Automática
Implementación:
# services/demo_session/app/services/scheduled_cleanup.py
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from datetime import datetime, timezone, timedelta
scheduler = AsyncIOScheduler()
@scheduler.scheduled_job('cron', hour=2, minute=0) # 02:00 UTC diariamente
async def cleanup_expired_sessions():
"""
Find and clean up expired demo sessions.
Runs daily at 2 AM UTC.
"""
logger.info("scheduled_cleanup_started")
async with get_async_session() as db:
# Find expired sessions that haven't been destroyed
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
result = await db.execute(
select(DemoSession)
.where(DemoSession.expires_at < one_hour_ago)
.where(DemoSession.status.in_([
DemoSessionStatus.READY,
DemoSessionStatus.PARTIAL,
DemoSessionStatus.FAILED
]))
.limit(100) # Process in batches
)
expired_sessions = result.scalars().all()
logger.info(
"expired_sessions_found",
count=len(expired_sessions)
)
cleanup_service = DemoCleanupService()
success_count = 0
partial_count = 0
failed_count = 0
for session in expired_sessions:
try:
result = await cleanup_service.cleanup_session(session)
if result["success"]:
session.status = DemoSessionStatus.DESTROYED
success_count += 1
else:
session.status = DemoSessionStatus.PARTIAL
session.error_details = result.get("errors")
partial_count += 1
# Retry limit
retry_count = session.cleanup_retry_count or 0
if retry_count < 3:
session.cleanup_retry_count = retry_count + 1
logger.warning(
"cleanup_partial_will_retry",
session_id=session.session_id,
retry_count=session.cleanup_retry_count
)
else:
logger.error(
"cleanup_max_retries_exceeded",
session_id=session.session_id
)
# Notify ops team
await notify_ops_team(
f"Demo cleanup failed after 3 retries: {session.session_id}"
)
except Exception as e:
logger.error(
"cleanup_exception",
session_id=session.session_id,
error=str(e)
)
session.status = DemoSessionStatus.FAILED
session.error_details = {"exception": str(e)}
failed_count += 1
await db.commit()
logger.info(
"scheduled_cleanup_completed",
total=len(expired_sessions),
success=success_count,
partial=partial_count,
failed=failed_count
)
# Alert if >5% failure rate
if len(expired_sessions) > 0:
failure_rate = (partial_count + failed_count) / len(expired_sessions)
if failure_rate > 0.05:
await notify_ops_team(
f"High demo cleanup failure rate: {failure_rate:.1%} "
f"({partial_count + failed_count}/{len(expired_sessions)})"
)
# Start scheduler on app startup
def start_scheduled_cleanup():
scheduler.start()
logger.info("scheduled_cleanup_started")
Iniciar en app startup:
# services/demo_session/app/main.py
from app.services.scheduled_cleanup import start_scheduled_cleanup
@app.on_event("startup")
async def startup_event():
start_scheduled_cleanup()
logger.info("application_started")
🏢 ESCENARIOS DE DEMO — ESPECIFICACIÓN DETALLADA POR TIER
🥖 Tier Professional: "Panadería Artesana Madrid"
Configuración base: services/demo_session/app/core/config.py:39-46
| Dimensión | Detalle |
|---|---|
| Ubicación | Madrid, España (coordenadas ficticias) |
| Modelo | Tienda + obrador integrado (standalone) |
| Equipo | 6 personas: - María García (Owner/Admin) - Juan Panadero (Baker) - Ana Ventas (Sales) - Pedro Calidad (Quality Control) - Carlos Almacén (Warehouse) - Isabel Producción (Production Manager) |
| Productos | ~24 referencias: - 12 panes (baguette, integral, centeno, payés, etc.) - 8 bollería (croissant, napolitana, ensaimada, etc.) - 4 pastelería (tarta, pastel, galletas, brownies) |
| Recetas | 18-24 activas (alineadas con productos) |
| Maquinaria | - 1 horno de leña (OPERATIONAL) - 1 horno secundario (OPERATIONAL) - 2 amasadoras (1 en MAINTENANCE) - 1 laminadora (OPERATIONAL) - 1 cortadora (OPERATIONAL) |
| Proveedores | 5-6: - Harinas del Norte (harina) - Lácteos Gipuzkoa (leche/mantequilla) - Frutas Frescas (frutas) - Sal de Mar (sal) - Envases Pro (packaging) - Levaduras Spain (levadura) |
| Datos operativos | Stock crítico: - Harina: 5 kg (reorder_point: 50 kg) → OC pendiente - Levadura: 0 kg (reorder_point: 10 kg) → SIN OC → ❗ ALERTA - Mantequilla: 2 kg (reorder_point: 15 kg) → OC aprobada Lotes hoy: - OVERDUE: Baguette (debió empezar hace 2h) → ⚠️ ALERTA - IN_PROGRESS: Croissant (empezó hace 1h45m, etapa: BAKING) - UPCOMING: Pan Integral (empieza en 1h30m) Alertas activas: - 🔴 "Levadura agotada — crear OC urgente" - 🟡 "Producción retrasada — Baguette" |
| Dashboard KPIs | - % cumplimiento producción: 87% - Stock crítico: 3 ingredientes - Alertas abiertas: 2 - Forecasting precisión: 92% |
🏢 Tier Enterprise: "Panadería Central Group"
Configuración base: services/demo_session/app/core/config.py:47-72
| Dimensión | Detalle |
|---|---|
| Estructura | 1 obrador central (parent tenant) + 3 tiendas (child outlets): - Madrid Centro - Barcelona Gràcia - Valencia Ruzafa |
| Equipo | ~20 personas: Central: - Carlos Martínez (Owner/Director Operativo) - Roberto Producción (Production Manager) - Marta Calidad (Quality Control) - Luis Compras (Procurement) - Miguel Mantenimiento (Maintenance) Por tienda (5 personas cada una): - Gerente - 2 vendedores - 1 panadero - 1 warehouse |
| Producción | Centralizada en obrador principal: - Produce para las 3 tiendas - Distribuye por rutas nocturnas - Capacidad: 500 kg/día |
| Logística | Rutas diarias: - Ruta 1: Madrid → Barcelona (salida: 23:00, llegada: 05:00) - Ruta 2: Madrid → Valencia (salida: 01:00, llegada: 06:00) - Reparto local Madrid (salida: 05:00) |
| Datos por tienda | Madrid Centro: - Alta rotación - Stock ajustado (just-in-time) - Pedidos: 15-20/día Barcelona Gràcia: - Alta demanda turística - Productos premium (croissants mantequilla francesa) - ❗ Alerta activa: "Stock bajo brioche premium" Valencia Ruzafa: - En crecimiento - Stock más conservador - Pedidos: 10-15/día |
| Alertas multisite | - 🔴 BCN: "Stock bajo de brioche premium — OC creada por IA" - 🟠 Obrador: "Capacidad horno al 95% — riesgo cuello de botella" - 🟡 Logística: "Retraso ruta Barcelona — ETA +30 min (tráfico A-2)" |
| Dashboard Enterprise | Mapa de alertas: - Vista geográfica con marcadores por tienda - Drill-down por ubicación Comparativa KPIs: - Producción por tienda - Stock por ubicación - Margen por tienda Forecasting agregado: - Precisión: 88% - Próxima semana: +12% demanda prevista ROI de automatización IA: - Reducción desperdicio: 17% - Ahorro procurement: €1,200/mes |
✅ VERIFICACIÓN TÉCNICA
Requisitos de Validación
| Requisito | Cómo se Verifica | Tooling/Métrica | Umbral de Éxito |
|---|---|---|---|
| Determinismo | Ejecutar 100 clonaciones del mismo tier → comparar hash SHA-256 de todos los datos (por servicio) | Script scripts/test/demo_determinism.py |
100% de hashes idénticos (excluir timestamps audit) |
| Coherencia cruzada | Post-clonado, ejecutar CrossServiceIntegrityChecker → validar todas las referencias UUID |
Script scripts/validate_cross_refs.py (CI/CD) |
0 errores de integridad |
| Latencia Professional | P50, P95, P99 de tiempo de creación | Prometheus: demo_session_creation_duration_seconds{tier="professional"} |
P50 < 7s, P95 < 12s, P99 < 15s |
| Latencia Enterprise | P50, P95, P99 de tiempo de creación | Prometheus: demo_session_creation_duration_seconds{tier="enterprise"} |
P50 < 12s, P95 < 18s, P99 < 22s |
| Realismo temporal | En sesión creada a las 10:00, lote programado para "session + 1.5h" → debe ser 11:30 exacto | Validador scripts/test/time_delta_validator.py |
0 desviaciones > ±1 minuto |
| Alertas inmediatas | Tras creación, GET /alerts?tenant_id={virtual}&status=open debe devolver ≥2 alertas |
Cypress E2E: cypress/e2e/demo_session_spec.js |
Professional: ≥2 alertas Enterprise: ≥4 alertas |
| Limpieza atómica | Tras DELETE, consulta directa a DB de cada servicio → 0 registros con tenant_id = virtual_tenant_id |
Script scripts/test/cleanup_verification.py |
0 registros residuales en todas las tablas |
| Escalabilidad | 50 sesiones concurrentes → tasa de éxito, sin timeouts | Locust: locust -f scripts/load_test/demo_load_test.py --users 50 --spawn-rate 5 |
≥99% tasa de éxito 0 timeouts HTTP |
| Idempotencia limpieza | Llamar DELETE 3 veces consecutivas al mismo session_id → todas devuelven 200, sin errores |
Script scripts/test/idempotency_test.py |
3/3 llamadas exitosas |
Scripts de Validación
1. Determinismo
# scripts/test/demo_determinism.py
"""
Test deterministic cloning by creating multiple sessions and comparing data hashes.
"""
import asyncio
import hashlib
import json
from typing import List, Dict
import httpx
DEMO_API_URL = "http://localhost:8018"
INTERNAL_API_KEY = "test-internal-key"
async def create_demo_session(tier: str = "professional") -> dict:
"""Create a demo session"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{DEMO_API_URL}/api/demo/sessions",
json={"demo_account_type": tier}
)
return response.json()
async def get_all_data_from_service(
service_url: str,
tenant_id: str
) -> dict:
"""Fetch all data for a tenant from a service"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{service_url}/internal/demo/export/{tenant_id}",
headers={"X-Internal-API-Key": INTERNAL_API_KEY}
)
return response.json()
def calculate_data_hash(data: dict) -> str:
"""
Calculate SHA-256 hash of data, excluding audit timestamps.
"""
# Remove non-deterministic fields
clean_data = remove_audit_fields(data)
# Sort keys for consistency
json_str = json.dumps(clean_data, sort_keys=True)
return hashlib.sha256(json_str.encode()).hexdigest()
def remove_audit_fields(data: dict) -> dict:
"""Remove created_at, updated_at fields recursively"""
if isinstance(data, dict):
return {
k: remove_audit_fields(v)
for k, v in data.items()
if k not in ["created_at", "updated_at", "id"] # IDs are UUIDs
}
elif isinstance(data, list):
return [remove_audit_fields(item) for item in data]
else:
return data
async def test_determinism(tier: str = "professional", iterations: int = 100):
"""
Test that cloning is deterministic across multiple sessions.
"""
print(f"Testing determinism for {tier} tier ({iterations} iterations)...")
services = [
("inventory", "http://inventory-service:8002"),
("production", "http://production-service:8003"),
("recipes", "http://recipes-service:8004"),
]
hashes_by_service = {svc[0]: [] for svc in services}
for i in range(iterations):
# Create session
session = await create_demo_session(tier)
tenant_id = session["virtual_tenant_id"]
# Get data from each service
for service_name, service_url in services:
data = await get_all_data_from_service(service_url, tenant_id)
data_hash = calculate_data_hash(data)
hashes_by_service[service_name].append(data_hash)
# Cleanup
async with httpx.AsyncClient() as client:
await client.delete(f"{DEMO_API_URL}/api/demo/sessions/{session['session_id']}")
if (i + 1) % 10 == 0:
print(f" Completed {i + 1}/{iterations} iterations")
# Check consistency
all_consistent = True
for service_name, hashes in hashes_by_service.items():
unique_hashes = set(hashes)
if len(unique_hashes) == 1:
print(f"✅ {service_name}: All {iterations} hashes identical")
else:
print(f"❌ {service_name}: {len(unique_hashes)} different hashes found!")
all_consistent = False
if all_consistent:
print("\n✅ DETERMINISM TEST PASSED")
return 0
else:
print("\n❌ DETERMINISM TEST FAILED")
return 1
if __name__ == "__main__":
exit_code = asyncio.run(test_determinism())
exit(exit_code)
2. Latencia y Escalabilidad (Locust)
# scripts/load_test/demo_load_test.py
"""
Load test for demo session creation using Locust.
Usage:
locust -f demo_load_test.py --users 50 --spawn-rate 5 --run-time 5m
"""
from locust import HttpUser, task, between
import random
class DemoSessionUser(HttpUser):
wait_time = between(1, 3) # Wait 1-3s between tasks
def on_start(self):
"""Called when a user starts"""
self.session_id = None
@task(3)
def create_professional_session(self):
"""Create a professional tier demo session"""
with self.client.post(
"/api/demo/sessions",
json={"demo_account_type": "professional"},
catch_response=True
) as response:
if response.status_code == 200:
data = response.json()
self.session_id = data.get("session_id")
# Check if cloning completed successfully
if data.get("status") == "READY":
response.success()
else:
response.failure(f"Session not ready: {data.get('status')}")
else:
response.failure(f"Failed to create session: {response.status_code}")
@task(1)
def create_enterprise_session(self):
"""Create an enterprise tier demo session"""
with self.client.post(
"/api/demo/sessions",
json={"demo_account_type": "enterprise"},
catch_response=True
) as response:
if response.status_code == 200:
data = response.json()
self.session_id = data.get("session_id")
if data.get("status") == "READY":
response.success()
else:
response.failure(f"Session not ready: {data.get('status')}")
else:
response.failure(f"Failed to create session: {response.status_code}")
@task(1)
def get_session_status(self):
"""Get status of current session"""
if self.session_id:
self.client.get(f"/api/demo/sessions/{self.session_id}/status")
def on_stop(self):
"""Called when a user stops - cleanup session"""
if self.session_id:
self.client.delete(f"/api/demo/sessions/{self.session_id}")
Ejecutar:
# Test de carga: 50 usuarios concurrentes
locust -f scripts/load_test/demo_load_test.py \
--host http://localhost:8018 \
--users 50 \
--spawn-rate 5 \
--run-time 5m \
--html reports/demo_load_test_report.html
# Analizar resultados
# P95 latency debe ser < 12s (professional), < 18s (enterprise)
# Failure rate debe ser < 1%
Métricas de Prometheus
Archivo: services/demo_session/app/monitoring/metrics.py
from prometheus_client import Counter, Histogram, Gauge
# Counters
demo_sessions_created_total = Counter(
'demo_sessions_created_total',
'Total number of demo sessions created',
['tier', 'status']
)
demo_sessions_deleted_total = Counter(
'demo_sessions_deleted_total',
'Total number of demo sessions deleted',
['tier', 'status']
)
demo_cloning_errors_total = Counter(
'demo_cloning_errors_total',
'Total number of cloning errors',
['tier', 'service', 'error_type']
)
# Histograms (for latency percentiles)
demo_session_creation_duration_seconds = Histogram(
'demo_session_creation_duration_seconds',
'Duration of demo session creation',
['tier'],
buckets=[1, 2, 5, 7, 10, 12, 15, 18, 20, 25, 30, 40, 50, 60]
)
demo_service_clone_duration_seconds = Histogram(
'demo_service_clone_duration_seconds',
'Duration of individual service cloning',
['tier', 'service'],
buckets=[0.5, 1, 2, 3, 5, 10, 15, 20, 30, 40, 50]
)
demo_session_cleanup_duration_seconds = Histogram(
'demo_session_cleanup_duration_seconds',
'Duration of demo session cleanup',
['tier'],
buckets=[0.5, 1, 2, 5, 10, 15, 20, 30]
)
# Gauges
demo_sessions_active = Gauge(
'demo_sessions_active',
'Number of currently active demo sessions',
['tier']
)
demo_sessions_pending_cleanup = Gauge(
'demo_sessions_pending_cleanup',
'Number of demo sessions pending cleanup'
)
Queries de ejemplo:
# P95 latency for professional tier
histogram_quantile(0.95,
rate(demo_session_creation_duration_seconds_bucket{tier="professional"}[5m])
)
# P99 latency for enterprise tier
histogram_quantile(0.99,
rate(demo_session_creation_duration_seconds_bucket{tier="enterprise"}[5m])
)
# Error rate by service
rate(demo_cloning_errors_total[5m])
# Active sessions by tier
demo_sessions_active
📚 APÉNDICES
A. Endpoints Internos Completos
Tenant Service
POST /internal/demo/clone
POST /internal/demo/create-child (enterprise only)
DELETE /internal/demo/tenant/{virtual_tenant_id}
HEAD /internal/demo/exists?entity_type=tenant&id={id}&tenant_id={tenant_id}
GET /internal/demo/export/{tenant_id} (for testing)
Otros Servicios (Auth, Inventory, Production, etc.)
POST /internal/demo/clone
DELETE /internal/demo/tenant/{virtual_tenant_id}
HEAD /internal/demo/exists?entity_type={type}&id={id}&tenant_id={tenant_id}
GET /internal/demo/export/{tenant_id} (for testing)
Production Service (triggers post-clone)
POST /internal/alerts/trigger (trigger production alerts)
POST /internal/insights/yield/trigger (trigger yield insights)
Inventory Service (triggers post-clone)
POST /internal/alerts/trigger (trigger inventory alerts)
POST /internal/insights/safety-stock/trigger
Procurement Service (triggers post-clone)
POST /internal/delivery-tracking/trigger
POST /internal/insights/price/trigger
B. Variables de Entorno
# Demo Session Service
DEMO_SESSION_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/demo_session_db
DEMO_SESSION_DURATION_MINUTES=30
DEMO_SESSION_MAX_EXTENSIONS=3
DEMO_SESSION_CLEANUP_INTERVAL_MINUTES=60
# Internal API Key (shared across services)
INTERNAL_API_KEY=your-secret-internal-key
# Service URLs (Kubernetes defaults)
TENANT_SERVICE_URL=http://tenant-service:8000
AUTH_SERVICE_URL=http://auth-service:8001
INVENTORY_SERVICE_URL=http://inventory-service:8002
PRODUCTION_SERVICE_URL=http://production-service:8003
RECIPES_SERVICE_URL=http://recipes-service:8004
PROCUREMENT_SERVICE_URL=http://procurement-service:8005
SUPPLIERS_SERVICE_URL=http://suppliers-service:8006
ORDERS_SERVICE_URL=http://orders-service:8007
SALES_SERVICE_URL=http://sales-service:8008
FORECASTING_SERVICE_URL=http://forecasting-service:8009
ORCHESTRATOR_SERVICE_URL=http://orchestrator-service:8012
# Redis
REDIS_URL=redis://redis:6379/0
REDIS_KEY_PREFIX=demo:session
REDIS_SESSION_TTL=1800 # 30 minutes
# Feature Flags
ENABLE_DEMO_AI_INSIGHTS=true
ENABLE_DEMO_ALERT_GENERATION=true
C. Modelos de Base de Datos Clave
Ver archivos de referencia:
services/production/app/models/production.pyservices/inventory/app/models/inventory.pyservices/tenant/app/models/tenants.pyservices/auth/app/models/users.pyservices/demo_session/app/models/demo_session.py
D. Archivos de Referencia del Proyecto
| Archivo | Descripción |
|---|---|
services/demo_session/app/core/config.py |
Configuración de cuentas demo y settings |
services/demo_session/app/services/clone_orchestrator.py |
Orquestador de clonación paralela |
shared/utils/demo_dates.py |
Utilidades de ajuste temporal |
services/production/app/api/internal_demo.py |
Implementación de clonación (ejemplo) |
SIMPLIFIED_DEMO_SESSION_ARCHITECTURE.md |
Arquitectura simplificada actual |
🎯 CONCLUSIÓN
Este documento especifica un sistema de demostración técnica production-grade, con:
✅ Integridad garantizada mediante validación cross-service de referencias UUID
✅ Determinismo temporal con ajuste dinámico relativo a session_created_at
✅ Edge cases realistas que generan alertas y insights automáticamente
✅ Clonación paralela en 5-15 segundos (3-6x más rápido que arquitectura anterior)
✅ Limpieza atómica con idempotencia y registro de auditoría
✅ Validación CI/CD de esquemas JSON y referencias cruzadas
✅ Métricas y observabilidad con Prometheus
Resultado: Demos técnicamente impecables que simulan entornos productivos reales, sin infraestructura pesada, con datos coherentes y reproducibles.
Versión: 1.0 Fecha: 2025-12-13 Autor: Basado en arquitectura real de bakery-ia Mantenido por: Equipo de Infraestructura y DevOps