Files
bakery-ia/DEMO_ARCHITECTURE_COMPLETE_SPEC.md
2025-12-13 23:57:54 +01:00

88 KiB
Raw Blame History

🚀 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 (515 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

  1. Fase 0: Análisis y Alineación con Modelos de Base de Datos
  2. Arquitectura de Microservicios
  3. Garantía de Integridad Transversal
  4. Determinismo Temporal
  5. Modelo de Datos Base (SSOT)
  6. Estado Semilla del Orquestador
  7. Limpieza de Sesión
  8. Escenarios de Demo
  9. 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_time
  • planned_quantity > 0
  • actual_quantity <= planned_quantity * 1.1 (permite 10% sobre-producción)
  • status = IN_PROGRESSactual_start_time debe existir
  • status = COMPLETEDactual_end_time debe existir
  • equipment_used debe 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=True o tener valores default
  • 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:

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