Improve demo seed

This commit is contained in:
Urtzi Alfaro
2025-10-17 07:31:14 +02:00
parent b6cb800758
commit d4060962e4
56 changed files with 8235 additions and 339 deletions

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Seed Demo Users
Creates demo user accounts for production demo environment
@@ -18,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
from sqlalchemy import select
import structlog
import uuid
import json
logger = structlog.get_logger()
@@ -52,6 +54,22 @@ DEMO_USERS = [
]
def load_staff_users():
"""Load staff users from JSON file"""
json_file = Path(__file__).parent / "usuarios_staff_es.json"
if not json_file.exists():
logger.warning(f"Staff users JSON not found: {json_file}, skipping staff users")
return []
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Combine both individual and central bakery staff
all_staff = data.get("staff_individual_bakery", []) + data.get("staff_central_bakery", [])
logger.info(f"Loaded {len(all_staff)} staff users from JSON")
return all_staff
async def seed_demo_users():
"""Seed demo users into auth database"""
@@ -74,7 +92,17 @@ async def seed_demo_users():
from services.auth.app.models.users import User
from datetime import datetime, timezone
for user_data in DEMO_USERS:
# Load staff users from JSON
staff_users = load_staff_users()
# Combine owner users with staff users
all_users = DEMO_USERS + staff_users
logger.info(f"Seeding {len(all_users)} total users ({len(DEMO_USERS)} owners + {len(staff_users)} staff)")
created_count = 0
skipped_count = 0
for user_data in all_users:
# Check if user already exists
result = await session.execute(
select(User).where(User.email == user_data["email"])
@@ -82,7 +110,8 @@ async def seed_demo_users():
existing_user = result.scalar_one_or_none()
if existing_user:
logger.info(f"Demo user already exists: {user_data['email']}")
logger.debug(f"Demo user already exists: {user_data['email']}")
skipped_count += 1
continue
# Create new demo user
@@ -102,10 +131,11 @@ async def seed_demo_users():
)
session.add(user)
logger.info(f"Created demo user: {user_data['email']}")
created_count += 1
logger.debug(f"Created demo user: {user_data['email']} ({user_data.get('role', 'owner')})")
await session.commit()
logger.info("Demo users seeded successfully")
logger.info(f"Demo users seeded successfully: {created_count} created, {skipped_count} skipped")
return True
except Exception as e:

View File

@@ -0,0 +1,204 @@
{
"staff_individual_bakery": [
{
"id": "50000000-0000-0000-0000-000000000001",
"email": "juan.panadero@panaderiasanpablo.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Juan Pérez Moreno",
"phone": "+34 912 111 001",
"language": "es",
"timezone": "Europe/Madrid",
"role": "baker",
"department": "production",
"position": "Panadero Senior",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000002",
"email": "ana.ventas@panaderiasanpablo.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Ana Rodríguez Sánchez",
"phone": "+34 912 111 002",
"language": "es",
"timezone": "Europe/Madrid",
"role": "sales",
"department": "sales",
"position": "Responsable de Ventas",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000003",
"email": "luis.calidad@panaderiasanpablo.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Luis Fernández García",
"phone": "+34 912 111 003",
"language": "es",
"timezone": "Europe/Madrid",
"role": "quality_control",
"department": "quality",
"position": "Inspector de Calidad",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000004",
"email": "carmen.admin@panaderiasanpablo.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Carmen López Martínez",
"phone": "+34 912 111 004",
"language": "es",
"timezone": "Europe/Madrid",
"role": "admin",
"department": "administration",
"position": "Administradora",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000005",
"email": "pedro.almacen@panaderiasanpablo.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Pedro González Torres",
"phone": "+34 912 111 005",
"language": "es",
"timezone": "Europe/Madrid",
"role": "warehouse",
"department": "inventory",
"position": "Encargado de Almacén",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000006",
"email": "isabel.produccion@panaderiasanpablo.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Isabel Romero Díaz",
"phone": "+34 912 111 006",
"language": "es",
"timezone": "Europe/Madrid",
"role": "production_manager",
"department": "production",
"position": "Jefa de Producción",
"is_active": true,
"is_verified": true,
"is_demo": true
}
],
"staff_central_bakery": [
{
"id": "50000000-0000-0000-0000-000000000011",
"email": "roberto.produccion@panaderialaespiga.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Roberto Sánchez Vargas",
"phone": "+34 913 222 001",
"language": "es",
"timezone": "Europe/Madrid",
"role": "production_manager",
"department": "production",
"position": "Director de Producción",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000012",
"email": "sofia.calidad@panaderialaespiga.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Sofía Jiménez Ortega",
"phone": "+34 913 222 002",
"language": "es",
"timezone": "Europe/Madrid",
"role": "quality_control",
"department": "quality",
"position": "Responsable de Control de Calidad",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000013",
"email": "miguel.logistica@panaderialaespiga.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Miguel Herrera Castro",
"phone": "+34 913 222 003",
"language": "es",
"timezone": "Europe/Madrid",
"role": "logistics",
"department": "logistics",
"position": "Coordinador de Logística",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000014",
"email": "elena.ventas@panaderialaespiga.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Elena Morales Ruiz",
"phone": "+34 913 222 004",
"language": "es",
"timezone": "Europe/Madrid",
"role": "sales",
"department": "sales",
"position": "Directora Comercial",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000015",
"email": "javier.compras@panaderialaespiga.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Javier Navarro Prieto",
"phone": "+34 913 222 005",
"language": "es",
"timezone": "Europe/Madrid",
"role": "procurement",
"department": "procurement",
"position": "Responsable de Compras",
"is_active": true,
"is_verified": true,
"is_demo": true
},
{
"id": "50000000-0000-0000-0000-000000000016",
"email": "laura.mantenimiento@panaderialaespiga.com",
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi",
"full_name": "Laura Delgado Santos",
"phone": "+34 913 222 006",
"language": "es",
"timezone": "Europe/Madrid",
"role": "maintenance",
"department": "maintenance",
"position": "Técnica de Mantenimiento",
"is_active": true,
"is_verified": true,
"is_demo": true
}
],
"notas": {
"password_comun": "DemoStaff2024!",
"total_staff": 12,
"roles": {
"individual_bakery": ["baker", "sales", "quality_control", "admin", "warehouse", "production_manager"],
"central_bakery": ["production_manager", "quality_control", "logistics", "sales", "procurement", "maintenance"]
},
"departamentos": [
"production",
"sales",
"quality",
"administration",
"inventory",
"logistics",
"procurement",
"maintenance"
]
}
}

View File

@@ -8,6 +8,7 @@ from typing import Dict, Any, List
import httpx
import structlog
import uuid
import os
from app.core.redis_wrapper import DemoRedisWrapper
from app.core import settings
@@ -64,7 +65,8 @@ class DemoDataCloner:
service_name,
base_demo_tenant_id,
virtual_tenant_id,
session_id
session_id,
demo_account_type
)
stats["services_cloned"].append(service_name)
stats["total_records"] += service_stats.get("records_cloned", 0)
@@ -110,7 +112,8 @@ class DemoDataCloner:
service_name: str,
base_tenant_id: str,
virtual_tenant_id: str,
session_id: str
session_id: str,
demo_account_type: str
) -> Dict[str, Any]:
"""
Clone data for a specific service
@@ -120,21 +123,26 @@ class DemoDataCloner:
base_tenant_id: Source tenant ID
virtual_tenant_id: Target tenant ID
session_id: Session ID
demo_account_type: Type of demo account
Returns:
Cloning statistics
"""
service_url = self._get_service_url(service_name)
# Get internal API key from environment
internal_api_key = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{service_url}/internal/demo/clone",
json={
"base_tenant_id": base_tenant_id,
"virtual_tenant_id": virtual_tenant_id,
"session_id": session_id
"session_id": session_id,
"demo_account_type": demo_account_type
},
headers={"X-Internal-Service": "demo-session"}
headers={"X-Internal-Api-Key": internal_api_key}
)
response.raise_for_status()
@@ -261,7 +269,17 @@ class DemoDataCloner:
)
# Delete from each service
services = ["inventory", "recipes", "sales", "orders", "production", "suppliers", "pos"]
# Note: Services are deleted in reverse dependency order to avoid foreign key issues
services = [
"forecasting", # No dependencies
"sales", # Depends on inventory, recipes
"orders", # Depends on customers (within same service)
"production", # Depends on recipes, equipment
"inventory", # Core data (ingredients, products)
"recipes", # Core data
"suppliers", # Core data
"pos" # Point of sale data
]
for service_name in services:
try:
@@ -282,8 +300,11 @@ class DemoDataCloner:
"""Delete data from a specific service"""
service_url = self._get_service_url(service_name)
# Get internal API key from environment
internal_api_key = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
async with httpx.AsyncClient(timeout=30.0) as client:
await client.delete(
f"{service_url}/internal/demo/tenant/{virtual_tenant_id}",
headers={"X-Internal-Service": "demo-session"}
headers={"X-Internal-Api-Key": internal_api_key}
)

View File

@@ -0,0 +1,307 @@
{
"configuracion_previsiones": {
"productos_por_tenant": 15,
"dias_prevision_futuro": 14,
"dias_historico": 30,
"intervalo_previsiones_dias": 1,
"nivel_confianza": 0.8,
"productos_demo": [
{
"id": "60000000-0000-0000-0000-000000000001",
"nombre": "Pan de Barra Tradicional",
"demanda_base_diaria": 250.0,
"variabilidad": 0.15,
"tendencia_semanal": {
"lunes": 1.1,
"martes": 1.0,
"miercoles": 0.95,
"jueves": 1.0,
"viernes": 1.2,
"sabado": 1.3,
"domingo": 0.7
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000002",
"nombre": "Baguette",
"demanda_base_diaria": 180.0,
"variabilidad": 0.20,
"tendencia_semanal": {
"lunes": 0.9,
"martes": 0.95,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.25,
"sabado": 1.35,
"domingo": 0.85
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000003",
"nombre": "Pan Integral",
"demanda_base_diaria": 120.0,
"variabilidad": 0.18,
"tendencia_semanal": {
"lunes": 1.15,
"martes": 1.1,
"miercoles": 1.05,
"jueves": 1.0,
"viernes": 1.1,
"sabado": 1.0,
"domingo": 0.6
},
"estacionalidad": "creciente"
},
{
"id": "60000000-0000-0000-0000-000000000004",
"nombre": "Croissant",
"demanda_base_diaria": 200.0,
"variabilidad": 0.25,
"tendencia_semanal": {
"lunes": 0.8,
"martes": 0.9,
"miercoles": 0.9,
"jueves": 0.95,
"viernes": 1.1,
"sabado": 1.5,
"domingo": 1.4
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000005",
"nombre": "Napolitana de Chocolate",
"demanda_base_diaria": 150.0,
"variabilidad": 0.22,
"tendencia_semanal": {
"lunes": 0.85,
"martes": 0.9,
"miercoles": 0.95,
"jueves": 1.0,
"viernes": 1.15,
"sabado": 1.4,
"domingo": 1.3
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000006",
"nombre": "Pan de Molde Blanco",
"demanda_base_diaria": 100.0,
"variabilidad": 0.12,
"tendencia_semanal": {
"lunes": 1.05,
"martes": 1.0,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.05,
"sabado": 1.1,
"domingo": 0.9
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000007",
"nombre": "Magdalena",
"demanda_base_diaria": 130.0,
"variabilidad": 0.20,
"tendencia_semanal": {
"lunes": 0.9,
"martes": 0.95,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.1,
"sabado": 1.25,
"domingo": 1.2
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000008",
"nombre": "Palmera",
"demanda_base_diaria": 90.0,
"variabilidad": 0.23,
"tendencia_semanal": {
"lunes": 0.85,
"martes": 0.9,
"miercoles": 0.95,
"jueves": 1.0,
"viernes": 1.15,
"sabado": 1.35,
"domingo": 1.25
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000009",
"nombre": "Ensaimada",
"demanda_base_diaria": 60.0,
"variabilidad": 0.30,
"tendencia_semanal": {
"lunes": 0.7,
"martes": 0.8,
"miercoles": 0.85,
"jueves": 0.9,
"viernes": 1.1,
"sabado": 1.6,
"domingo": 1.5
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000010",
"nombre": "Bollo de Leche",
"demanda_base_diaria": 140.0,
"variabilidad": 0.18,
"tendencia_semanal": {
"lunes": 0.95,
"martes": 1.0,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.05,
"sabado": 1.2,
"domingo": 1.15
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000011",
"nombre": "Pan de Centeno",
"demanda_base_diaria": 70.0,
"variabilidad": 0.25,
"tendencia_semanal": {
"lunes": 1.1,
"martes": 1.05,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.1,
"sabado": 0.95,
"domingo": 0.6
},
"estacionalidad": "creciente"
},
{
"id": "60000000-0000-0000-0000-000000000012",
"nombre": "Rosca de Anís",
"demanda_base_diaria": 50.0,
"variabilidad": 0.28,
"tendencia_semanal": {
"lunes": 0.8,
"martes": 0.85,
"miercoles": 0.9,
"jueves": 0.95,
"viernes": 1.1,
"sabado": 1.4,
"domingo": 1.3
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000013",
"nombre": "Panecillo",
"demanda_base_diaria": 300.0,
"variabilidad": 0.16,
"tendencia_semanal": {
"lunes": 1.05,
"martes": 1.0,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.1,
"sabado": 1.15,
"domingo": 0.8
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000014",
"nombre": "Empanada de Atún",
"demanda_base_diaria": 80.0,
"variabilidad": 0.27,
"tendencia_semanal": {
"lunes": 0.9,
"martes": 0.95,
"miercoles": 1.0,
"jueves": 1.05,
"viernes": 1.2,
"sabado": 1.15,
"domingo": 0.85
},
"estacionalidad": "estable"
},
{
"id": "60000000-0000-0000-0000-000000000015",
"nombre": "Pan Integral de Molde",
"demanda_base_diaria": 85.0,
"variabilidad": 0.17,
"tendencia_semanal": {
"lunes": 1.1,
"martes": 1.05,
"miercoles": 1.0,
"jueves": 1.0,
"viernes": 1.05,
"sabado": 1.0,
"domingo": 0.75
},
"estacionalidad": "creciente"
}
],
"multiplicador_central_bakery": 4.5,
"ubicaciones": {
"individual_bakery": "Tienda Principal",
"central_bakery": "Planta Central"
},
"algoritmos": [
{"algoritmo": "prophet", "peso": 0.50},
{"algoritmo": "arima", "peso": 0.30},
{"algoritmo": "lstm", "peso": 0.20}
],
"tiempo_procesamiento_ms": {
"min": 150,
"max": 500
},
"factores_externos": {
"temperatura": {
"min": 10.0,
"max": 28.0,
"impacto_demanda": 0.05
},
"precipitacion": {
"probabilidad_lluvia": 0.25,
"mm_promedio": 5.0,
"impacto_demanda": -0.08
},
"volumen_trafico": {
"min": 500,
"max": 2000,
"correlacion_demanda": 0.3
}
},
"precision_modelo": {
"intervalo_confianza_porcentaje": {
"inferior": 15.0,
"superior": 20.0
}
}
},
"lotes_prediccion": {
"lotes_por_tenant": 3,
"estados": ["completed", "processing", "failed"],
"distribucion_estados": {
"completed": 0.70,
"processing": 0.20,
"failed": 0.10
},
"dias_prevision_lotes": [7, 14, 30]
},
"notas": {
"descripcion": "Configuración para generación de previsiones de demanda demo",
"productos": 15,
"dias_futuro": 14,
"dias_historico": 30,
"modelos": ["prophet", "arima", "lstm"],
"fechas": "Usar offsets relativos a BASE_REFERENCE_DATE",
"idioma": "español"
}
}

View File

@@ -0,0 +1,498 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Forecasting Seeding Script for Forecasting Service
Creates demand forecasts and prediction batches for demo template tenants
This script runs as a Kubernetes init job inside the forecasting-service container.
"""
import asyncio
import uuid
import sys
import os
import json
import random
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.forecasts import Forecast, PredictionBatch
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
# Day of week mapping
DAYS_OF_WEEK = {
0: "lunes",
1: "martes",
2: "miercoles",
3: "jueves",
4: "viernes",
5: "sabado",
6: "domingo"
}
def load_forecasting_config():
"""Load forecasting configuration from JSON file"""
config_file = Path(__file__).parent / "previsiones_config_es.json"
if not config_file.exists():
raise FileNotFoundError(f"Forecasting config file not found: {config_file}")
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_datetime_from_offset(offset_days: int) -> datetime:
"""Calculate a datetime based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
def weighted_choice(choices: list) -> dict:
"""Make a weighted random choice from list of dicts with 'peso' key"""
total_weight = sum(c.get("peso", 1.0) for c in choices)
r = random.uniform(0, total_weight)
cumulative = 0
for choice in choices:
cumulative += choice.get("peso", 1.0)
if r <= cumulative:
return choice
return choices[-1]
def calculate_demand(
product: dict,
day_of_week: int,
is_weekend: bool,
weather_temp: float,
weather_precip: float,
traffic_volume: int,
config: dict
) -> float:
"""Calculate predicted demand based on various factors"""
# Base demand
base_demand = product["demanda_base_diaria"]
# Weekly trend factor
day_name = DAYS_OF_WEEK[day_of_week]
weekly_factor = product["tendencia_semanal"][day_name]
# Apply seasonality (simple growth factor for "creciente")
seasonality_factor = 1.0
if product["estacionalidad"] == "creciente":
seasonality_factor = 1.05
# Weather impact (simple model)
weather_factor = 1.0
temp_impact = config["configuracion_previsiones"]["factores_externos"]["temperatura"]["impacto_demanda"]
precip_impact = config["configuracion_previsiones"]["factores_externos"]["precipitacion"]["impacto_demanda"]
if weather_temp > 22.0:
weather_factor += temp_impact * (weather_temp - 22.0) / 10.0
if weather_precip > 0:
weather_factor += precip_impact
# Traffic correlation
traffic_correlation = config["configuracion_previsiones"]["factores_externos"]["volumen_trafico"]["correlacion_demanda"]
traffic_factor = 1.0 + (traffic_volume / 1000.0 - 1.0) * traffic_correlation
# Calculate predicted demand
predicted = base_demand * weekly_factor * seasonality_factor * weather_factor * traffic_factor
# Add randomness based on variability
variability = product["variabilidad"]
predicted = predicted * random.uniform(1.0 - variability, 1.0 + variability)
return max(0.0, predicted)
async def generate_forecasts_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
business_type: str,
config: dict
):
"""Generate forecasts for a specific tenant"""
logger.info(f"Generating forecasts for: {tenant_name}", tenant_id=str(tenant_id))
# Check if forecasts already exist
result = await db.execute(
select(Forecast).where(Forecast.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Forecasts already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "forecasts_created": 0, "batches_created": 0, "skipped": True}
forecast_config = config["configuracion_previsiones"]
batches_config = config["lotes_prediccion"]
# Get location for this business type
location = forecast_config["ubicaciones"][business_type]
# Get multiplier for central bakery
multiplier = forecast_config["multiplicador_central_bakery"] if business_type == "central_bakery" else 1.0
forecasts_created = 0
batches_created = 0
# Generate prediction batches first
num_batches = batches_config["lotes_por_tenant"]
for batch_idx in range(num_batches):
# Select batch status
status_rand = random.random()
cumulative = 0
batch_status = "completed"
for status, weight in batches_config["distribucion_estados"].items():
cumulative += weight
if status_rand <= cumulative:
batch_status = status
break
# Select forecast days
forecast_days = random.choice(batches_config["dias_prevision_lotes"])
# Create batch at different times in the past
requested_offset = -(batch_idx + 1) * 10 # Batches every 10 days in the past
requested_at = calculate_datetime_from_offset(requested_offset)
completed_at = None
processing_time = None
if batch_status == "completed":
processing_time = random.randint(5000, 25000) # 5-25 seconds
completed_at = requested_at + timedelta(milliseconds=processing_time)
batch = PredictionBatch(
id=uuid.uuid4(),
tenant_id=tenant_id,
batch_name=f"Previsión {forecast_days} días - {requested_at.strftime('%Y%m%d')}",
requested_at=requested_at,
completed_at=completed_at,
status=batch_status,
total_products=forecast_config["productos_por_tenant"],
completed_products=forecast_config["productos_por_tenant"] if batch_status == "completed" else 0,
failed_products=0 if batch_status != "failed" else random.randint(1, 3),
forecast_days=forecast_days,
business_type=business_type,
error_message="Error de conexión con servicio de clima" if batch_status == "failed" else None,
processing_time_ms=processing_time
)
db.add(batch)
batches_created += 1
await db.flush()
# Generate historical forecasts (past 30 days)
dias_historico = forecast_config["dias_historico"]
for product in forecast_config["productos_demo"]:
product_id = uuid.UUID(product["id"])
product_name = product["nombre"]
for day_offset in range(-dias_historico, 0):
forecast_date = calculate_datetime_from_offset(day_offset)
day_of_week = forecast_date.weekday()
is_weekend = day_of_week >= 5
# Generate weather data
weather_temp = random.uniform(
forecast_config["factores_externos"]["temperatura"]["min"],
forecast_config["factores_externos"]["temperatura"]["max"]
)
weather_precip = 0.0
if random.random() < forecast_config["factores_externos"]["precipitacion"]["probabilidad_lluvia"]:
weather_precip = random.uniform(0.5, forecast_config["factores_externos"]["precipitacion"]["mm_promedio"])
weather_descriptions = ["Despejado", "Parcialmente nublado", "Nublado", "Lluvia ligera", "Lluvia"]
weather_desc = random.choice(weather_descriptions)
# Traffic volume
traffic_volume = random.randint(
forecast_config["factores_externos"]["volumen_trafico"]["min"],
forecast_config["factores_externos"]["volumen_trafico"]["max"]
)
# Calculate demand
predicted_demand = calculate_demand(
product, day_of_week, is_weekend,
weather_temp, weather_precip, traffic_volume, config
)
# Apply multiplier for central bakery
predicted_demand *= multiplier
# Calculate confidence intervals
lower_pct = forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["inferior"] / 100.0
upper_pct = forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["superior"] / 100.0
confidence_lower = predicted_demand * (1.0 - lower_pct)
confidence_upper = predicted_demand * (1.0 + upper_pct)
# Select algorithm
algorithm_choice = weighted_choice(forecast_config["algoritmos"])
algorithm = algorithm_choice["algoritmo"]
# Processing time
processing_time = random.randint(
forecast_config["tiempo_procesamiento_ms"]["min"],
forecast_config["tiempo_procesamiento_ms"]["max"]
)
# Model info
model_version = f"v{random.randint(1, 3)}.{random.randint(0, 9)}"
model_id = f"{algorithm}_{business_type}_{model_version}"
# Create forecast
forecast = Forecast(
id=uuid.uuid4(),
tenant_id=tenant_id,
inventory_product_id=product_id,
product_name=product_name,
location=location,
forecast_date=forecast_date,
created_at=forecast_date - timedelta(days=1), # Created day before
predicted_demand=predicted_demand,
confidence_lower=confidence_lower,
confidence_upper=confidence_upper,
confidence_level=forecast_config["nivel_confianza"],
model_id=model_id,
model_version=model_version,
algorithm=algorithm,
business_type=business_type,
day_of_week=day_of_week,
is_holiday=False, # Could add holiday logic
is_weekend=is_weekend,
weather_temperature=weather_temp,
weather_precipitation=weather_precip,
weather_description=weather_desc,
traffic_volume=traffic_volume,
processing_time_ms=processing_time,
features_used={
"day_of_week": True,
"weather": True,
"traffic": True,
"historical_demand": True,
"seasonality": True
}
)
db.add(forecast)
forecasts_created += 1
# Generate future forecasts (next 14 days)
dias_futuro = forecast_config["dias_prevision_futuro"]
for product in forecast_config["productos_demo"]:
product_id = uuid.UUID(product["id"])
product_name = product["nombre"]
for day_offset in range(1, dias_futuro + 1):
forecast_date = calculate_datetime_from_offset(day_offset)
day_of_week = forecast_date.weekday()
is_weekend = day_of_week >= 5
# Generate weather forecast data (slightly less certain)
weather_temp = random.uniform(
forecast_config["factores_externos"]["temperatura"]["min"],
forecast_config["factores_externos"]["temperatura"]["max"]
)
weather_precip = 0.0
if random.random() < forecast_config["factores_externos"]["precipitacion"]["probabilidad_lluvia"]:
weather_precip = random.uniform(0.5, forecast_config["factores_externos"]["precipitacion"]["mm_promedio"])
weather_desc = random.choice(["Despejado", "Parcialmente nublado", "Nublado"])
traffic_volume = random.randint(
forecast_config["factores_externos"]["volumen_trafico"]["min"],
forecast_config["factores_externos"]["volumen_trafico"]["max"]
)
# Calculate demand
predicted_demand = calculate_demand(
product, day_of_week, is_weekend,
weather_temp, weather_precip, traffic_volume, config
)
predicted_demand *= multiplier
# Wider confidence intervals for future predictions
lower_pct = (forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["inferior"] + 5.0) / 100.0
upper_pct = (forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["superior"] + 5.0) / 100.0
confidence_lower = predicted_demand * (1.0 - lower_pct)
confidence_upper = predicted_demand * (1.0 + upper_pct)
algorithm_choice = weighted_choice(forecast_config["algoritmos"])
algorithm = algorithm_choice["algoritmo"]
processing_time = random.randint(
forecast_config["tiempo_procesamiento_ms"]["min"],
forecast_config["tiempo_procesamiento_ms"]["max"]
)
model_version = f"v{random.randint(1, 3)}.{random.randint(0, 9)}"
model_id = f"{algorithm}_{business_type}_{model_version}"
forecast = Forecast(
id=uuid.uuid4(),
tenant_id=tenant_id,
inventory_product_id=product_id,
product_name=product_name,
location=location,
forecast_date=forecast_date,
created_at=BASE_REFERENCE_DATE, # Created today
predicted_demand=predicted_demand,
confidence_lower=confidence_lower,
confidence_upper=confidence_upper,
confidence_level=forecast_config["nivel_confianza"],
model_id=model_id,
model_version=model_version,
algorithm=algorithm,
business_type=business_type,
day_of_week=day_of_week,
is_holiday=False,
is_weekend=is_weekend,
weather_temperature=weather_temp,
weather_precipitation=weather_precip,
weather_description=weather_desc,
traffic_volume=traffic_volume,
processing_time_ms=processing_time,
features_used={
"day_of_week": True,
"weather": True,
"traffic": True,
"historical_demand": True,
"seasonality": True
}
)
db.add(forecast)
forecasts_created += 1
await db.commit()
logger.info(f"Successfully created {forecasts_created} forecasts and {batches_created} batches for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"forecasts_created": forecasts_created,
"batches_created": batches_created,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with forecasting data"""
logger.info("Starting demo forecasting seed process")
# Load configuration
config = load_forecasting_config()
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await generate_forecasts_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
"individual_bakery",
config
)
results.append(result_san_pablo)
# Seed La Espiga (Central Bakery)
result_la_espiga = await generate_forecasts_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
"central_bakery",
config
)
results.append(result_la_espiga)
total_forecasts = sum(r["forecasts_created"] for r in results)
total_batches = sum(r["batches_created"] for r in results)
return {
"results": results,
"total_forecasts_created": total_forecasts,
"total_batches_created": total_batches,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("FORECASTING_DATABASE_URL")
if not database_url:
logger.error("FORECASTING_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Forecasting seed completed successfully!",
total_forecasts=result["total_forecasts_created"],
total_batches=result["total_batches_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO FORECASTING SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
forecasts = tenant_result["forecasts_created"]
batches = tenant_result["batches_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {forecasts} forecasts, {batches} batches"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Forecasts: {result['total_forecasts_created']}")
print(f"Total Batches: {result['total_batches_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Forecasting seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -1,6 +1,6 @@
"""
Internal Demo Cloning API for Inventory Service
Service-to-service endpoint for cloning inventory data
Service-to-service endpoint for cloning inventory data with date adjustment
"""
from fastapi import APIRouter, Depends, HTTPException, Header
@@ -11,9 +11,15 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
import os
import sys
from pathlib import Path
# Add shared path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from app.core.database import get_db
from app.models.inventory import Ingredient
from app.models.inventory import Ingredient, Stock
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
@@ -48,7 +54,8 @@ async def clone_demo_data(
Clones:
- Ingredients from template tenant
- (Future: recipes, stock data, etc.)
- Stock batches with date-adjusted expiration dates
- Generates inventory alerts based on stock status
Args:
base_tenant_id: Template tenant UUID to clone from
@@ -60,13 +67,15 @@ async def clone_demo_data(
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
session_created_at = datetime.now(timezone.utc)
logger.info(
"Starting inventory data cloning",
"Starting inventory data cloning with date adjustment",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id
session_id=session_id,
session_created_at=session_created_at.isoformat()
)
try:
@@ -77,9 +86,13 @@ async def clone_demo_data(
# Track cloning statistics
stats = {
"ingredients": 0,
# Add other entities here in future
"stock_batches": 0,
"alerts_generated": 0
}
# Mapping from base ingredient ID to virtual ingredient ID
ingredient_id_mapping = {}
# Clone Ingredients
result = await db.execute(
select(Ingredient).where(Ingredient.tenant_id == base_uuid)
@@ -94,8 +107,9 @@ async def clone_demo_data(
for ingredient in base_ingredients:
# Create new ingredient with same attributes but new ID and tenant
new_ingredient_id = uuid.uuid4()
new_ingredient = Ingredient(
id=uuid.uuid4(),
id=new_ingredient_id,
tenant_id=virtual_uuid,
name=ingredient.name,
sku=ingredient.sku,
@@ -116,21 +130,123 @@ async def clone_demo_data(
reorder_quantity=ingredient.reorder_quantity,
max_stock_level=ingredient.max_stock_level,
shelf_life_days=ingredient.shelf_life_days,
display_life_hours=ingredient.display_life_hours,
best_before_hours=ingredient.best_before_hours,
storage_instructions=ingredient.storage_instructions,
is_perishable=ingredient.is_perishable,
is_active=ingredient.is_active,
allergen_info=ingredient.allergen_info
allergen_info=ingredient.allergen_info,
nutritional_info=ingredient.nutritional_info
)
db.add(new_ingredient)
stats["ingredients"] += 1
# Store mapping for stock cloning
ingredient_id_mapping[ingredient.id] = new_ingredient_id
await db.flush() # Ensure ingredients are persisted before stock
# Clone Stock batches with date adjustment
result = await db.execute(
select(Stock).where(Stock.tenant_id == base_uuid)
)
base_stocks = result.scalars().all()
logger.info(
"Found stock batches to clone",
count=len(base_stocks),
base_tenant=str(base_uuid)
)
for stock in base_stocks:
# Map ingredient ID
new_ingredient_id = ingredient_id_mapping.get(stock.ingredient_id)
if not new_ingredient_id:
logger.warning(
"Stock references non-existent ingredient, skipping",
stock_id=str(stock.id),
ingredient_id=str(stock.ingredient_id)
)
continue
# Adjust dates relative to session creation
adjusted_expiration = adjust_date_for_demo(
stock.expiration_date,
session_created_at,
BASE_REFERENCE_DATE
)
adjusted_received = adjust_date_for_demo(
stock.received_date,
session_created_at,
BASE_REFERENCE_DATE
)
adjusted_best_before = adjust_date_for_demo(
stock.best_before_date,
session_created_at,
BASE_REFERENCE_DATE
)
adjusted_created = adjust_date_for_demo(
stock.created_at,
session_created_at,
BASE_REFERENCE_DATE
) or session_created_at
# Create new stock batch
new_stock = Stock(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
ingredient_id=new_ingredient_id,
supplier_id=stock.supplier_id,
batch_number=stock.batch_number,
lot_number=stock.lot_number,
supplier_batch_ref=stock.supplier_batch_ref,
production_stage=stock.production_stage,
current_quantity=stock.current_quantity,
reserved_quantity=stock.reserved_quantity,
available_quantity=stock.available_quantity,
received_date=adjusted_received,
expiration_date=adjusted_expiration,
best_before_date=adjusted_best_before,
unit_cost=stock.unit_cost,
total_cost=stock.total_cost,
storage_location=stock.storage_location,
warehouse_zone=stock.warehouse_zone,
shelf_position=stock.shelf_position,
requires_refrigeration=stock.requires_refrigeration,
requires_freezing=stock.requires_freezing,
storage_temperature_min=stock.storage_temperature_min,
storage_temperature_max=stock.storage_temperature_max,
storage_humidity_max=stock.storage_humidity_max,
shelf_life_days=stock.shelf_life_days,
storage_instructions=stock.storage_instructions,
is_available=stock.is_available,
is_expired=stock.is_expired,
quality_status=stock.quality_status,
created_at=adjusted_created,
updated_at=session_created_at
)
db.add(new_stock)
stats["stock_batches"] += 1
# Commit all changes
await db.commit()
# Generate inventory alerts
try:
from shared.utils.alert_generator import generate_inventory_alerts
alerts_count = await generate_inventory_alerts(db, virtual_uuid, session_created_at)
stats["alerts_generated"] = alerts_count
await db.commit() # Commit alerts
logger.info(f"Generated {alerts_count} inventory alerts", virtual_tenant_id=virtual_tenant_id)
except Exception as e:
logger.warning(f"Failed to generate alerts: {str(e)}", exc_info=True)
stats["alerts_generated"] = 0
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Inventory data cloning completed",
"Inventory data cloning completed with date adjustment",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,

View File

@@ -303,6 +303,7 @@ class Stock(Base):
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'ingredient_id': str(self.ingredient_id),
'supplier_id': str(self.supplier_id) if self.supplier_id else None,
'batch_number': self.batch_number,
'lot_number': self.lot_number,
'supplier_batch_ref': self.supplier_batch_ref,

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Inventory Seeding Script for Inventory Service
Creates realistic Spanish ingredients for demo template tenants

View File

@@ -0,0 +1,423 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Stock Seeding Script for Inventory Service
Creates realistic stock batches with varied expiration dates for demo template tenants
This script runs as a Kubernetes init job inside the inventory-service container.
It populates the template tenants with stock data that will demonstrate inventory alerts.
Usage:
python /app/scripts/demo/seed_demo_stock.py
Environment Variables Required:
INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database
DEMO_MODE - Set to 'production' for production seeding
LOG_LEVEL - Logging level (default: INFO)
"""
import asyncio
import uuid
import sys
import os
import random
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.inventory import Ingredient, Stock
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
# Base reference date for demo data (all relative dates calculated from this)
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
# Load configuration from JSON
def load_stock_config():
"""Load stock configuration from JSON file"""
config_file = Path(__file__).parent / "stock_lotes_es.json"
if not config_file.exists():
raise FileNotFoundError(f"Stock configuration file not found: {config_file}")
logger.info("Loading stock configuration", file=str(config_file))
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
# Load configuration
STOCK_CONFIG = load_stock_config()
STORAGE_LOCATIONS = STOCK_CONFIG["stock_distribution"]["storage_locations"]
WAREHOUSE_ZONES = STOCK_CONFIG["stock_distribution"]["warehouse_zones"]
QUALITY_STATUSES = ["good", "damaged", "expired", "quarantined"]
def generate_batch_number(tenant_id: uuid.UUID, ingredient_sku: str, batch_index: int) -> str:
"""Generate a realistic batch number"""
tenant_short = str(tenant_id).split('-')[0].upper()[:4]
return f"LOTE-{tenant_short}-{ingredient_sku}-{batch_index:03d}"
def calculate_expiration_distribution():
"""
Calculate expiration date distribution for realistic demo alerts
Distribution:
- 5% expired (already past expiration)
- 10% expiring soon (< 3 days)
- 15% moderate alert (3-7 days)
- 30% short-term (7-30 days)
- 40% long-term (30-90 days)
"""
rand = random.random()
if rand < 0.05: # 5% expired
return random.randint(-10, -1)
elif rand < 0.15: # 10% expiring soon
return random.randint(1, 3)
elif rand < 0.30: # 15% moderate alert
return random.randint(3, 7)
elif rand < 0.60: # 30% short-term
return random.randint(7, 30)
else: # 40% long-term
return random.randint(30, 90)
async def create_stock_batches_for_ingredient(
db: AsyncSession,
tenant_id: uuid.UUID,
ingredient: Ingredient,
base_date: datetime
) -> list:
"""
Create 3-5 stock batches for a single ingredient with varied properties
Args:
db: Database session
tenant_id: Tenant UUID
ingredient: Ingredient model instance
base_date: Base reference date for calculating expiration dates
Returns:
List of created Stock instances
"""
stocks = []
num_batches = random.randint(3, 5)
for i in range(num_batches):
# Calculate expiration days offset
days_offset = calculate_expiration_distribution()
expiration_date = base_date + timedelta(days=days_offset)
received_date = expiration_date - timedelta(days=ingredient.shelf_life_days or 30)
# Determine if expired
is_expired = days_offset < 0
# Quality status based on expiration
if is_expired:
quality_status = random.choice(["expired", "quarantined"])
is_available = False
elif days_offset < 3:
quality_status = random.choice(["good", "good", "good", "damaged"]) # Mostly good
is_available = quality_status == "good"
else:
quality_status = "good"
is_available = True
# Generate quantities
if ingredient.unit_of_measure.value in ['kg', 'l']:
current_quantity = round(random.uniform(5.0, 50.0), 2)
reserved_quantity = round(random.uniform(0.0, current_quantity * 0.3), 2) if is_available else 0.0
elif ingredient.unit_of_measure.value in ['g', 'ml']:
current_quantity = round(random.uniform(500.0, 5000.0), 2)
reserved_quantity = round(random.uniform(0.0, current_quantity * 0.3), 2) if is_available else 0.0
else: # units, pieces, etc.
current_quantity = float(random.randint(10, 200))
reserved_quantity = float(random.randint(0, int(current_quantity * 0.3))) if is_available else 0.0
available_quantity = current_quantity - reserved_quantity
# Calculate costs with variation
base_cost = float(ingredient.average_cost or Decimal("1.0"))
unit_cost = Decimal(str(round(base_cost * random.uniform(0.9, 1.1), 2)))
total_cost = unit_cost * Decimal(str(current_quantity))
# Determine storage requirements
requires_refrigeration = ingredient.is_perishable and ingredient.ingredient_category.value in ['dairy', 'eggs']
requires_freezing = False # Could be enhanced based on ingredient type
stock = Stock(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=ingredient.id,
supplier_id=None, # Could link to suppliers in future
batch_number=generate_batch_number(tenant_id, ingredient.sku or f"SKU{i}", i + 1),
lot_number=f"LOT-{random.randint(1000, 9999)}",
supplier_batch_ref=f"SUP-{random.randint(10000, 99999)}",
production_stage='raw_ingredient',
current_quantity=current_quantity,
reserved_quantity=reserved_quantity,
available_quantity=available_quantity,
received_date=received_date,
expiration_date=expiration_date,
best_before_date=expiration_date - timedelta(days=2) if ingredient.is_perishable else None,
unit_cost=unit_cost,
total_cost=total_cost,
storage_location=random.choice(STORAGE_LOCATIONS),
warehouse_zone=random.choice(["A", "B", "C", "D"]),
shelf_position=f"{random.randint(1, 20)}-{random.choice(['A', 'B', 'C'])}",
requires_refrigeration=requires_refrigeration,
requires_freezing=requires_freezing,
storage_temperature_min=2.0 if requires_refrigeration else None,
storage_temperature_max=8.0 if requires_refrigeration else None,
shelf_life_days=ingredient.shelf_life_days,
is_available=is_available,
is_expired=is_expired,
quality_status=quality_status,
created_at=received_date,
updated_at=datetime.now(timezone.utc)
)
stocks.append(stock)
return stocks
async def seed_stock_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
base_date: datetime
) -> dict:
"""
Seed stock batches for all ingredients of a specific tenant
Args:
db: Database session
tenant_id: UUID of the tenant
tenant_name: Name of the tenant (for logging)
base_date: Base reference date for expiration calculations
Returns:
Dict with seeding statistics
"""
logger.info("" * 80)
logger.info(f"Seeding stock for: {tenant_name}")
logger.info(f"Tenant ID: {tenant_id}")
logger.info(f"Base Reference Date: {base_date.isoformat()}")
logger.info("" * 80)
# Get all ingredients for this tenant
result = await db.execute(
select(Ingredient).where(
Ingredient.tenant_id == tenant_id,
Ingredient.is_active == True
)
)
ingredients = result.scalars().all()
if not ingredients:
logger.warning(f"No ingredients found for tenant {tenant_id}")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"stock_created": 0,
"ingredients_processed": 0
}
total_stock_created = 0
expired_count = 0
expiring_soon_count = 0
for ingredient in ingredients:
stocks = await create_stock_batches_for_ingredient(db, tenant_id, ingredient, base_date)
for stock in stocks:
db.add(stock)
total_stock_created += 1
if stock.is_expired:
expired_count += 1
elif stock.expiration_date:
days_until_expiry = (stock.expiration_date - base_date).days
if days_until_expiry <= 3:
expiring_soon_count += 1
logger.debug(f" ✅ Created {len(stocks)} stock batches for: {ingredient.name}")
# Commit all changes
await db.commit()
logger.info(f" 📊 Total Stock Batches Created: {total_stock_created}")
logger.info(f" ⚠️ Expired Batches: {expired_count}")
logger.info(f" 🔔 Expiring Soon (≤3 days): {expiring_soon_count}")
logger.info("")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"stock_created": total_stock_created,
"ingredients_processed": len(ingredients),
"expired_count": expired_count,
"expiring_soon_count": expiring_soon_count
}
async def seed_stock(db: AsyncSession):
"""
Seed stock for all demo template tenants
Args:
db: Database session
Returns:
Dict with overall seeding statistics
"""
logger.info("=" * 80)
logger.info("📦 Starting Demo Stock Seeding")
logger.info("=" * 80)
results = []
# Seed for San Pablo (Traditional Bakery)
logger.info("")
result_san_pablo = await seed_stock_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Traditional)",
BASE_REFERENCE_DATE
)
results.append(result_san_pablo)
# Seed for La Espiga (Central Workshop)
result_la_espiga = await seed_stock_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Workshop)",
BASE_REFERENCE_DATE
)
results.append(result_la_espiga)
# Calculate totals
total_stock = sum(r["stock_created"] for r in results)
total_expired = sum(r["expired_count"] for r in results)
total_expiring_soon = sum(r["expiring_soon_count"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Stock Seeding Completed")
logger.info("=" * 80)
return {
"service": "inventory",
"tenants_seeded": len(results),
"total_stock_created": total_stock,
"total_expired": total_expired,
"total_expiring_soon": total_expiring_soon,
"results": results
}
async def main():
"""Main execution function"""
logger.info("Demo Stock Seeding Script Starting")
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Convert to async URL if needed
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to inventory database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with async_session() as session:
result = await seed_stock(session)
logger.info("")
logger.info("📊 Seeding Summary:")
logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}")
logger.info(f" ✅ Total stock batches: {result['total_stock_created']}")
logger.info(f" ⚠️ Expired batches: {result['total_expired']}")
logger.info(f" 🔔 Expiring soon (≤3 days): {result['total_expiring_soon']}")
logger.info("")
# Print per-tenant details
for tenant_result in result['results']:
logger.info(
f" {tenant_result['tenant_name']}: "
f"{tenant_result['stock_created']} batches "
f"({tenant_result['expired_count']} expired, "
f"{tenant_result['expiring_soon_count']} expiring soon)"
)
logger.info("")
logger.info("🎉 Success! Stock data ready for cloning and alert generation.")
logger.info("")
logger.info("Next steps:")
logger.info(" 1. Update inventory clone endpoint to include stock")
logger.info(" 2. Implement date offset during cloning")
logger.info(" 3. Generate expiration alerts during clone")
logger.info(" 4. Test demo session creation")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Stock Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -0,0 +1,49 @@
{
"stock_distribution": {
"batches_per_ingredient": {
"min": 3,
"max": 5
},
"expiration_distribution": {
"expired": 0.05,
"expiring_soon_3days": 0.10,
"moderate_alert_7days": 0.15,
"short_term_30days": 0.30,
"long_term_90days": 0.40
},
"quality_status_weights": {
"good": 0.75,
"damaged": 0.10,
"expired": 0.10,
"quarantined": 0.05
},
"storage_locations": [
"Almacén Principal",
"Cámara Fría",
"Congelador",
"Zona Seca",
"Estantería A",
"Estantería B",
"Zona Refrigerada",
"Depósito Exterior"
],
"warehouse_zones": ["A", "B", "C", "D"]
},
"quantity_ranges": {
"kg": {"min": 5.0, "max": 50.0},
"l": {"min": 5.0, "max": 50.0},
"g": {"min": 500.0, "max": 5000.0},
"ml": {"min": 500.0, "max": 5000.0},
"units": {"min": 10, "max": 200},
"pcs": {"min": 10, "max": 200},
"pkg": {"min": 5, "max": 50},
"bags": {"min": 5, "max": 30},
"boxes": {"min": 5, "max": 25}
},
"cost_variation": {
"min_multiplier": 0.90,
"max_multiplier": 1.10
},
"refrigeration_categories": ["dairy", "eggs"],
"freezing_categories": []
}

View File

@@ -17,6 +17,8 @@ from app.core.database import get_db
from app.models.order import CustomerOrder, OrderItem
from app.models.procurement import ProcurementPlan, ProcurementRequirement
from app.models.customer import Customer
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
from shared.utils.alert_generator import generate_order_alerts
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
@@ -43,6 +45,7 @@ async def clone_demo_data(
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
@@ -66,12 +69,22 @@ async def clone_demo_data(
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting orders data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id
session_id=session_id,
session_created_at=session_created_at
)
try:
@@ -85,7 +98,8 @@ async def clone_demo_data(
"customer_orders": 0,
"order_line_items": 0,
"procurement_plans": 0,
"procurement_requirements": 0
"procurement_requirements": 0,
"alerts_generated": 0
}
# Customer ID mapping (old -> new)
@@ -110,23 +124,36 @@ async def clone_demo_data(
new_customer = Customer(
id=new_customer_id,
tenant_id=virtual_uuid,
customer_name=customer.customer_name,
customer_type=customer.customer_type,
customer_code=customer.customer_code,
name=customer.name,
business_name=customer.business_name,
contact_person=customer.contact_person,
customer_type=customer.customer_type,
tax_id=customer.tax_id,
email=customer.email,
phone=customer.phone,
address=customer.address,
tax_id=customer.tax_id,
credit_limit=customer.credit_limit,
payment_terms=customer.payment_terms,
discount_percentage=customer.discount_percentage,
address_line1=customer.address_line1,
address_line2=customer.address_line2,
city=customer.city,
state=customer.state,
postal_code=customer.postal_code,
country=customer.country,
business_license=customer.business_license,
is_active=customer.is_active,
notes=customer.notes,
tags=customer.tags,
metadata_=customer.metadata_,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
preferred_delivery_method=customer.preferred_delivery_method,
payment_terms=customer.payment_terms,
credit_limit=customer.credit_limit,
discount_percentage=customer.discount_percentage,
customer_segment=customer.customer_segment,
priority_level=customer.priority_level,
special_instructions=customer.special_instructions,
delivery_preferences=customer.delivery_preferences,
product_preferences=customer.product_preferences,
total_orders=customer.total_orders,
total_spent=customer.total_spent,
average_order_value=customer.average_order_value,
last_order_date=customer.last_order_date,
created_at=session_time,
updated_at=session_time
)
db.add(new_customer)
stats["customers"] += 1
@@ -143,20 +170,32 @@ async def clone_demo_data(
base_tenant=str(base_uuid)
)
# Calculate date offset
if base_orders:
max_date = max(order.order_date for order in base_orders)
today = datetime.now(timezone.utc)
date_offset = today - max_date
else:
date_offset = timedelta(days=0)
order_id_map = {}
for order in base_orders:
new_order_id = uuid.uuid4()
order_id_map[order.id] = new_order_id
# Adjust dates using demo_dates utility
adjusted_order_date = adjust_date_for_demo(
order.order_date, session_time, BASE_REFERENCE_DATE
)
adjusted_requested_delivery = adjust_date_for_demo(
order.requested_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_confirmed_delivery = adjust_date_for_demo(
order.confirmed_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_actual_delivery = adjust_date_for_demo(
order.actual_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_window_start = adjust_date_for_demo(
order.delivery_window_start, session_time, BASE_REFERENCE_DATE
)
adjusted_window_end = adjust_date_for_demo(
order.delivery_window_end, session_time, BASE_REFERENCE_DATE
)
new_order = CustomerOrder(
id=new_order_id,
tenant_id=virtual_uuid,
@@ -165,28 +204,30 @@ async def clone_demo_data(
status=order.status,
order_type=order.order_type,
priority=order.priority,
order_date=order.order_date + date_offset if order.order_date else None,
requested_delivery_date=order.requested_delivery_date + date_offset if order.requested_delivery_date else None,
confirmed_delivery_date=order.confirmed_delivery_date + date_offset if order.confirmed_delivery_date else None,
actual_delivery_date=order.actual_delivery_date + date_offset if order.actual_delivery_date else None,
order_date=adjusted_order_date,
requested_delivery_date=adjusted_requested_delivery,
confirmed_delivery_date=adjusted_confirmed_delivery,
actual_delivery_date=adjusted_actual_delivery,
delivery_method=order.delivery_method,
delivery_address=order.delivery_address,
delivery_instructions=order.delivery_instructions,
delivery_window_start=order.delivery_window_start + date_offset if order.delivery_window_start else None,
delivery_window_end=order.delivery_window_end + date_offset if order.delivery_window_end else None,
delivery_window_start=adjusted_window_start,
delivery_window_end=adjusted_window_end,
subtotal=order.subtotal,
tax_amount=order.tax_amount,
discount_amount=order.discount_amount,
discount_percentage=order.discount_percentage,
delivery_fee=order.delivery_fee,
total_amount=order.total_amount,
payment_status=order.payment_status,
payment_method=order.payment_method,
notes=order.notes,
internal_notes=order.internal_notes,
tags=order.tags,
metadata_=order.metadata_,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
payment_terms=order.payment_terms,
payment_due_date=order.payment_due_date,
special_instructions=order.special_instructions,
order_source=order.order_source,
sales_channel=order.sales_channel,
created_at=session_time,
updated_at=session_time
)
db.add(new_order)
stats["customer_orders"] += 1
@@ -202,16 +243,15 @@ async def clone_demo_data(
new_item = OrderItem(
id=uuid.uuid4(),
order_id=new_order_id,
product_id=item.product_id, # Keep product reference
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
quantity=item.quantity,
unit_of_measure=item.unit_of_measure,
unit_price=item.unit_price,
subtotal=item.subtotal,
discount_amount=item.discount_amount,
tax_amount=item.tax_amount,
total_amount=item.total_amount,
notes=item.notes,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
line_discount=item.line_discount,
line_total=item.line_total,
status=item.status
)
db.add(new_item)
stats["order_line_items"] += 1
@@ -247,9 +287,9 @@ async def clone_demo_data(
id=new_plan_id,
tenant_id=virtual_uuid,
plan_number=f"PROC-{uuid.uuid4().hex[:8].upper()}",
plan_date=plan.plan_date + plan_date_offset.days if plan.plan_date else None,
plan_period_start=plan.plan_period_start + plan_date_offset.days if plan.plan_period_start else None,
plan_period_end=plan.plan_period_end + plan_date_offset.days if plan.plan_period_end else None,
plan_date=plan.plan_date + plan_date_offset if plan.plan_date else None,
plan_period_start=plan.plan_period_start + plan_date_offset if plan.plan_period_start else None,
plan_period_end=plan.plan_period_end + plan_date_offset if plan.plan_period_end else None,
planning_horizon_days=plan.planning_horizon_days,
status=plan.status,
plan_type=plan.plan_type,
@@ -260,7 +300,6 @@ async def clone_demo_data(
total_estimated_cost=plan.total_estimated_cost,
total_approved_cost=plan.total_approved_cost,
cost_variance=plan.cost_variance,
notes=plan.notes,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@@ -270,32 +309,91 @@ async def clone_demo_data(
# Clone Procurement Requirements
for old_plan_id, new_plan_id in plan_id_map.items():
result = await db.execute(
select(ProcurementRequirement).where(ProcurementRequirement.procurement_plan_id == old_plan_id)
select(ProcurementRequirement).where(ProcurementRequirement.plan_id == old_plan_id)
)
requirements = result.scalars().all()
for req in requirements:
new_req = ProcurementRequirement(
id=uuid.uuid4(),
procurement_plan_id=new_plan_id,
ingredient_id=req.ingredient_id, # Keep ingredient reference
plan_id=new_plan_id,
requirement_number=req.requirement_number,
product_id=req.product_id,
product_name=req.product_name,
product_sku=req.product_sku,
product_category=req.product_category,
product_type=req.product_type,
required_quantity=req.required_quantity,
unit_of_measure=req.unit_of_measure,
safety_stock_quantity=req.safety_stock_quantity,
total_quantity_needed=req.total_quantity_needed,
current_stock_level=req.current_stock_level,
reserved_stock=req.reserved_stock,
available_stock=req.available_stock,
net_requirement=req.net_requirement,
order_demand=req.order_demand,
production_demand=req.production_demand,
forecast_demand=req.forecast_demand,
buffer_demand=req.buffer_demand,
preferred_supplier_id=req.preferred_supplier_id,
backup_supplier_id=req.backup_supplier_id,
supplier_name=req.supplier_name,
supplier_lead_time_days=req.supplier_lead_time_days,
minimum_order_quantity=req.minimum_order_quantity,
estimated_unit_cost=req.estimated_unit_cost,
estimated_total_cost=req.estimated_total_cost,
required_by_date=req.required_by_date + plan_date_offset.days if req.required_by_date else None,
last_purchase_cost=req.last_purchase_cost,
cost_variance=req.cost_variance,
required_by_date=req.required_by_date + plan_date_offset if req.required_by_date else None,
lead_time_buffer_days=req.lead_time_buffer_days,
suggested_order_date=req.suggested_order_date + plan_date_offset if req.suggested_order_date else None,
latest_order_date=req.latest_order_date + plan_date_offset if req.latest_order_date else None,
quality_specifications=req.quality_specifications,
special_requirements=req.special_requirements,
storage_requirements=req.storage_requirements,
shelf_life_days=req.shelf_life_days,
status=req.status,
priority=req.priority,
source=req.source,
notes=req.notes,
risk_level=req.risk_level,
purchase_order_id=req.purchase_order_id,
purchase_order_number=req.purchase_order_number,
ordered_quantity=req.ordered_quantity,
ordered_at=req.ordered_at,
expected_delivery_date=req.expected_delivery_date + plan_date_offset if req.expected_delivery_date else None,
actual_delivery_date=req.actual_delivery_date + plan_date_offset if req.actual_delivery_date else None,
received_quantity=req.received_quantity,
delivery_status=req.delivery_status,
fulfillment_rate=req.fulfillment_rate,
on_time_delivery=req.on_time_delivery,
quality_rating=req.quality_rating,
source_orders=req.source_orders,
source_production_batches=req.source_production_batches,
demand_analysis=req.demand_analysis,
approved_quantity=req.approved_quantity,
approved_cost=req.approved_cost,
approved_at=req.approved_at,
approved_by=req.approved_by,
procurement_notes=req.procurement_notes,
supplier_communication=req.supplier_communication,
requirement_metadata=req.requirement_metadata,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(new_req)
stats["procurement_requirements"] += 1
# Commit all changes
# Commit cloned data first
await db.commit()
# Generate order alerts (urgent, delayed, upcoming deliveries)
try:
alerts_count = await generate_order_alerts(db, virtual_uuid, session_time)
stats["alerts_generated"] += alerts_count
await db.commit()
logger.info(f"Generated {alerts_count} order alerts")
except Exception as alert_error:
logger.warning(f"Alert generation failed: {alert_error}", exc_info=True)
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)

View File

@@ -18,11 +18,13 @@ class DeliveryMethod(enum.Enum):
"""Order delivery methods"""
DELIVERY = "delivery"
PICKUP = "pickup"
STANDARD = "standard" # Standard delivery method
class PaymentTerms(enum.Enum):
"""Payment terms for customers and orders"""
IMMEDIATE = "immediate"
NET_15 = "net_15"
NET_30 = "net_30"
NET_60 = "net_60"
@@ -31,6 +33,8 @@ class PaymentMethod(enum.Enum):
"""Payment methods for orders"""
CASH = "cash"
CARD = "card"
CREDIT_CARD = "credit_card" # Credit card payment
CHECK = "check" # Bank check/cheque payment
BANK_TRANSFER = "bank_transfer"
ACCOUNT = "account"

View File

@@ -1,229 +1,281 @@
{
"clientes": [
{
"customer_name": "Cafetería El Rincón",
"customer_type": "retail",
"business_name": "El Rincón Cafetería S.L.",
"contact_person": "Ana Rodríguez García",
"email": "pedidos@cafeteriaelrincon.es",
"phone": "+34 963 456 789",
"address": "Calle Mayor, 78, 46001 Valencia",
"payment_terms": "net_7",
"discount_percentage": 15.0,
"id": "20000000-0000-0000-0000-000000000001",
"customer_code": "CLI-001",
"name": "Hotel Plaza Mayor",
"business_name": "Hotel Plaza Mayor S.L.",
"customer_type": "business",
"email": "compras@hotelplazamayor.es",
"phone": "+34 91 234 5601",
"address_line1": "Plaza Mayor 15",
"city": "Madrid",
"postal_code": "28012",
"country": "España",
"customer_segment": "wholesale",
"priority_level": "high",
"payment_terms": "net_30",
"credit_limit": 5000.00,
"discount_percentage": 10.00,
"preferred_delivery_method": "delivery",
"special_instructions": "Entrega antes de las 6:00 AM. Llamar al llegar."
},
{
"id": "20000000-0000-0000-0000-000000000002",
"customer_code": "CLI-002",
"name": "Restaurante El Mesón",
"business_name": "Restaurante El Mesón S.L.",
"customer_type": "business",
"email": "pedidos@elmeson.es",
"phone": "+34 91 345 6702",
"address_line1": "Calle Mayor 45",
"city": "Madrid",
"postal_code": "28013",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "net_15",
"credit_limit": 2000.00,
"is_active": true,
"notes": "Cliente diario. Entrega preferente 6:00-7:00 AM.",
"tags": ["hosteleria", "cafeteria", "diario"]
"discount_percentage": 5.00,
"preferred_delivery_method": "delivery",
"special_instructions": "Dejar pedido en la puerta de servicio."
},
{
"customer_name": "Supermercado La Bodega",
"customer_type": "wholesale",
"business_name": "Supermercados La Bodega S.L.",
"contact_person": "Carlos Jiménez Moreno",
"email": "compras@superlabodega.com",
"phone": "+34 965 789 012",
"address": "Avenida del Mediterráneo, 156, 03500 Benidorm, Alicante",
"payment_terms": "net_30",
"discount_percentage": 20.0,
"credit_limit": 5000.00,
"is_active": true,
"notes": "Entrega 3 veces/semana: Lunes, Miércoles, Viernes. Horario: 5:00-6:00 AM.",
"tags": ["retail", "supermercado", "mayorista"]
},
{
"customer_name": "Restaurante Casa Pepe",
"customer_type": "retail",
"business_name": "Casa Pepe Restauración S.C.",
"contact_person": "José Luis Pérez",
"email": "pedidos@casapepe.es",
"phone": "+34 961 234 567",
"address": "Plaza del Mercado, 12, 46003 Valencia",
"payment_terms": "net_15",
"discount_percentage": 12.0,
"credit_limit": 1500.00,
"is_active": true,
"notes": "Especializado en cocina mediterránea. Requiere panes especiales.",
"tags": ["hosteleria", "restaurante"]
},
{
"customer_name": "Hotel Playa Sol",
"customer_type": "wholesale",
"business_name": "Hoteles Costa Blanca S.A.",
"contact_person": "María Carmen López",
"email": "compras@hotelplayasol.com",
"phone": "+34 965 123 456",
"address": "Paseo Marítimo, 234, 03501 Benidorm, Alicante",
"payment_terms": "net_30",
"discount_percentage": 18.0,
"credit_limit": 8000.00,
"is_active": true,
"notes": "Hotel 4 estrellas. Pedidos grandes para desayuno buffet. Volumen estable todo el año.",
"tags": ["hosteleria", "hotel", "mayorista", "alto_volumen"]
},
{
"customer_name": "Bar Los Naranjos",
"customer_type": "retail",
"business_name": "Los Naranjos C.B.",
"contact_person": "Francisco Martínez",
"email": "losnaranjos@gmail.com",
"phone": "+34 963 789 012",
"address": "Calle de la Paz, 45, 46002 Valencia",
"payment_terms": "net_7",
"discount_percentage": 10.0,
"credit_limit": 800.00,
"is_active": true,
"notes": "Bar de barrio. Pedidos pequeños diarios.",
"tags": ["hosteleria", "bar", "pequeño"]
},
{
"customer_name": "Panadería La Tahona",
"customer_type": "retail",
"business_name": "Panadería La Tahona",
"contact_person": "Isabel García Ruiz",
"email": "latahona@hotmail.com",
"phone": "+34 962 345 678",
"address": "Avenida de los Naranjos, 89, 46470 Albal, Valencia",
"payment_terms": "net_15",
"discount_percentage": 25.0,
"credit_limit": 3000.00,
"is_active": true,
"notes": "Panadería que no tiene obrador propio. Compra productos semipreparados.",
"tags": ["panaderia", "b2b"]
},
{
"customer_name": "Catering García e Hijos",
"customer_type": "wholesale",
"business_name": "García Catering S.L.",
"contact_person": "Miguel García Sánchez",
"email": "pedidos@cateringgarcia.es",
"phone": "+34 963 567 890",
"address": "Polígono Industrial Vara de Quart, Nave 34, 46014 Valencia",
"payment_terms": "net_30",
"discount_percentage": 22.0,
"credit_limit": 6000.00,
"is_active": true,
"notes": "Catering para eventos. Pedidos variables según calendario de eventos.",
"tags": ["catering", "eventos", "variable"]
},
{
"customer_name": "Residencia Tercera Edad San Antonio",
"customer_type": "wholesale",
"business_name": "Residencia San Antonio",
"contact_person": "Lucía Fernández",
"email": "compras@residenciasanantonio.es",
"phone": "+34 961 890 123",
"address": "Calle San Antonio, 156, 46013 Valencia",
"payment_terms": "net_30",
"discount_percentage": 15.0,
"credit_limit": 4000.00,
"is_active": true,
"notes": "Residencia con 120 plazas. Pedidos regulares y previsibles.",
"tags": ["institucional", "residencia", "estable"]
},
{
"customer_name": "Colegio Santa Teresa",
"customer_type": "wholesale",
"business_name": "Cooperativa Colegio Santa Teresa",
"contact_person": "Carmen Navarro",
"email": "cocina@colegiosantateresa.es",
"phone": "+34 963 012 345",
"address": "Avenida de la Constitución, 234, 46008 Valencia",
"payment_terms": "net_45",
"discount_percentage": 18.0,
"credit_limit": 5000.00,
"is_active": true,
"notes": "Colegio con 800 alumnos. Pedidos de septiembre a junio (calendario escolar).",
"tags": ["institucional", "colegio", "estacional"]
},
{
"customer_name": "Mercado Central - Puesto 23",
"customer_type": "retail",
"business_name": "Antonio Sánchez - Mercado Central",
"contact_person": "Antonio Sánchez",
"email": "antoniosanchez.mercado@gmail.com",
"phone": "+34 963 456 012",
"address": "Mercado Central, Puesto 23, 46001 Valencia",
"payment_terms": "net_7",
"discount_percentage": 8.0,
"id": "20000000-0000-0000-0000-000000000003",
"customer_code": "CLI-003",
"name": "Cafetería La Esquina",
"business_name": "Cafetería La Esquina S.L.",
"customer_type": "business",
"email": "info@laesquina.es",
"phone": "+34 91 456 7803",
"address_line1": "Calle Toledo 23",
"city": "Madrid",
"postal_code": "28005",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "immediate",
"credit_limit": 1000.00,
"is_active": true,
"notes": "Puesto de venta en el mercado central. Compra para revender.",
"tags": ["mercado", "revendedor", "pequeño"]
"discount_percentage": 0.00,
"preferred_delivery_method": "delivery"
},
{
"customer_name": "Cafetería Universidad Politécnica",
"customer_type": "wholesale",
"business_name": "Servicios Universitarios UPV",
"contact_person": "Roberto Martín",
"email": "cafeteria@upv.es",
"phone": "+34 963 789 456",
"address": "Campus de Vera, Edificio 4N, 46022 Valencia",
"payment_terms": "net_30",
"discount_percentage": 20.0,
"credit_limit": 7000.00,
"is_active": true,
"notes": "Cafetería universitaria. Alto volumen durante curso académico. Cierra en verano.",
"tags": ["institucional", "universidad", "estacional", "alto_volumen"]
"id": "20000000-0000-0000-0000-000000000004",
"customer_code": "CLI-004",
"name": "María García Ruiz",
"customer_type": "individual",
"email": "maria.garcia@email.com",
"phone": "+34 612 345 678",
"address_line1": "Calle Alcalá 100, 3º B",
"city": "Madrid",
"postal_code": "28009",
"country": "España",
"customer_segment": "vip",
"priority_level": "high",
"payment_terms": "immediate",
"preferred_delivery_method": "delivery",
"special_instructions": "Cliente VIP - Tartas de cumpleaños personalizadas"
},
{
"customer_name": "Panadería El Horno de Oro",
"customer_type": "retail",
"business_name": "El Horno de Oro S.C.",
"contact_person": "Manuel Jiménez",
"email": "hornodeoro@telefonica.net",
"phone": "+34 965 234 567",
"address": "Calle del Cid, 67, 03400 Villena, Alicante",
"id": "20000000-0000-0000-0000-000000000005",
"customer_code": "CLI-005",
"name": "Carlos Martínez López",
"customer_type": "individual",
"email": "carlos.m@email.com",
"phone": "+34 623 456 789",
"address_line1": "Gran Vía 75, 5º A",
"city": "Madrid",
"postal_code": "28013",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "immediate",
"preferred_delivery_method": "pickup"
},
{
"id": "20000000-0000-0000-0000-000000000006",
"customer_code": "CLI-006",
"name": "Panadería Central Distribución",
"business_name": "Panadería Central S.A.",
"customer_type": "central_bakery",
"email": "produccion@panaderiacentral.es",
"phone": "+34 91 567 8904",
"address_line1": "Polígono Industrial Norte, Nave 12",
"city": "Madrid",
"postal_code": "28050",
"country": "España",
"customer_segment": "wholesale",
"priority_level": "high",
"payment_terms": "net_15",
"discount_percentage": 25.0,
"credit_limit": 2500.00,
"is_active": true,
"notes": "Panadería tradicional. Compra productos especializados que no produce.",
"tags": ["panaderia", "b2b", "especializado"]
"credit_limit": 10000.00,
"discount_percentage": 15.00,
"preferred_delivery_method": "pickup",
"special_instructions": "Pedidos grandes - Coordinación con almacén necesaria"
},
{
"customer_name": "Bar Cafetería La Plaza",
"customer_type": "retail",
"business_name": "La Plaza Hostelería",
"contact_person": "Teresa López",
"email": "barlaplaza@hotmail.com",
"phone": "+34 962 567 890",
"address": "Plaza Mayor, 3, 46470 Catarroja, Valencia",
"payment_terms": "net_7",
"discount_percentage": 12.0,
"credit_limit": 1200.00,
"is_active": true,
"notes": "Bar de pueblo con clientela local. Pedidos regulares de lunes a sábado.",
"tags": ["hosteleria", "bar", "regular"]
},
{
"customer_name": "Supermercado Eco Verde",
"customer_type": "wholesale",
"business_name": "Eco Verde Distribución S.L.",
"contact_person": "Laura Sánchez",
"email": "compras@ecoverde.es",
"phone": "+34 963 890 123",
"address": "Calle Colón, 178, 46004 Valencia",
"id": "20000000-0000-0000-0000-000000000007",
"customer_code": "CLI-007",
"name": "Supermercado El Ahorro",
"business_name": "Supermercado El Ahorro S.L.",
"customer_type": "business",
"email": "compras@elahorro.es",
"phone": "+34 91 678 9015",
"address_line1": "Avenida de América 200",
"city": "Madrid",
"postal_code": "28028",
"country": "España",
"customer_segment": "wholesale",
"priority_level": "high",
"payment_terms": "net_30",
"discount_percentage": 18.0,
"credit_limit": 4500.00,
"is_active": true,
"notes": "Supermercado especializado en productos ecológicos. Interesados en panes artesanales.",
"tags": ["retail", "supermercado", "ecologico", "premium"]
"credit_limit": 8000.00,
"discount_percentage": 12.00,
"preferred_delivery_method": "delivery",
"special_instructions": "Entrega en muelle de carga. Horario: 7:00-9:00 AM"
},
{
"customer_name": "Restaurante La Alquería",
"customer_type": "retail",
"business_name": "La Alquería Grupo Gastronómico",
"contact_person": "Javier Moreno",
"email": "jefe.cocina@laalqueria.es",
"phone": "+34 961 456 789",
"address": "Camino de Vera, 45, 46022 Valencia",
"id": "20000000-0000-0000-0000-000000000008",
"customer_code": "CLI-008",
"name": "Ana Rodríguez Fernández",
"customer_type": "individual",
"email": "ana.rodriguez@email.com",
"phone": "+34 634 567 890",
"address_line1": "Calle Serrano 50, 2º D",
"city": "Madrid",
"postal_code": "28001",
"country": "España",
"customer_segment": "vip",
"priority_level": "high",
"payment_terms": "immediate",
"preferred_delivery_method": "delivery",
"special_instructions": "Prefiere croissants de mantequilla y pan integral"
},
{
"id": "20000000-0000-0000-0000-000000000009",
"customer_code": "CLI-009",
"name": "Colegio San José",
"business_name": "Colegio San José - Comedor Escolar",
"customer_type": "business",
"email": "administracion@colegiosanjose.es",
"phone": "+34 91 789 0126",
"address_line1": "Calle Bravo Murillo 150",
"city": "Madrid",
"postal_code": "28020",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "net_30",
"credit_limit": 3000.00,
"discount_percentage": 8.00,
"preferred_delivery_method": "delivery",
"special_instructions": "Entrega diaria a las 7:30 AM. 500 alumnos."
},
{
"id": "20000000-0000-0000-0000-000000000010",
"customer_code": "CLI-010",
"name": "Javier López Sánchez",
"customer_type": "individual",
"email": "javier.lopez@email.com",
"phone": "+34 645 678 901",
"address_line1": "Calle Atocha 25, 1º C",
"city": "Madrid",
"postal_code": "28012",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "immediate",
"preferred_delivery_method": "pickup"
},
{
"id": "20000000-0000-0000-0000-000000000011",
"customer_code": "CLI-011",
"name": "Cafetería Central Station",
"business_name": "Central Station Coffee S.L.",
"customer_type": "business",
"email": "pedidos@centralstation.es",
"phone": "+34 91 890 1237",
"address_line1": "Estación de Atocha, Local 23",
"city": "Madrid",
"postal_code": "28045",
"country": "España",
"customer_segment": "wholesale",
"priority_level": "high",
"payment_terms": "net_15",
"discount_percentage": 15.0,
"credit_limit": 3500.00,
"is_active": true,
"notes": "Restaurante de alta gama. Exigente con la calidad. Panes artesanales especiales.",
"tags": ["hosteleria", "restaurante", "premium", "exigente"]
"credit_limit": 4000.00,
"discount_percentage": 10.00,
"preferred_delivery_method": "delivery",
"special_instructions": "Dos entregas diarias: 5:30 AM y 12:00 PM"
},
{
"id": "20000000-0000-0000-0000-000000000012",
"customer_code": "CLI-012",
"name": "Isabel Torres Muñoz",
"customer_type": "individual",
"email": "isabel.torres@email.com",
"phone": "+34 656 789 012",
"address_line1": "Calle Goya 88, 4º A",
"city": "Madrid",
"postal_code": "28001",
"country": "España",
"customer_segment": "vip",
"priority_level": "high",
"payment_terms": "immediate",
"preferred_delivery_method": "delivery",
"special_instructions": "Pedidos semanales de tartas especiales"
},
{
"id": "20000000-0000-0000-0000-000000000013",
"customer_code": "CLI-013",
"name": "Bar Tapas La Latina",
"business_name": "Bar La Latina S.L.",
"customer_type": "business",
"email": "info@barlalatina.es",
"phone": "+34 91 901 2348",
"address_line1": "Plaza de la Paja 8",
"city": "Madrid",
"postal_code": "28005",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "net_15",
"credit_limit": 1500.00,
"discount_percentage": 5.00,
"preferred_delivery_method": "pickup"
},
{
"id": "20000000-0000-0000-0000-000000000014",
"customer_code": "CLI-014",
"name": "Francisco Gómez Rivera",
"customer_type": "individual",
"email": "francisco.gomez@email.com",
"phone": "+34 667 890 123",
"address_line1": "Calle Velázquez 120, 6º B",
"city": "Madrid",
"postal_code": "28006",
"country": "España",
"customer_segment": "regular",
"priority_level": "normal",
"payment_terms": "immediate",
"preferred_delivery_method": "pickup"
},
{
"id": "20000000-0000-0000-0000-000000000015",
"customer_code": "CLI-015",
"name": "Residencia Tercera Edad Los Olivos",
"business_name": "Residencia Los Olivos S.L.",
"customer_type": "business",
"email": "cocina@residenciaolivos.es",
"phone": "+34 91 012 3459",
"address_line1": "Calle Arturo Soria 345",
"city": "Madrid",
"postal_code": "28033",
"country": "España",
"customer_segment": "wholesale",
"priority_level": "high",
"payment_terms": "net_30",
"credit_limit": 6000.00,
"discount_percentage": 10.00,
"preferred_delivery_method": "delivery",
"special_instructions": "Pan de molde sin corteza para 120 residentes. Entrega 6:00 AM."
}
]
}

View File

@@ -0,0 +1,266 @@
{
"configuracion_compras": {
"planes_por_tenant": 8,
"requisitos_por_plan": {
"min": 5,
"max": 12
},
"distribucion_temporal": {
"completados": {
"porcentaje": 0.25,
"offset_dias_min": -45,
"offset_dias_max": -8,
"estados": ["completed"]
},
"en_ejecucion": {
"porcentaje": 0.375,
"offset_dias_min": -7,
"offset_dias_max": -1,
"estados": ["in_execution", "approved"]
},
"pendiente_aprobacion": {
"porcentaje": 0.25,
"offset_dias_min": 0,
"offset_dias_max": 0,
"estados": ["pending_approval"]
},
"borrador": {
"porcentaje": 0.125,
"offset_dias_min": 1,
"offset_dias_max": 3,
"estados": ["draft"]
}
},
"distribucion_estados": {
"draft": 0.125,
"pending_approval": 0.25,
"approved": 0.25,
"in_execution": 0.25,
"completed": 0.125
},
"tipos_plan": [
{"tipo": "regular", "peso": 0.75},
{"tipo": "emergency", "peso": 0.15},
{"tipo": "seasonal", "peso": 0.10}
],
"prioridades": {
"low": 0.20,
"normal": 0.55,
"high": 0.20,
"critical": 0.05
},
"estrategias_compra": [
{"estrategia": "just_in_time", "peso": 0.50},
{"estrategia": "bulk", "peso": 0.30},
{"estrategia": "mixed", "peso": 0.20}
],
"niveles_riesgo": {
"low": 0.50,
"medium": 0.30,
"high": 0.15,
"critical": 0.05
},
"ingredientes_demo": [
{
"id": "10000000-0000-0000-0000-000000000001",
"nombre": "Harina de Trigo Panadera T-55",
"sku": "ING-HAR-001",
"categoria": "harinas",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 0.65,
"lead_time_dias": 3,
"cantidad_minima": 500.0,
"vida_util_dias": 180
},
{
"id": "10000000-0000-0000-0000-000000000002",
"nombre": "Harina de Trigo Integral",
"sku": "ING-HAR-002",
"categoria": "harinas",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 0.85,
"lead_time_dias": 3,
"cantidad_minima": 300.0,
"vida_util_dias": 120
},
{
"id": "10000000-0000-0000-0000-000000000003",
"nombre": "Levadura Fresca Prensada",
"sku": "ING-LEV-001",
"categoria": "levaduras",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 3.50,
"lead_time_dias": 2,
"cantidad_minima": 25.0,
"vida_util_dias": 21
},
{
"id": "10000000-0000-0000-0000-000000000004",
"nombre": "Sal Marina Refinada",
"sku": "ING-SAL-001",
"categoria": "ingredientes_basicos",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 0.40,
"lead_time_dias": 7,
"cantidad_minima": 200.0,
"vida_util_dias": 730
},
{
"id": "10000000-0000-0000-0000-000000000005",
"nombre": "Mantequilla 82% MG",
"sku": "ING-MAN-001",
"categoria": "lacteos",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 5.80,
"lead_time_dias": 2,
"cantidad_minima": 50.0,
"vida_util_dias": 90
},
{
"id": "10000000-0000-0000-0000-000000000006",
"nombre": "Azúcar Blanco Refinado",
"sku": "ING-AZU-001",
"categoria": "azucares",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 0.75,
"lead_time_dias": 5,
"cantidad_minima": 300.0,
"vida_util_dias": 365
},
{
"id": "10000000-0000-0000-0000-000000000007",
"nombre": "Huevos Categoría A",
"sku": "ING-HUE-001",
"categoria": "lacteos",
"tipo": "ingredient",
"unidad": "unidad",
"costo_unitario": 0.18,
"lead_time_dias": 2,
"cantidad_minima": 360.0,
"vida_util_dias": 28
},
{
"id": "10000000-0000-0000-0000-000000000008",
"nombre": "Leche Entera UHT",
"sku": "ING-LEC-001",
"categoria": "lacteos",
"tipo": "ingredient",
"unidad": "litro",
"costo_unitario": 0.85,
"lead_time_dias": 3,
"cantidad_minima": 100.0,
"vida_util_dias": 90
},
{
"id": "10000000-0000-0000-0000-000000000009",
"nombre": "Chocolate Cobertura 70%",
"sku": "ING-CHO-001",
"categoria": "chocolates",
"tipo": "ingredient",
"unidad": "kg",
"costo_unitario": 12.50,
"lead_time_dias": 5,
"cantidad_minima": 25.0,
"vida_util_dias": 365
},
{
"id": "10000000-0000-0000-0000-000000000010",
"nombre": "Aceite de Oliva Virgen Extra",
"sku": "ING-ACE-001",
"categoria": "aceites",
"tipo": "ingredient",
"unidad": "litro",
"costo_unitario": 4.20,
"lead_time_dias": 4,
"cantidad_minima": 50.0,
"vida_util_dias": 540
},
{
"id": "10000000-0000-0000-0000-000000000011",
"nombre": "Bolsas de Papel Kraft",
"sku": "PAC-BOL-001",
"categoria": "embalaje",
"tipo": "packaging",
"unidad": "unidad",
"costo_unitario": 0.08,
"lead_time_dias": 10,
"cantidad_minima": 5000.0,
"vida_util_dias": 730
},
{
"id": "10000000-0000-0000-0000-000000000012",
"nombre": "Cajas de Cartón Grande",
"sku": "PAC-CAJ-001",
"categoria": "embalaje",
"tipo": "packaging",
"unidad": "unidad",
"costo_unitario": 0.45,
"lead_time_dias": 7,
"cantidad_minima": 500.0,
"vida_util_dias": 730
}
],
"rangos_cantidad": {
"harinas": {"min": 500.0, "max": 2000.0},
"levaduras": {"min": 20.0, "max": 100.0},
"ingredientes_basicos": {"min": 100.0, "max": 500.0},
"lacteos": {"min": 50.0, "max": 300.0},
"azucares": {"min": 200.0, "max": 800.0},
"chocolates": {"min": 10.0, "max": 50.0},
"aceites": {"min": 30.0, "max": 150.0},
"embalaje": {"min": 1000.0, "max": 10000.0}
},
"buffer_seguridad_porcentaje": {
"min": 10.0,
"max": 30.0,
"tipico": 20.0
},
"horizonte_planificacion_dias": {
"individual_bakery": 14,
"central_bakery": 21
},
"metricas_rendimiento": {
"tasa_cumplimiento": {"min": 85.0, "max": 98.0},
"entrega_puntual": {"min": 80.0, "max": 95.0},
"precision_costo": {"min": 90.0, "max": 99.0},
"puntuacion_calidad": {"min": 7.0, "max": 10.0}
}
},
"alertas_compras": {
"plan_urgente": {
"condicion": "plan_type = emergency AND status IN (draft, pending_approval)",
"mensaje": "Plan de compras de emergencia requiere aprobación urgente: {plan_number}",
"severidad": "high"
},
"requisito_critico": {
"condicion": "priority = critical AND required_by_date < NOW() + INTERVAL '3 days'",
"mensaje": "Requisito crítico con fecha límite próxima: {product_name} para {required_by_date}",
"severidad": "high"
},
"riesgo_suministro": {
"condicion": "supply_risk_level IN (high, critical)",
"mensaje": "Alto riesgo de suministro detectado en plan {plan_number}",
"severidad": "medium"
},
"fecha_pedido_proxima": {
"condicion": "suggested_order_date BETWEEN NOW() AND NOW() + INTERVAL '2 days'",
"mensaje": "Fecha sugerida de pedido próxima: {product_name}",
"severidad": "medium"
}
},
"notas": {
"descripcion": "Configuración para generación de planes de compras demo",
"planes_totales": 8,
"ingredientes_disponibles": 12,
"proveedores": "Usar proveedores de proveedores_es.json",
"fechas": "Usar offsets relativos a BASE_REFERENCE_DATE",
"moneda": "EUR",
"idioma": "español"
}
}

View File

@@ -0,0 +1,220 @@
{
"configuracion_pedidos": {
"total_pedidos_por_tenant": 30,
"distribucion_temporal": {
"completados_antiguos": {
"porcentaje": 0.30,
"offset_dias_min": -60,
"offset_dias_max": -15,
"estados": ["delivered", "completed"]
},
"completados_recientes": {
"porcentaje": 0.25,
"offset_dias_min": -14,
"offset_dias_max": -1,
"estados": ["delivered", "completed"]
},
"en_proceso": {
"porcentaje": 0.25,
"offset_dias_min": 0,
"offset_dias_max": 0,
"estados": ["confirmed", "in_production", "ready"]
},
"futuros": {
"porcentaje": 0.20,
"offset_dias_min": 1,
"offset_dias_max": 7,
"estados": ["pending", "confirmed"]
}
},
"distribucion_estados": {
"pending": 0.10,
"confirmed": 0.15,
"in_production": 0.10,
"ready": 0.10,
"in_delivery": 0.05,
"delivered": 0.35,
"completed": 0.10,
"cancelled": 0.05
},
"distribucion_prioridad": {
"low": 0.30,
"normal": 0.50,
"high": 0.15,
"urgent": 0.05
},
"lineas_por_pedido": {
"min": 2,
"max": 8
},
"cantidad_por_linea": {
"min": 5,
"max": 100
},
"precio_unitario": {
"min": 1.50,
"max": 15.00
},
"descuento_porcentaje": {
"sin_descuento": 0.70,
"con_descuento_5": 0.15,
"con_descuento_10": 0.10,
"con_descuento_15": 0.05
},
"metodos_pago": [
{"metodo": "bank_transfer", "peso": 0.40},
{"metodo": "credit_card", "peso": 0.25},
{"metodo": "cash", "peso": 0.20},
{"metodo": "check", "peso": 0.10},
{"metodo": "account", "peso": 0.05}
],
"tipos_entrega": [
{"tipo": "standard", "peso": 0.60},
{"tipo": "delivery", "peso": 0.25},
{"tipo": "pickup", "peso": 0.15}
],
"notas_pedido": [
"Entrega en horario de mañana, antes de las 8:00 AM",
"Llamar 15 minutos antes de llegar",
"Dejar en la entrada de servicio",
"Contactar con el encargado al llegar",
"Pedido urgente para evento especial",
"Embalaje especial para transporte",
"Verificar cantidad antes de descargar",
"Entrega programada según calendario acordado",
"Incluir factura con el pedido",
"Pedido recurrente semanal"
],
"productos_demo": [
{
"nombre": "Pan de Barra Tradicional",
"codigo": "PROD-001",
"precio_base": 1.80,
"unidad": "unidad"
},
{
"nombre": "Baguette",
"codigo": "PROD-002",
"precio_base": 2.00,
"unidad": "unidad"
},
{
"nombre": "Pan Integral",
"codigo": "PROD-003",
"precio_base": 2.50,
"unidad": "unidad"
},
{
"nombre": "Pan de Centeno",
"codigo": "PROD-004",
"precio_base": 2.80,
"unidad": "unidad"
},
{
"nombre": "Croissant",
"codigo": "PROD-005",
"precio_base": 1.50,
"unidad": "unidad"
},
{
"nombre": "Napolitana de Chocolate",
"codigo": "PROD-006",
"precio_base": 1.80,
"unidad": "unidad"
},
{
"nombre": "Palmera",
"codigo": "PROD-007",
"precio_base": 1.60,
"unidad": "unidad"
},
{
"nombre": "Ensaimada",
"codigo": "PROD-008",
"precio_base": 3.50,
"unidad": "unidad"
},
{
"nombre": "Magdalena",
"codigo": "PROD-009",
"precio_base": 1.20,
"unidad": "unidad"
},
{
"nombre": "Bollo de Leche",
"codigo": "PROD-010",
"precio_base": 1.00,
"unidad": "unidad"
},
{
"nombre": "Pan de Molde Blanco",
"codigo": "PROD-011",
"precio_base": 2.20,
"unidad": "unidad"
},
{
"nombre": "Pan de Molde Integral",
"codigo": "PROD-012",
"precio_base": 2.50,
"unidad": "unidad"
},
{
"nombre": "Panecillo",
"codigo": "PROD-013",
"precio_base": 0.80,
"unidad": "unidad"
},
{
"nombre": "Rosca de Anís",
"codigo": "PROD-014",
"precio_base": 3.00,
"unidad": "unidad"
},
{
"nombre": "Empanada de Atún",
"codigo": "PROD-015",
"precio_base": 4.50,
"unidad": "unidad"
}
],
"horarios_entrega": [
"06:00-08:00",
"08:00-10:00",
"10:00-12:00",
"12:00-14:00",
"14:00-16:00",
"16:00-18:00"
]
},
"alertas_pedidos": {
"pedidos_urgentes": {
"condicion": "priority = urgent AND status IN (pending, confirmed)",
"mensaje": "Pedido urgente requiere atención inmediata: {order_number}",
"severidad": "high"
},
"pedidos_retrasados": {
"condicion": "delivery_date < NOW() AND status NOT IN (delivered, completed, cancelled)",
"mensaje": "Pedido retrasado: {order_number} para cliente {customer_name}",
"severidad": "high"
},
"pedidos_proximos": {
"condicion": "delivery_date BETWEEN NOW() AND NOW() + INTERVAL '24 hours'",
"mensaje": "Entrega programada en las próximas 24 horas: {order_number}",
"severidad": "medium"
},
"pedidos_grandes": {
"condicion": "total_amount > 500",
"mensaje": "Pedido de alto valor requiere verificación: {order_number} ({total_amount}¬)",
"severidad": "medium"
}
},
"notas": {
"descripcion": "Configuración para generación automática de pedidos demo",
"total_pedidos": 30,
"productos_disponibles": 15,
"clientes_requeridos": "Usar clientes de clientes_es.json",
"fechas": "Usar offsets relativos a BASE_REFERENCE_DATE",
"moneda": "EUR",
"idioma": "español"
}
}

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Customer Seeding Script for Orders Service
Creates customers for demo template tenants
This script runs as a Kubernetes init job inside the orders-service container.
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.customer import Customer
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_customer_data():
"""Load customer data from JSON file"""
data_file = Path(__file__).parent / "clientes_es.json"
if not data_file.exists():
raise FileNotFoundError(f"Customer data file not found: {data_file}")
with open(data_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_date_from_offset(offset_days: int) -> datetime:
"""Calculate a date based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
async def seed_customers_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
customer_list: list
):
"""Seed customers for a specific tenant"""
logger.info(f"Seeding customers for: {tenant_name}", tenant_id=str(tenant_id))
# Check if customers already exist
result = await db.execute(
select(Customer).where(Customer.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Customers already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "customers_created": 0, "skipped": True}
count = 0
for customer_data in customer_list:
# Calculate dates from offsets
first_order_date = None
if "first_order_offset_days" in customer_data:
first_order_date = calculate_date_from_offset(customer_data["first_order_offset_days"])
last_order_date = None
if "last_order_offset_days" in customer_data:
last_order_date = calculate_date_from_offset(customer_data["last_order_offset_days"])
# Use strings directly (model doesn't use enums)
customer_type = customer_data.get("customer_type", "business")
customer_segment = customer_data.get("customer_segment", "regular")
is_active = customer_data.get("status", "active") == "active"
# Create customer (using actual model fields)
# For San Pablo, use original IDs. For La Espiga, generate new UUIDs
if tenant_id == DEMO_TENANT_SAN_PABLO:
customer_id = uuid.UUID(customer_data["id"])
else:
# Generate deterministic UUID for La Espiga based on original ID
base_uuid = uuid.UUID(customer_data["id"])
# Add a fixed offset to create a unique but deterministic ID
customer_id = uuid.UUID(int=base_uuid.int + 0x10000000000000000000000000000000)
customer = Customer(
id=customer_id,
tenant_id=tenant_id,
customer_code=customer_data["customer_code"],
name=customer_data["name"],
business_name=customer_data.get("business_name"),
customer_type=customer_type,
tax_id=customer_data.get("tax_id"),
email=customer_data.get("email"),
phone=customer_data.get("phone"),
address_line1=customer_data.get("billing_address"),
city=customer_data.get("billing_city"),
state=customer_data.get("billing_state"),
postal_code=customer_data.get("billing_postal_code"),
country=customer_data.get("billing_country", "España"),
is_active=is_active,
preferred_delivery_method=customer_data.get("preferred_delivery_method", "delivery"),
payment_terms=customer_data.get("payment_terms", "immediate"),
credit_limit=customer_data.get("credit_limit"),
discount_percentage=customer_data.get("discount_percentage", 0.0),
customer_segment=customer_segment,
priority_level=customer_data.get("priority_level", "normal"),
special_instructions=customer_data.get("special_instructions"),
total_orders=customer_data.get("total_orders", 0),
total_spent=customer_data.get("total_revenue", 0.0),
average_order_value=customer_data.get("average_order_value", 0.0),
last_order_date=last_order_date,
created_at=BASE_REFERENCE_DATE,
updated_at=BASE_REFERENCE_DATE
)
db.add(customer)
count += 1
logger.debug(f"Created customer: {customer.name}", customer_id=str(customer.id))
await db.commit()
logger.info(f"Successfully created {count} customers for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"customers_created": count,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with customers"""
logger.info("Starting demo customer seed process")
# Load customer data
data = load_customer_data()
results = []
# Both tenants get the same customer base
# (In real scenario, you might want different customer lists)
result_san_pablo = await seed_customers_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
data["clientes"]
)
results.append(result_san_pablo)
result_la_espiga = await seed_customers_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
data["clientes"]
)
results.append(result_la_espiga)
total_created = sum(r["customers_created"] for r in results)
return {
"results": results,
"total_customers_created": total_created,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("ORDERS_DATABASE_URL")
if not database_url:
logger.error("ORDERS_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Customer seed completed successfully!",
total_customers=result["total_customers_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO CUSTOMER SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
count = tenant_result["customers_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {count} customers"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Customers Created: {result['total_customers_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Customer seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -0,0 +1,396 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Orders Seeding Script for Orders Service
Creates realistic orders with order lines for demo template tenants
This script runs as a Kubernetes init job inside the orders-service container.
"""
import asyncio
import uuid
import sys
import os
import json
import random
from datetime import datetime, timezone, timedelta
from pathlib import Path
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.order import CustomerOrder, OrderItem
from app.models.customer import Customer
from app.models.enums import OrderStatus, PaymentMethod, PaymentStatus, DeliveryMethod
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_orders_config():
"""Load orders configuration from JSON file"""
config_file = Path(__file__).parent / "pedidos_config_es.json"
if not config_file.exists():
raise FileNotFoundError(f"Orders config file not found: {config_file}")
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def load_customers_data():
"""Load customers data from JSON file"""
customers_file = Path(__file__).parent / "clientes_es.json"
if not customers_file.exists():
raise FileNotFoundError(f"Customers file not found: {customers_file}")
with open(customers_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("clientes", [])
def calculate_date_from_offset(offset_days: int) -> datetime:
"""Calculate a date based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
# Model uses simple strings, no need for enum mapping functions
# (OrderPriority, DeliveryType don't exist in enums.py)
def weighted_choice(choices: list) -> dict:
"""Make a weighted random choice from list of dicts with 'peso' key"""
total_weight = sum(c.get("peso", 1.0) for c in choices)
r = random.uniform(0, total_weight)
cumulative = 0
for choice in choices:
cumulative += choice.get("peso", 1.0)
if r <= cumulative:
return choice
return choices[-1]
def generate_order_number(tenant_id: uuid.UUID, index: int) -> str:
"""Generate a unique order number"""
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_SAN_PABLO else "LE"
return f"ORD-{tenant_prefix}-{BASE_REFERENCE_DATE.year}-{index:04d}"
async def generate_orders_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
config: dict,
customers_data: list
):
"""Generate orders for a specific tenant"""
logger.info(f"Generating orders for: {tenant_name}", tenant_id=str(tenant_id))
# Check if orders already exist
result = await db.execute(
select(CustomerOrder).where(CustomerOrder.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Orders already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "orders_created": 0, "order_lines_created": 0, "skipped": True}
# Get customers for this tenant
result = await db.execute(
select(Customer).where(Customer.tenant_id == tenant_id)
)
customers = list(result.scalars().all())
if not customers:
logger.warning(f"No customers found for {tenant_name}, cannot generate orders")
return {"tenant_id": str(tenant_id), "orders_created": 0, "order_lines_created": 0, "error": "no_customers"}
orders_config = config["configuracion_pedidos"]
total_orders = orders_config["total_pedidos_por_tenant"]
orders_created = 0
lines_created = 0
for i in range(total_orders):
# Select random customer
customer = random.choice(customers)
# Determine temporal distribution
rand_temporal = random.random()
cumulative = 0
temporal_category = None
for category, details in orders_config["distribucion_temporal"].items():
cumulative += details["porcentaje"]
if rand_temporal <= cumulative:
temporal_category = details
break
if not temporal_category:
temporal_category = orders_config["distribucion_temporal"]["completados_antiguos"]
# Calculate order date
offset_days = random.randint(
temporal_category["offset_dias_min"],
temporal_category["offset_dias_max"]
)
order_date = calculate_date_from_offset(offset_days)
# Select status based on temporal category (use strings directly)
status = random.choice(temporal_category["estados"])
# Select priority (use strings directly)
priority_rand = random.random()
cumulative_priority = 0
priority = "normal"
for p, weight in orders_config["distribucion_prioridad"].items():
cumulative_priority += weight
if priority_rand <= cumulative_priority:
priority = p
break
# Select payment method (use strings directly)
payment_method_choice = weighted_choice(orders_config["metodos_pago"])
payment_method = payment_method_choice["metodo"]
# Select delivery type (use strings directly)
delivery_type_choice = weighted_choice(orders_config["tipos_entrega"])
delivery_method = delivery_type_choice["tipo"]
# Calculate delivery date (1-7 days after order date typically)
delivery_offset = random.randint(1, 7)
delivery_date = order_date + timedelta(days=delivery_offset)
# Select delivery time
delivery_time = random.choice(orders_config["horarios_entrega"])
# Generate order number
order_number = generate_order_number(tenant_id, i + 1)
# Select notes
notes = random.choice(orders_config["notas_pedido"]) if random.random() < 0.6 else None
# Create order (using only actual model fields)
order = CustomerOrder(
id=uuid.uuid4(),
tenant_id=tenant_id,
order_number=order_number,
customer_id=customer.id,
status=status,
order_type="standard",
priority=priority,
order_date=order_date,
requested_delivery_date=delivery_date,
confirmed_delivery_date=delivery_date if status != "pending" else None,
actual_delivery_date=delivery_date if status in ["delivered", "completed"] else None,
delivery_method=delivery_method,
delivery_address={"address": customer.address_line1, "city": customer.city, "postal_code": customer.postal_code} if customer.address_line1 else None,
payment_method=payment_method,
payment_status="paid" if status in ["delivered", "completed"] else "pending",
payment_terms="immediate",
subtotal=Decimal("0.00"), # Will calculate
discount_percentage=Decimal("0.00"), # Will set
discount_amount=Decimal("0.00"), # Will calculate
tax_amount=Decimal("0.00"), # Will calculate
delivery_fee=Decimal("0.00"),
total_amount=Decimal("0.00"), # Will calculate
special_instructions=notes,
order_source="manual",
sales_channel="direct",
created_at=order_date,
updated_at=order_date
)
db.add(order)
await db.flush() # Get order ID
# Generate order lines
num_lines = random.randint(
orders_config["lineas_por_pedido"]["min"],
orders_config["lineas_por_pedido"]["max"]
)
# Select random products
selected_products = random.sample(
orders_config["productos_demo"],
min(num_lines, len(orders_config["productos_demo"]))
)
subtotal = Decimal("0.00")
for line_num, product in enumerate(selected_products, 1):
quantity = random.randint(
orders_config["cantidad_por_linea"]["min"],
orders_config["cantidad_por_linea"]["max"]
)
# Use base price with some variation
unit_price = Decimal(str(product["precio_base"])) * Decimal(str(random.uniform(0.95, 1.05)))
unit_price = unit_price.quantize(Decimal("0.01"))
line_total = unit_price * quantity
order_line = OrderItem(
id=uuid.uuid4(),
order_id=order.id,
product_id=uuid.uuid4(), # Generate placeholder product ID
product_name=product["nombre"],
product_sku=product["codigo"],
quantity=Decimal(str(quantity)),
unit_of_measure="each",
unit_price=unit_price,
line_discount=Decimal("0.00"),
line_total=line_total,
status="pending"
)
db.add(order_line)
subtotal += line_total
lines_created += 1
# Apply order-level discount
discount_rand = random.random()
if discount_rand < 0.70:
discount_percentage = Decimal("0.00")
elif discount_rand < 0.85:
discount_percentage = Decimal("5.00")
elif discount_rand < 0.95:
discount_percentage = Decimal("10.00")
else:
discount_percentage = Decimal("15.00")
discount_amount = (subtotal * discount_percentage / 100).quantize(Decimal("0.01"))
amount_after_discount = subtotal - discount_amount
tax_amount = (amount_after_discount * Decimal("0.10")).quantize(Decimal("0.01"))
total_amount = amount_after_discount + tax_amount
# Update order totals
order.subtotal = subtotal
order.discount_percentage = discount_percentage
order.discount_amount = discount_amount
order.tax_amount = tax_amount
order.total_amount = total_amount
orders_created += 1
await db.commit()
logger.info(f"Successfully created {orders_created} orders with {lines_created} lines for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"orders_created": orders_created,
"order_lines_created": lines_created,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with orders"""
logger.info("Starting demo orders seed process")
# Load configuration
config = load_orders_config()
customers_data = load_customers_data()
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await generate_orders_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
config,
customers_data
)
results.append(result_san_pablo)
# Seed La Espiga (Central Bakery)
result_la_espiga = await generate_orders_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
config,
customers_data
)
results.append(result_la_espiga)
total_orders = sum(r["orders_created"] for r in results)
total_lines = sum(r["order_lines_created"] for r in results)
return {
"results": results,
"total_orders_created": total_orders,
"total_lines_created": total_lines,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("ORDERS_DATABASE_URL")
if not database_url:
logger.error("ORDERS_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Orders seed completed successfully!",
total_orders=result["total_orders_created"],
total_lines=result["total_lines_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO ORDERS SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
orders = tenant_result["orders_created"]
lines = tenant_result["order_lines_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {orders} orders, {lines} lines"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Orders: {result['total_orders_created']}")
print(f"Total Order Lines: {result['total_lines_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Orders seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -0,0 +1,496 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Procurement Seeding Script for Orders Service
Creates procurement plans and requirements for demo template tenants
This script runs as a Kubernetes init job inside the orders-service container.
"""
import asyncio
import uuid
import sys
import os
import json
import random
from datetime import datetime, timezone, timedelta, date
from pathlib import Path
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.procurement import ProcurementPlan, ProcurementRequirement
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_procurement_config():
"""Load procurement configuration from JSON file"""
config_file = Path(__file__).parent / "compras_config_es.json"
if not config_file.exists():
raise FileNotFoundError(f"Procurement config file not found: {config_file}")
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_date_from_offset(offset_days: int) -> date:
"""Calculate a date based on offset from BASE_REFERENCE_DATE"""
return (BASE_REFERENCE_DATE + timedelta(days=offset_days)).date()
def calculate_datetime_from_offset(offset_days: int) -> datetime:
"""Calculate a datetime based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
def weighted_choice(choices: list) -> dict:
"""Make a weighted random choice from list of dicts with 'peso' key"""
total_weight = sum(c.get("peso", 1.0) for c in choices)
r = random.uniform(0, total_weight)
cumulative = 0
for choice in choices:
cumulative += choice.get("peso", 1.0)
if r <= cumulative:
return choice
return choices[-1]
def generate_plan_number(tenant_id: uuid.UUID, index: int, plan_type: str) -> str:
"""Generate a unique plan number"""
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_SAN_PABLO else "LE"
type_code = plan_type[0:3].upper()
return f"PROC-{tenant_prefix}-{type_code}-{BASE_REFERENCE_DATE.year}-{index:03d}"
async def generate_procurement_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
business_model: str,
config: dict
):
"""Generate procurement plans and requirements for a specific tenant"""
logger.info(f"Generating procurement data for: {tenant_name}", tenant_id=str(tenant_id))
# Check if procurement plans already exist
result = await db.execute(
select(ProcurementPlan).where(ProcurementPlan.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Procurement plans already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "plans_created": 0, "requirements_created": 0, "skipped": True}
proc_config = config["configuracion_compras"]
total_plans = proc_config["planes_por_tenant"]
plans_created = 0
requirements_created = 0
for i in range(total_plans):
# Determine temporal distribution
rand_temporal = random.random()
cumulative = 0
temporal_category = None
for category, details in proc_config["distribucion_temporal"].items():
cumulative += details["porcentaje"]
if rand_temporal <= cumulative:
temporal_category = details
break
if not temporal_category:
temporal_category = proc_config["distribucion_temporal"]["completados"]
# Calculate plan date
offset_days = random.randint(
temporal_category["offset_dias_min"],
temporal_category["offset_dias_max"]
)
plan_date = calculate_date_from_offset(offset_days)
# Select status
status = random.choice(temporal_category["estados"])
# Select plan type
plan_type_choice = weighted_choice(proc_config["tipos_plan"])
plan_type = plan_type_choice["tipo"]
# Select priority
priority_rand = random.random()
cumulative_priority = 0
priority = "normal"
for p, weight in proc_config["prioridades"].items():
cumulative_priority += weight
if priority_rand <= cumulative_priority:
priority = p
break
# Select procurement strategy
strategy_choice = weighted_choice(proc_config["estrategias_compra"])
procurement_strategy = strategy_choice["estrategia"]
# Select supply risk level
risk_rand = random.random()
cumulative_risk = 0
supply_risk_level = "low"
for risk, weight in proc_config["niveles_riesgo"].items():
cumulative_risk += weight
if risk_rand <= cumulative_risk:
supply_risk_level = risk
break
# Calculate planning horizon
planning_horizon = proc_config["horizonte_planificacion_dias"][business_model]
# Calculate period dates
period_start = plan_date
period_end = plan_date + timedelta(days=planning_horizon)
# Generate plan number
plan_number = generate_plan_number(tenant_id, i + 1, plan_type)
# Calculate safety stock buffer
safety_stock_buffer = Decimal(str(random.uniform(
proc_config["buffer_seguridad_porcentaje"]["min"],
proc_config["buffer_seguridad_porcentaje"]["max"]
)))
# Calculate approval/execution dates based on status
approved_at = None
execution_started_at = None
execution_completed_at = None
approved_by = None
if status in ["approved", "in_execution", "completed"]:
approved_at = calculate_datetime_from_offset(offset_days - 1)
approved_by = uuid.uuid4() # Would be actual user ID
if status in ["in_execution", "completed"]:
execution_started_at = calculate_datetime_from_offset(offset_days)
if status == "completed":
execution_completed_at = calculate_datetime_from_offset(offset_days + planning_horizon)
# Calculate performance metrics for completed plans
fulfillment_rate = None
on_time_delivery_rate = None
cost_accuracy = None
quality_score = None
if status == "completed":
metrics = proc_config["metricas_rendimiento"]
fulfillment_rate = Decimal(str(random.uniform(
metrics["tasa_cumplimiento"]["min"],
metrics["tasa_cumplimiento"]["max"]
)))
on_time_delivery_rate = Decimal(str(random.uniform(
metrics["entrega_puntual"]["min"],
metrics["entrega_puntual"]["max"]
)))
cost_accuracy = Decimal(str(random.uniform(
metrics["precision_costo"]["min"],
metrics["precision_costo"]["max"]
)))
quality_score = Decimal(str(random.uniform(
metrics["puntuacion_calidad"]["min"],
metrics["puntuacion_calidad"]["max"]
)))
# Create procurement plan
plan = ProcurementPlan(
id=uuid.uuid4(),
tenant_id=tenant_id,
plan_number=plan_number,
plan_date=plan_date,
plan_period_start=period_start,
plan_period_end=period_end,
planning_horizon_days=planning_horizon,
status=status,
plan_type=plan_type,
priority=priority,
business_model=business_model,
procurement_strategy=procurement_strategy,
total_requirements=0, # Will update after adding requirements
total_estimated_cost=Decimal("0.00"), # Will calculate
total_approved_cost=Decimal("0.00"),
safety_stock_buffer=safety_stock_buffer,
supply_risk_level=supply_risk_level,
demand_forecast_confidence=Decimal(str(random.uniform(7.0, 9.5))),
approved_at=approved_at,
approved_by=approved_by,
execution_started_at=execution_started_at,
execution_completed_at=execution_completed_at,
fulfillment_rate=fulfillment_rate,
on_time_delivery_rate=on_time_delivery_rate,
cost_accuracy=cost_accuracy,
quality_score=quality_score,
created_at=calculate_datetime_from_offset(offset_days - 2),
updated_at=calculate_datetime_from_offset(offset_days)
)
db.add(plan)
await db.flush() # Get plan ID
# Generate requirements for this plan
num_requirements = random.randint(
proc_config["requisitos_por_plan"]["min"],
proc_config["requisitos_por_plan"]["max"]
)
# Select random ingredients
selected_ingredients = random.sample(
proc_config["ingredientes_demo"],
min(num_requirements, len(proc_config["ingredientes_demo"]))
)
total_estimated_cost = Decimal("0.00")
for req_num, ingredient in enumerate(selected_ingredients, 1):
# Get quantity range for category
category = ingredient["categoria"]
cantidad_range = proc_config["rangos_cantidad"].get(
category,
{"min": 50.0, "max": 200.0}
)
# Calculate required quantity
required_quantity = Decimal(str(random.uniform(
cantidad_range["min"],
cantidad_range["max"]
)))
# Calculate safety stock
safety_stock_quantity = required_quantity * (safety_stock_buffer / 100)
# Total quantity needed
total_quantity_needed = required_quantity + safety_stock_quantity
# Current stock simulation
current_stock_level = required_quantity * Decimal(str(random.uniform(0.1, 0.4)))
reserved_stock = current_stock_level * Decimal(str(random.uniform(0.0, 0.3)))
available_stock = current_stock_level - reserved_stock
# Net requirement
net_requirement = total_quantity_needed - available_stock
# Demand breakdown
order_demand = required_quantity * Decimal(str(random.uniform(0.5, 0.7)))
production_demand = required_quantity * Decimal(str(random.uniform(0.2, 0.4)))
forecast_demand = required_quantity * Decimal(str(random.uniform(0.05, 0.15)))
buffer_demand = safety_stock_quantity
# Pricing
estimated_unit_cost = Decimal(str(ingredient["costo_unitario"])) * Decimal(str(random.uniform(0.95, 1.05)))
estimated_total_cost = estimated_unit_cost * net_requirement
# Timing
lead_time_days = ingredient["lead_time_dias"]
required_by_date = period_start + timedelta(days=random.randint(3, planning_horizon - 2))
lead_time_buffer_days = random.randint(1, 2)
suggested_order_date = required_by_date - timedelta(days=lead_time_days + lead_time_buffer_days)
latest_order_date = required_by_date - timedelta(days=lead_time_days)
# Requirement status based on plan status
if status == "draft":
req_status = "pending"
elif status == "pending_approval":
req_status = "pending"
elif status == "approved":
req_status = "approved"
elif status == "in_execution":
req_status = random.choice(["ordered", "partially_received"])
elif status == "completed":
req_status = "received"
else:
req_status = "pending"
# Requirement priority
if priority == "critical":
req_priority = "critical"
elif priority == "high":
req_priority = random.choice(["high", "critical"])
else:
req_priority = random.choice(["normal", "high"])
# Risk level
if supply_risk_level == "critical":
req_risk_level = random.choice(["high", "critical"])
elif supply_risk_level == "high":
req_risk_level = random.choice(["medium", "high"])
else:
req_risk_level = "low"
# Create requirement
requirement = ProcurementRequirement(
id=uuid.uuid4(),
plan_id=plan.id,
requirement_number=f"{plan_number}-REQ-{req_num:03d}",
product_id=uuid.UUID(ingredient["id"]),
product_name=ingredient["nombre"],
product_sku=ingredient["sku"],
product_category=ingredient["categoria"],
product_type=ingredient["tipo"],
required_quantity=required_quantity,
unit_of_measure=ingredient["unidad"],
safety_stock_quantity=safety_stock_quantity,
total_quantity_needed=total_quantity_needed,
current_stock_level=current_stock_level,
reserved_stock=reserved_stock,
available_stock=available_stock,
net_requirement=net_requirement,
order_demand=order_demand,
production_demand=production_demand,
forecast_demand=forecast_demand,
buffer_demand=buffer_demand,
supplier_lead_time_days=lead_time_days,
minimum_order_quantity=Decimal(str(ingredient["cantidad_minima"])),
estimated_unit_cost=estimated_unit_cost,
estimated_total_cost=estimated_total_cost,
required_by_date=required_by_date,
lead_time_buffer_days=lead_time_buffer_days,
suggested_order_date=suggested_order_date,
latest_order_date=latest_order_date,
shelf_life_days=ingredient["vida_util_dias"],
status=req_status,
priority=req_priority,
risk_level=req_risk_level,
created_at=plan.created_at,
updated_at=plan.updated_at
)
db.add(requirement)
total_estimated_cost += estimated_total_cost
requirements_created += 1
# Update plan totals
plan.total_requirements = num_requirements
plan.total_estimated_cost = total_estimated_cost
if status in ["approved", "in_execution", "completed"]:
plan.total_approved_cost = total_estimated_cost * Decimal(str(random.uniform(0.95, 1.05)))
plans_created += 1
await db.commit()
logger.info(f"Successfully created {plans_created} plans with {requirements_created} requirements for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"plans_created": plans_created,
"requirements_created": requirements_created,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with procurement data"""
logger.info("Starting demo procurement seed process")
# Load configuration
config = load_procurement_config()
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await generate_procurement_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
"individual_bakery",
config
)
results.append(result_san_pablo)
# Seed La Espiga (Central Bakery)
result_la_espiga = await generate_procurement_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
"central_bakery",
config
)
results.append(result_la_espiga)
total_plans = sum(r["plans_created"] for r in results)
total_requirements = sum(r["requirements_created"] for r in results)
return {
"results": results,
"total_plans_created": total_plans,
"total_requirements_created": total_requirements,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("ORDERS_DATABASE_URL")
if not database_url:
logger.error("ORDERS_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Procurement seed completed successfully!",
total_plans=result["total_plans_created"],
total_requirements=result["total_requirements_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO PROCUREMENT SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
plans = tenant_result["plans_created"]
requirements = tenant_result["requirements_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {plans} plans, {requirements} requirements"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Plans: {result['total_plans_created']}")
print(f"Total Requirements: {result['total_requirements_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Procurement seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -9,7 +9,7 @@ from sqlalchemy import select
import structlog
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional
from typing import Optional, Dict, Any
import os
from app.core.database import get_db
@@ -19,6 +19,8 @@ from app.models.production import (
ProductionStatus, ProductionPriority, ProcessStage,
EquipmentStatus, EquipmentType
)
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
from shared.utils.alert_generator import generate_equipment_alerts
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
@@ -45,6 +47,7 @@ async def clone_demo_data(
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
@@ -64,18 +67,29 @@ async def clone_demo_data(
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
session_created_at: Session creation timestamp for date adjustment
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting production data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id
session_id=session_id,
session_created_at=session_created_at
)
try:
@@ -90,7 +104,8 @@ async def clone_demo_data(
"production_capacity": 0,
"quality_check_templates": 0,
"quality_checks": 0,
"equipment": 0
"equipment": 0,
"alerts_generated": 0
}
# ID mappings
@@ -114,6 +129,17 @@ async def clone_demo_data(
new_equipment_id = uuid.uuid4()
equipment_id_map[equipment.id] = new_equipment_id
# Adjust dates relative to session creation time
adjusted_install_date = adjust_date_for_demo(
equipment.install_date, session_time, BASE_REFERENCE_DATE
)
adjusted_last_maintenance = adjust_date_for_demo(
equipment.last_maintenance_date, session_time, BASE_REFERENCE_DATE
)
adjusted_next_maintenance = adjust_date_for_demo(
equipment.next_maintenance_date, session_time, BASE_REFERENCE_DATE
)
new_equipment = Equipment(
id=new_equipment_id,
tenant_id=virtual_uuid,
@@ -123,9 +149,9 @@ async def clone_demo_data(
serial_number=equipment.serial_number,
location=equipment.location,
status=equipment.status,
install_date=equipment.install_date,
last_maintenance_date=equipment.last_maintenance_date,
next_maintenance_date=equipment.next_maintenance_date,
install_date=adjusted_install_date,
last_maintenance_date=adjusted_last_maintenance,
next_maintenance_date=adjusted_next_maintenance,
maintenance_interval_days=equipment.maintenance_interval_days,
efficiency_percentage=equipment.efficiency_percentage,
uptime_percentage=equipment.uptime_percentage,
@@ -137,8 +163,8 @@ async def clone_demo_data(
target_temperature=equipment.target_temperature,
is_active=equipment.is_active,
notes=equipment.notes,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
created_at=session_time,
updated_at=session_time
)
db.add(new_equipment)
stats["equipment"] += 1
@@ -185,8 +211,8 @@ async def clone_demo_data(
tolerance_percentage=template.tolerance_percentage,
applicable_stages=template.applicable_stages,
created_by=template.created_by,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
created_at=session_time,
updated_at=session_time
)
db.add(new_template)
stats["quality_check_templates"] += 1
@@ -403,9 +429,18 @@ async def clone_demo_data(
db.add(new_capacity)
stats["production_capacity"] += 1
# Commit all changes
# Commit cloned data first
await db.commit()
# Generate equipment maintenance and status alerts
try:
alerts_count = await generate_equipment_alerts(db, virtual_uuid, session_time)
stats["alerts_generated"] += alerts_count
await db.commit()
logger.info(f"Generated {alerts_count} equipment alerts")
except Exception as alert_error:
logger.warning(f"Alert generation failed: {alert_error}", exc_info=True)
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)

View File

@@ -20,6 +20,7 @@ class QualityCheckType(str, Enum):
WEIGHT = "weight"
BOOLEAN = "boolean"
TIMING = "timing"
CHECKLIST = "checklist"
class QualityCheckTemplateBase(BaseModel):

View File

@@ -0,0 +1,219 @@
{
"equipos_individual_bakery": [
{
"id": "30000000-0000-0000-0000-000000000001",
"name": "Horno Rotativo Principal",
"type": "oven",
"model": "Sveba Dahlen DC-16",
"serial_number": "SD-2023-1547",
"location": "Área de Producción - Zona A",
"status": "operational",
"power_kw": 45.0,
"capacity": 16.0,
"efficiency_percentage": 92.0,
"current_temperature": 220.0,
"target_temperature": 220.0,
"maintenance_interval_days": 90,
"last_maintenance_offset_days": -30,
"install_date_offset_days": -730
},
{
"id": "30000000-0000-0000-0000-000000000002",
"name": "Amasadora Espiral Grande",
"type": "mixer",
"model": "Diosna SP 120",
"serial_number": "DI-2022-0892",
"location": "Área de Amasado",
"status": "operational",
"power_kw": 12.0,
"capacity": 120.0,
"efficiency_percentage": 95.0,
"maintenance_interval_days": 60,
"last_maintenance_offset_days": -15,
"install_date_offset_days": -900
},
{
"id": "30000000-0000-0000-0000-000000000003",
"name": "Cámara de Fermentación 1",
"type": "proofer",
"model": "Mondial Forni PF-2000",
"serial_number": "MF-2023-0445",
"location": "Área de Fermentación",
"status": "operational",
"power_kw": 8.0,
"capacity": 40.0,
"efficiency_percentage": 88.0,
"current_temperature": 28.0,
"target_temperature": 28.0,
"maintenance_interval_days": 90,
"last_maintenance_offset_days": -45,
"install_date_offset_days": -550
},
{
"id": "30000000-0000-0000-0000-000000000004",
"name": "Congelador Rápido",
"type": "freezer",
"model": "Irinox MF 70.2",
"serial_number": "IR-2021-1234",
"location": "Área de Conservación",
"status": "operational",
"power_kw": 15.0,
"capacity": 70.0,
"efficiency_percentage": 90.0,
"current_temperature": -40.0,
"target_temperature": -40.0,
"maintenance_interval_days": 120,
"last_maintenance_offset_days": -60,
"install_date_offset_days": -1460
},
{
"id": "30000000-0000-0000-0000-000000000005",
"name": "Amasadora Pequeña",
"type": "mixer",
"model": "Diosna SP 60",
"serial_number": "DI-2020-0334",
"location": "Área de Amasado",
"status": "warning",
"power_kw": 6.0,
"capacity": 60.0,
"efficiency_percentage": 78.0,
"maintenance_interval_days": 60,
"last_maintenance_offset_days": -55,
"install_date_offset_days": -1825,
"notes": "Eficiencia reducida. Programar inspección preventiva."
},
{
"id": "30000000-0000-0000-0000-000000000006",
"name": "Horno de Convección Auxiliar",
"type": "oven",
"model": "Unox XBC 1065",
"serial_number": "UN-2019-0667",
"location": "Área de Producción - Zona B",
"status": "operational",
"power_kw": 28.0,
"capacity": 10.0,
"efficiency_percentage": 85.0,
"current_temperature": 180.0,
"target_temperature": 180.0,
"maintenance_interval_days": 90,
"last_maintenance_offset_days": -20,
"install_date_offset_days": -2190
}
],
"equipos_central_bakery": [
{
"id": "30000000-0000-0000-0000-000000000011",
"name": "Línea de Producción Automática 1",
"type": "other",
"model": "Mecatherm TH 4500",
"serial_number": "MT-2023-8890",
"location": "Nave Principal - Línea 1",
"status": "operational",
"power_kw": 180.0,
"capacity": 4500.0,
"efficiency_percentage": 96.0,
"maintenance_interval_days": 30,
"last_maintenance_offset_days": -15,
"install_date_offset_days": -400
},
{
"id": "30000000-0000-0000-0000-000000000012",
"name": "Horno Túnel Industrial",
"type": "oven",
"model": "Werner & Pfleiderer HS-3000",
"serial_number": "WP-2022-1156",
"location": "Nave Principal - Línea 1",
"status": "operational",
"power_kw": 250.0,
"capacity": 3000.0,
"efficiency_percentage": 94.0,
"current_temperature": 230.0,
"target_temperature": 230.0,
"maintenance_interval_days": 45,
"last_maintenance_offset_days": -20,
"install_date_offset_days": -1095
},
{
"id": "30000000-0000-0000-0000-000000000013",
"name": "Amasadora Industrial Grande",
"type": "mixer",
"model": "Diosna SP 500",
"serial_number": "DI-2023-1789",
"location": "Zona de Amasado Industrial",
"status": "operational",
"power_kw": 75.0,
"capacity": 500.0,
"efficiency_percentage": 97.0,
"maintenance_interval_days": 60,
"last_maintenance_offset_days": -30,
"install_date_offset_days": -365
},
{
"id": "30000000-0000-0000-0000-000000000014",
"name": "Cámara de Fermentación Industrial 1",
"type": "proofer",
"model": "Sveba Dahlen FC-800",
"serial_number": "SD-2022-3344",
"location": "Zona de Fermentación",
"status": "operational",
"power_kw": 45.0,
"capacity": 800.0,
"efficiency_percentage": 92.0,
"current_temperature": 30.0,
"target_temperature": 30.0,
"maintenance_interval_days": 60,
"last_maintenance_offset_days": -25,
"install_date_offset_days": -1000
},
{
"id": "30000000-0000-0000-0000-000000000015",
"name": "Túnel de Congelación IQF",
"type": "freezer",
"model": "GEA ColdSteam CF-2000",
"serial_number": "GEA-2021-5567",
"location": "Zona de Congelación",
"status": "operational",
"power_kw": 180.0,
"capacity": 2000.0,
"efficiency_percentage": 91.0,
"current_temperature": -45.0,
"target_temperature": -45.0,
"maintenance_interval_days": 90,
"last_maintenance_offset_days": -40,
"install_date_offset_days": -1460
},
{
"id": "30000000-0000-0000-0000-000000000016",
"name": "Línea de Empaquetado Automática",
"type": "packaging",
"model": "Bosch SVE 3600",
"serial_number": "BO-2023-2234",
"location": "Zona de Empaquetado",
"status": "maintenance",
"power_kw": 35.0,
"capacity": 3600.0,
"efficiency_percentage": 88.0,
"maintenance_interval_days": 30,
"last_maintenance_offset_days": -5,
"install_date_offset_days": -300,
"notes": "En mantenimiento programado. Retorno previsto en 2 días."
},
{
"id": "30000000-0000-0000-0000-000000000017",
"name": "Cámara de Fermentación Industrial 2",
"type": "proofer",
"model": "Sveba Dahlen FC-600",
"serial_number": "SD-2020-2211",
"location": "Zona de Fermentación",
"status": "operational",
"power_kw": 35.0,
"capacity": 600.0,
"efficiency_percentage": 89.0,
"current_temperature": 28.0,
"target_temperature": 28.0,
"maintenance_interval_days": 60,
"last_maintenance_offset_days": -35,
"install_date_offset_days": -1825
}
]
}

View File

@@ -0,0 +1,545 @@
{
"lotes_produccion": [
{
"id": "40000000-0000-0000-0000-000000000001",
"batch_number": "BATCH-20250115-001",
"product_id": "20000000-0000-0000-0000-000000000001",
"product_name": "Baguette Francesa Tradicional",
"recipe_id": "30000000-0000-0000-0000-000000000001",
"planned_start_offset_days": -7,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 165,
"planned_quantity": 100.0,
"actual_quantity": 98.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 98.0,
"quality_score": 95.0,
"waste_quantity": 2.0,
"defect_quantity": 0.0,
"estimated_cost": 150.00,
"actual_cost": 148.50,
"labor_cost": 80.00,
"material_cost": 55.00,
"overhead_cost": 13.50,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Producción estándar, sin incidencias",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000002",
"batch_number": "BATCH-20250115-002",
"product_id": "20000000-0000-0000-0000-000000000002",
"product_name": "Croissant de Mantequilla Artesanal",
"recipe_id": "30000000-0000-0000-0000-000000000002",
"planned_start_offset_days": -7,
"planned_start_hour": 5,
"planned_start_minute": 0,
"planned_duration_minutes": 240,
"planned_quantity": 120.0,
"actual_quantity": 115.0,
"status": "COMPLETED",
"priority": "HIGH",
"current_process_stage": "packaging",
"yield_percentage": 95.8,
"quality_score": 92.0,
"waste_quantity": 3.0,
"defect_quantity": 2.0,
"estimated_cost": 280.00,
"actual_cost": 275.00,
"labor_cost": 120.00,
"material_cost": 125.00,
"overhead_cost": 30.00,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Laminado perfecto, buen desarrollo",
"equipment_used": ["50000000-0000-0000-0000-000000000002", "50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000003",
"batch_number": "BATCH-20250116-001",
"product_id": "20000000-0000-0000-0000-000000000003",
"product_name": "Pan de Pueblo con Masa Madre",
"recipe_id": "30000000-0000-0000-0000-000000000003",
"planned_start_offset_days": -6,
"planned_start_hour": 7,
"planned_start_minute": 30,
"planned_duration_minutes": 300,
"planned_quantity": 80.0,
"actual_quantity": 80.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 100.0,
"quality_score": 98.0,
"waste_quantity": 0.0,
"defect_quantity": 0.0,
"estimated_cost": 200.00,
"actual_cost": 195.00,
"labor_cost": 90.00,
"material_cost": 80.00,
"overhead_cost": 25.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": true,
"production_notes": "Excelente fermentación de la masa madre",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000004",
"batch_number": "BATCH-20250116-002",
"product_id": "20000000-0000-0000-0000-000000000004",
"product_name": "Napolitana de Chocolate",
"recipe_id": "30000000-0000-0000-0000-000000000004",
"planned_start_offset_days": -6,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 180,
"planned_quantity": 90.0,
"actual_quantity": 88.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 97.8,
"quality_score": 94.0,
"waste_quantity": 1.0,
"defect_quantity": 1.0,
"estimated_cost": 220.00,
"actual_cost": 218.00,
"labor_cost": 95.00,
"material_cost": 98.00,
"overhead_cost": 25.00,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Distribución uniforme del chocolate",
"equipment_used": ["50000000-0000-0000-0000-000000000001", "50000000-0000-0000-0000-000000000002"]
},
{
"id": "40000000-0000-0000-0000-000000000005",
"batch_number": "BATCH-20250117-001",
"product_id": "20000000-0000-0000-0000-000000000001",
"product_name": "Baguette Francesa Tradicional",
"recipe_id": "30000000-0000-0000-0000-000000000001",
"planned_start_offset_days": -5,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 165,
"planned_quantity": 120.0,
"actual_quantity": 118.0,
"status": "COMPLETED",
"priority": "HIGH",
"current_process_stage": "packaging",
"yield_percentage": 98.3,
"quality_score": 96.0,
"waste_quantity": 1.5,
"defect_quantity": 0.5,
"estimated_cost": 180.00,
"actual_cost": 177.00,
"labor_cost": 95.00,
"material_cost": 65.00,
"overhead_cost": 17.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Lote grande para pedido especial",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000006",
"batch_number": "BATCH-20250117-002",
"product_id": "20000000-0000-0000-0000-000000000002",
"product_name": "Croissant de Mantequilla Artesanal",
"recipe_id": "30000000-0000-0000-0000-000000000002",
"planned_start_offset_days": -5,
"planned_start_hour": 5,
"planned_start_minute": 0,
"planned_duration_minutes": 240,
"planned_quantity": 100.0,
"actual_quantity": 96.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 96.0,
"quality_score": 90.0,
"waste_quantity": 2.0,
"defect_quantity": 2.0,
"estimated_cost": 240.00,
"actual_cost": 238.00,
"labor_cost": 105.00,
"material_cost": 105.00,
"overhead_cost": 28.00,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Algunos croissants con desarrollo irregular",
"quality_notes": "Revisar temperatura de fermentación",
"equipment_used": ["50000000-0000-0000-0000-000000000002", "50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000007",
"batch_number": "BATCH-20250118-001",
"product_id": "20000000-0000-0000-0000-000000000001",
"product_name": "Baguette Francesa Tradicional",
"recipe_id": "30000000-0000-0000-0000-000000000001",
"planned_start_offset_days": -4,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 165,
"planned_quantity": 100.0,
"actual_quantity": 99.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 99.0,
"quality_score": 97.0,
"waste_quantity": 1.0,
"defect_quantity": 0.0,
"estimated_cost": 150.00,
"actual_cost": 149.00,
"labor_cost": 80.00,
"material_cost": 55.00,
"overhead_cost": 14.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Excelente resultado",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000008",
"batch_number": "BATCH-20250118-002",
"product_id": "20000000-0000-0000-0000-000000000003",
"product_name": "Pan de Pueblo con Masa Madre",
"recipe_id": "30000000-0000-0000-0000-000000000003",
"planned_start_offset_days": -4,
"planned_start_hour": 7,
"planned_start_minute": 0,
"planned_duration_minutes": 300,
"planned_quantity": 60.0,
"actual_quantity": 60.0,
"status": "COMPLETED",
"priority": "LOW",
"current_process_stage": "packaging",
"yield_percentage": 100.0,
"quality_score": 99.0,
"waste_quantity": 0.0,
"defect_quantity": 0.0,
"estimated_cost": 155.00,
"actual_cost": 152.00,
"labor_cost": 70.00,
"material_cost": 65.00,
"overhead_cost": 17.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": true,
"production_notes": "Masa madre en punto óptimo",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000009",
"batch_number": "BATCH-20250119-001",
"product_id": "20000000-0000-0000-0000-000000000002",
"product_name": "Croissant de Mantequilla Artesanal",
"recipe_id": "30000000-0000-0000-0000-000000000002",
"planned_start_offset_days": -3,
"planned_start_hour": 5,
"planned_start_minute": 0,
"planned_duration_minutes": 240,
"planned_quantity": 150.0,
"actual_quantity": 145.0,
"status": "COMPLETED",
"priority": "URGENT",
"current_process_stage": "packaging",
"yield_percentage": 96.7,
"quality_score": 93.0,
"waste_quantity": 3.0,
"defect_quantity": 2.0,
"estimated_cost": 350.00,
"actual_cost": 345.00,
"labor_cost": 150.00,
"material_cost": 155.00,
"overhead_cost": 40.00,
"station_id": "STATION-02",
"is_rush_order": true,
"is_special_recipe": false,
"production_notes": "Pedido urgente de evento corporativo",
"equipment_used": ["50000000-0000-0000-0000-000000000002", "50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000010",
"batch_number": "BATCH-20250119-002",
"product_id": "20000000-0000-0000-0000-000000000004",
"product_name": "Napolitana de Chocolate",
"recipe_id": "30000000-0000-0000-0000-000000000004",
"planned_start_offset_days": -3,
"planned_start_hour": 6,
"planned_start_minute": 30,
"planned_duration_minutes": 180,
"planned_quantity": 80.0,
"actual_quantity": 79.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 98.8,
"quality_score": 95.0,
"waste_quantity": 0.5,
"defect_quantity": 0.5,
"estimated_cost": 195.00,
"actual_cost": 192.00,
"labor_cost": 85.00,
"material_cost": 85.00,
"overhead_cost": 22.00,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Buen resultado general",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000011",
"batch_number": "BATCH-20250120-001",
"product_id": "20000000-0000-0000-0000-000000000001",
"product_name": "Baguette Francesa Tradicional",
"recipe_id": "30000000-0000-0000-0000-000000000001",
"planned_start_offset_days": -2,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 165,
"planned_quantity": 110.0,
"actual_quantity": 108.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 98.2,
"quality_score": 96.0,
"waste_quantity": 1.5,
"defect_quantity": 0.5,
"estimated_cost": 165.00,
"actual_cost": 162.00,
"labor_cost": 88.00,
"material_cost": 60.00,
"overhead_cost": 14.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Producción estándar",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000012",
"batch_number": "BATCH-20250120-002",
"product_id": "20000000-0000-0000-0000-000000000003",
"product_name": "Pan de Pueblo con Masa Madre",
"recipe_id": "30000000-0000-0000-0000-000000000003",
"planned_start_offset_days": -2,
"planned_start_hour": 7,
"planned_start_minute": 30,
"planned_duration_minutes": 300,
"planned_quantity": 70.0,
"actual_quantity": 70.0,
"status": "COMPLETED",
"priority": "MEDIUM",
"current_process_stage": "packaging",
"yield_percentage": 100.0,
"quality_score": 98.0,
"waste_quantity": 0.0,
"defect_quantity": 0.0,
"estimated_cost": 175.00,
"actual_cost": 172.00,
"labor_cost": 80.00,
"material_cost": 72.00,
"overhead_cost": 20.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": true,
"production_notes": "Fermentación perfecta",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000013",
"batch_number": "BATCH-20250121-001",
"product_id": "20000000-0000-0000-0000-000000000002",
"product_name": "Croissant de Mantequilla Artesanal",
"recipe_id": "30000000-0000-0000-0000-000000000002",
"planned_start_offset_days": -1,
"planned_start_hour": 5,
"planned_start_minute": 0,
"planned_duration_minutes": 240,
"planned_quantity": 130.0,
"actual_quantity": 125.0,
"status": "COMPLETED",
"priority": "HIGH",
"current_process_stage": "packaging",
"yield_percentage": 96.2,
"quality_score": 94.0,
"waste_quantity": 3.0,
"defect_quantity": 2.0,
"estimated_cost": 310.00,
"actual_cost": 305.00,
"labor_cost": 135.00,
"material_cost": 138.00,
"overhead_cost": 32.00,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Demanda elevada del fin de semana",
"equipment_used": ["50000000-0000-0000-0000-000000000002", "50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000014",
"batch_number": "BATCH-20250121-002",
"product_id": "20000000-0000-0000-0000-000000000001",
"product_name": "Baguette Francesa Tradicional",
"recipe_id": "30000000-0000-0000-0000-000000000001",
"planned_start_offset_days": -1,
"planned_start_hour": 6,
"planned_start_minute": 30,
"planned_duration_minutes": 165,
"planned_quantity": 120.0,
"actual_quantity": 118.0,
"status": "COMPLETED",
"priority": "HIGH",
"current_process_stage": "packaging",
"yield_percentage": 98.3,
"quality_score": 97.0,
"waste_quantity": 1.5,
"defect_quantity": 0.5,
"estimated_cost": 180.00,
"actual_cost": 178.00,
"labor_cost": 95.00,
"material_cost": 66.00,
"overhead_cost": 17.00,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Alta demanda de fin de semana",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000015",
"batch_number": "BATCH-20250122-001",
"product_id": "20000000-0000-0000-0000-000000000001",
"product_name": "Baguette Francesa Tradicional",
"recipe_id": "30000000-0000-0000-0000-000000000001",
"planned_start_offset_days": 0,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 165,
"planned_quantity": 100.0,
"actual_quantity": null,
"status": "IN_PROGRESS",
"priority": "MEDIUM",
"current_process_stage": "baking",
"yield_percentage": null,
"quality_score": null,
"waste_quantity": null,
"defect_quantity": null,
"estimated_cost": 150.00,
"actual_cost": null,
"labor_cost": null,
"material_cost": null,
"overhead_cost": null,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Producción en curso",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000016",
"batch_number": "BATCH-20250122-002",
"product_id": "20000000-0000-0000-0000-000000000002",
"product_name": "Croissant de Mantequilla Artesanal",
"recipe_id": "30000000-0000-0000-0000-000000000002",
"planned_start_offset_days": 0,
"planned_start_hour": 8,
"planned_start_minute": 0,
"planned_duration_minutes": 240,
"planned_quantity": 100.0,
"actual_quantity": null,
"status": "PENDING",
"priority": "MEDIUM",
"current_process_stage": null,
"yield_percentage": null,
"quality_score": null,
"waste_quantity": null,
"defect_quantity": null,
"estimated_cost": 240.00,
"actual_cost": null,
"labor_cost": null,
"material_cost": null,
"overhead_cost": null,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Pendiente de inicio",
"equipment_used": ["50000000-0000-0000-0000-000000000002", "50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000017",
"batch_number": "BATCH-20250123-001",
"product_id": "20000000-0000-0000-0000-000000000003",
"product_name": "Pan de Pueblo con Masa Madre",
"recipe_id": "30000000-0000-0000-0000-000000000003",
"planned_start_offset_days": 1,
"planned_start_hour": 7,
"planned_start_minute": 0,
"planned_duration_minutes": 300,
"planned_quantity": 75.0,
"actual_quantity": null,
"status": "PENDING",
"priority": "MEDIUM",
"current_process_stage": null,
"yield_percentage": null,
"quality_score": null,
"waste_quantity": null,
"defect_quantity": null,
"estimated_cost": 185.00,
"actual_cost": null,
"labor_cost": null,
"material_cost": null,
"overhead_cost": null,
"station_id": "STATION-01",
"is_rush_order": false,
"is_special_recipe": true,
"production_notes": "Planificado para mañana",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
},
{
"id": "40000000-0000-0000-0000-000000000018",
"batch_number": "BATCH-20250123-002",
"product_id": "20000000-0000-0000-0000-000000000004",
"product_name": "Napolitana de Chocolate",
"recipe_id": "30000000-0000-0000-0000-000000000004",
"planned_start_offset_days": 1,
"planned_start_hour": 6,
"planned_start_minute": 0,
"planned_duration_minutes": 180,
"planned_quantity": 85.0,
"actual_quantity": null,
"status": "PENDING",
"priority": "LOW",
"current_process_stage": null,
"yield_percentage": null,
"quality_score": null,
"waste_quantity": null,
"defect_quantity": null,
"estimated_cost": 210.00,
"actual_cost": null,
"labor_cost": null,
"material_cost": null,
"overhead_cost": null,
"station_id": "STATION-02",
"is_rush_order": false,
"is_special_recipe": false,
"production_notes": "Planificado para mañana",
"equipment_used": ["50000000-0000-0000-0000-000000000001"]
}
]
}

View File

@@ -0,0 +1,444 @@
{
"plantillas_calidad": [
{
"id": "40000000-0000-0000-0000-000000000001",
"name": "Control de Peso de Pan",
"template_code": "QC-PESO-PAN-001",
"check_type": "measurement",
"category": "weight_check",
"description": "Verificación del peso del pan después del horneado para asegurar cumplimiento con estándares",
"instructions": "1. Seleccionar 5 panes de forma aleatoria del lote\n2. Pesar cada pan en balanza calibrada\n3. Calcular el peso promedio\n4. Verificar que está dentro de tolerancia\n5. Registrar resultados",
"parameters": {
"sample_size": 5,
"unit": "gramos",
"measurement_method": "balanza_digital"
},
"thresholds": {
"min_acceptable": 95.0,
"max_acceptable": 105.0,
"target": 100.0
},
"scoring_criteria": {
"excellent": {"min": 98.0, "max": 102.0},
"good": {"min": 96.0, "max": 104.0},
"acceptable": {"min": 95.0, "max": 105.0},
"fail": {"below": 95.0, "above": 105.0}
},
"is_active": true,
"is_required": true,
"is_critical": true,
"weight": 1.0,
"min_value": 95.0,
"max_value": 105.0,
"target_value": 100.0,
"unit": "g",
"tolerance_percentage": 5.0,
"applicable_stages": ["baking", "packaging"]
},
{
"id": "40000000-0000-0000-0000-000000000002",
"name": "Control de Temperatura de Masa",
"template_code": "QC-TEMP-MASA-001",
"check_type": "measurement",
"category": "temperature_check",
"description": "Verificación de la temperatura de la masa durante el amasado",
"instructions": "1. Insertar termómetro en el centro de la masa\n2. Esperar 30 segundos para lectura estable\n3. Registrar temperatura\n4. Verificar contra rango objetivo\n5. Ajustar velocidad o tiempo si necesario",
"parameters": {
"measurement_point": "centro_masa",
"wait_time_seconds": 30,
"unit": "celsius"
},
"thresholds": {
"min_acceptable": 24.0,
"max_acceptable": 27.0,
"target": 25.5
},
"scoring_criteria": {
"excellent": {"min": 25.0, "max": 26.0},
"good": {"min": 24.5, "max": 26.5},
"acceptable": {"min": 24.0, "max": 27.0},
"fail": {"below": 24.0, "above": 27.0}
},
"is_active": true,
"is_required": true,
"is_critical": true,
"weight": 1.0,
"min_value": 24.0,
"max_value": 27.0,
"target_value": 25.5,
"unit": "°C",
"tolerance_percentage": 4.0,
"applicable_stages": ["mixing"]
},
{
"id": "40000000-0000-0000-0000-000000000003",
"name": "Inspección Visual de Color",
"template_code": "QC-COLOR-001",
"check_type": "visual",
"category": "appearance",
"description": "Evaluación del color del producto horneado usando escala de referencia",
"instructions": "1. Comparar producto con carta de colores estándar\n2. Verificar uniformidad del dorado\n3. Buscar zonas pálidas o quemadas\n4. Calificar según escala\n5. Rechazar si fuera de tolerancia",
"parameters": {
"reference_standard": "Carta Munsell Panadería",
"lighting": "luz_natural_6500K",
"viewing_angle": "45_grados"
},
"thresholds": {
"min_score": 7.0,
"target_score": 9.0
},
"scoring_criteria": {
"excellent": {"description": "Dorado uniforme, sin manchas", "score": 9.0},
"good": {"description": "Buen color general, mínimas variaciones", "score": 8.0},
"acceptable": {"description": "Color aceptable, ligeras irregularidades", "score": 7.0},
"fail": {"description": "Pálido, quemado o muy irregular", "score": 6.0}
},
"is_active": true,
"is_required": true,
"is_critical": false,
"weight": 0.8,
"min_value": 7.0,
"max_value": 10.0,
"target_value": 9.0,
"unit": "score",
"tolerance_percentage": null,
"applicable_stages": ["baking", "finishing"]
},
{
"id": "40000000-0000-0000-0000-000000000004",
"name": "Control de Humedad de Miga",
"template_code": "QC-HUMEDAD-001",
"check_type": "measurement",
"category": "moisture_check",
"description": "Medición del porcentaje de humedad en la miga del pan",
"instructions": "1. Cortar muestra del centro del pan\n2. Pesar muestra (peso húmedo)\n3. Secar en horno a 105°C durante 3 horas\n4. Pesar muestra seca\n5. Calcular porcentaje de humedad",
"parameters": {
"sample_weight_g": 10.0,
"drying_temp_c": 105.0,
"drying_time_hours": 3.0,
"calculation": "(peso_húmedo - peso_seco) / peso_húmedo * 100"
},
"thresholds": {
"min_acceptable": 35.0,
"max_acceptable": 42.0,
"target": 38.0
},
"scoring_criteria": {
"excellent": {"min": 37.0, "max": 39.0},
"good": {"min": 36.0, "max": 40.0},
"acceptable": {"min": 35.0, "max": 42.0},
"fail": {"below": 35.0, "above": 42.0}
},
"is_active": true,
"is_required": false,
"is_critical": false,
"weight": 0.7,
"min_value": 35.0,
"max_value": 42.0,
"target_value": 38.0,
"unit": "%",
"tolerance_percentage": 8.0,
"applicable_stages": ["cooling", "finishing"]
},
{
"id": "40000000-0000-0000-0000-000000000005",
"name": "Control de Volumen Específico",
"template_code": "QC-VOLUMEN-001",
"check_type": "measurement",
"category": "volume_check",
"description": "Medición del volumen específico del pan (cm³/g) para evaluar calidad de fermentación",
"instructions": "1. Pesar el pan completo\n2. Medir volumen por desplazamiento de semillas\n3. Calcular volumen específico (volumen/peso)\n4. Comparar con estándar de producto\n5. Registrar resultado",
"parameters": {
"measurement_method": "desplazamiento_semillas",
"medium": "semillas_colza",
"calculation": "volumen_cm3 / peso_g"
},
"thresholds": {
"min_acceptable": 3.5,
"max_acceptable": 5.0,
"target": 4.2
},
"scoring_criteria": {
"excellent": {"min": 4.0, "max": 4.5},
"good": {"min": 3.8, "max": 4.7},
"acceptable": {"min": 3.5, "max": 5.0},
"fail": {"below": 3.5, "above": 5.0}
},
"is_active": true,
"is_required": false,
"is_critical": false,
"weight": 0.6,
"min_value": 3.5,
"max_value": 5.0,
"target_value": 4.2,
"unit": "cm³/g",
"tolerance_percentage": 15.0,
"applicable_stages": ["cooling", "finishing"]
},
{
"id": "40000000-0000-0000-0000-000000000006",
"name": "Inspección de Corteza",
"template_code": "QC-CORTEZA-001",
"check_type": "visual",
"category": "appearance",
"description": "Evaluación visual de la calidad de la corteza del pan",
"instructions": "1. Inspeccionar superficie completa del pan\n2. Verificar ausencia de grietas no deseadas\n3. Evaluar brillo y textura\n4. Verificar expansión de cortes\n5. Calificar integridad general",
"parameters": {
"inspection_points": ["grietas", "brillo", "textura", "cortes", "burbujas"]
},
"thresholds": {
"min_score": 7.0,
"target_score": 9.0
},
"scoring_criteria": {
"excellent": {"description": "Corteza perfecta, cortes bien expandidos, sin defectos", "score": 9.0},
"good": {"description": "Corteza buena, mínimos defectos superficiales", "score": 8.0},
"acceptable": {"description": "Corteza aceptable, algunos defectos menores", "score": 7.0},
"fail": {"description": "Grietas excesivas, corteza rota o muy irregular", "score": 6.0}
},
"is_active": true,
"is_required": true,
"is_critical": false,
"weight": 0.8,
"min_value": 7.0,
"max_value": 10.0,
"target_value": 9.0,
"unit": "score",
"tolerance_percentage": null,
"applicable_stages": ["cooling", "finishing"]
},
{
"id": "40000000-0000-0000-0000-000000000007",
"name": "Control de Temperatura de Horneado",
"template_code": "QC-TEMP-HORNO-001",
"check_type": "measurement",
"category": "temperature_check",
"description": "Verificación de la temperatura del horno durante el horneado",
"instructions": "1. Verificar temperatura en termómetro de horno\n2. Confirmar con termómetro independiente\n3. Registrar temperatura cada 5 minutos\n4. Verificar estabilidad\n5. Ajustar si está fuera de rango",
"parameters": {
"measurement_frequency_minutes": 5,
"measurement_points": ["superior", "central", "inferior"],
"acceptable_variation": 5.0
},
"thresholds": {
"min_acceptable": 210.0,
"max_acceptable": 230.0,
"target": 220.0
},
"scoring_criteria": {
"excellent": {"min": 218.0, "max": 222.0},
"good": {"min": 215.0, "max": 225.0},
"acceptable": {"min": 210.0, "max": 230.0},
"fail": {"below": 210.0, "above": 230.0}
},
"is_active": true,
"is_required": true,
"is_critical": true,
"weight": 1.0,
"min_value": 210.0,
"max_value": 230.0,
"target_value": 220.0,
"unit": "°C",
"tolerance_percentage": 2.5,
"applicable_stages": ["baking"]
},
{
"id": "40000000-0000-0000-0000-000000000008",
"name": "Control de Tiempo de Fermentación",
"template_code": "QC-TIEMPO-FERM-001",
"check_type": "measurement",
"category": "time_check",
"description": "Monitoreo del tiempo de fermentación y crecimiento de la masa",
"instructions": "1. Marcar nivel inicial de masa en recipiente\n2. Iniciar cronómetro\n3. Monitorear crecimiento cada 15 minutos\n4. Verificar duplicación de volumen\n5. Registrar tiempo total",
"parameters": {
"target_growth": "duplicar_volumen",
"monitoring_frequency_minutes": 15,
"ambient_conditions": "temperatura_controlada"
},
"thresholds": {
"min_acceptable": 45.0,
"max_acceptable": 75.0,
"target": 60.0
},
"scoring_criteria": {
"excellent": {"min": 55.0, "max": 65.0},
"good": {"min": 50.0, "max": 70.0},
"acceptable": {"min": 45.0, "max": 75.0},
"fail": {"below": 45.0, "above": 75.0}
},
"is_active": true,
"is_required": true,
"is_critical": true,
"weight": 1.0,
"min_value": 45.0,
"max_value": 75.0,
"target_value": 60.0,
"unit": "minutos",
"tolerance_percentage": 15.0,
"applicable_stages": ["proofing"]
},
{
"id": "40000000-0000-0000-0000-000000000009",
"name": "Inspección de Estructura de Miga",
"template_code": "QC-MIGA-001",
"check_type": "visual",
"category": "texture",
"description": "Evaluación de la estructura alveolar de la miga del pan",
"instructions": "1. Cortar pan por la mitad longitudinalmente\n2. Observar distribución de alveolos\n3. Evaluar uniformidad del alveolado\n4. Verificar ausencia de grandes cavidades\n5. Evaluar elasticidad al tacto",
"parameters": {
"cutting_method": "longitudinal_centro",
"evaluation_criteria": ["tamaño_alveolos", "distribución", "uniformidad", "elasticidad"]
},
"thresholds": {
"min_score": 7.0,
"target_score": 9.0
},
"scoring_criteria": {
"excellent": {"description": "Alveolado fino y uniforme, miga elástica", "score": 9.0},
"good": {"description": "Buen alveolado, ligeras variaciones", "score": 8.0},
"acceptable": {"description": "Alveolado aceptable, algunas irregularidades", "score": 7.0},
"fail": {"description": "Miga densa, grandes cavidades o muy irregular", "score": 6.0}
},
"is_active": true,
"is_required": true,
"is_critical": false,
"weight": 0.8,
"min_value": 7.0,
"max_value": 10.0,
"target_value": 9.0,
"unit": "score",
"tolerance_percentage": null,
"applicable_stages": ["finishing"]
},
{
"id": "40000000-0000-0000-0000-000000000010",
"name": "Control de pH de Masa",
"template_code": "QC-PH-MASA-001",
"check_type": "measurement",
"category": "chemical",
"description": "Medición del pH de la masa para verificar acidez correcta",
"instructions": "1. Tomar muestra de 10g de masa\n2. Diluir en 90ml de agua destilada\n3. Homogeneizar bien\n4. Medir pH con peachímetro calibrado\n5. Registrar lectura",
"parameters": {
"sample_size_g": 10.0,
"dilution_ml": 90.0,
"calibration_required": true,
"measurement_temp_c": 20.0
},
"thresholds": {
"min_acceptable": 5.0,
"max_acceptable": 5.8,
"target": 5.4
},
"scoring_criteria": {
"excellent": {"min": 5.3, "max": 5.5},
"good": {"min": 5.2, "max": 5.6},
"acceptable": {"min": 5.0, "max": 5.8},
"fail": {"below": 5.0, "above": 5.8}
},
"is_active": true,
"is_required": false,
"is_critical": false,
"weight": 0.5,
"min_value": 5.0,
"max_value": 5.8,
"target_value": 5.4,
"unit": "pH",
"tolerance_percentage": 5.0,
"applicable_stages": ["mixing", "proofing"]
},
{
"id": "40000000-0000-0000-0000-000000000011",
"name": "Control de Dimensiones",
"template_code": "QC-DIM-001",
"check_type": "measurement",
"category": "dimensions",
"description": "Verificación de las dimensiones del producto terminado",
"instructions": "1. Medir largo con cinta métrica\n2. Medir ancho en punto más amplio\n3. Medir altura en punto máximo\n4. Verificar contra especificaciones\n5. Calcular promedio de 3 muestras",
"parameters": {
"sample_size": 3,
"measurement_tool": "calibre_digital",
"precision_mm": 1.0
},
"thresholds": {
"length_mm": {"min": 280.0, "max": 320.0, "target": 300.0},
"width_mm": {"min": 140.0, "max": 160.0, "target": 150.0},
"height_mm": {"min": 90.0, "max": 110.0, "target": 100.0}
},
"scoring_criteria": {
"excellent": {"description": "Todas las dimensiones dentro de ±3%", "score": 9.0},
"good": {"description": "Dimensiones dentro de ±5%", "score": 8.0},
"acceptable": {"description": "Dimensiones dentro de tolerancia", "score": 7.0},
"fail": {"description": "Una o más dimensiones fuera de tolerancia", "score": 6.0}
},
"is_active": true,
"is_required": true,
"is_critical": false,
"weight": 0.7,
"min_value": 7.0,
"max_value": 10.0,
"target_value": 9.0,
"unit": "score",
"tolerance_percentage": 5.0,
"applicable_stages": ["packaging", "finishing"]
},
{
"id": "40000000-0000-0000-0000-000000000012",
"name": "Inspección de Higiene y Limpieza",
"template_code": "QC-HIGIENE-001",
"check_type": "checklist",
"category": "hygiene",
"description": "Verificación de condiciones higiénicas durante la producción",
"instructions": "1. Verificar limpieza de superficies de trabajo\n2. Inspeccionar vestimenta del personal\n3. Verificar lavado de manos\n4. Revisar limpieza de equipos\n5. Completar checklist",
"parameters": {
"checklist_items": [
"superficies_limpias",
"uniformes_limpios",
"manos_lavadas",
"equipos_sanitizados",
"ausencia_contaminantes",
"temperatura_ambiente_correcta"
]
},
"thresholds": {
"min_items_passed": 5,
"total_items": 6
},
"scoring_criteria": {
"excellent": {"items_passed": 6, "description": "100% cumplimiento"},
"good": {"items_passed": 5, "description": "Cumplimiento aceptable"},
"fail": {"items_passed": 4, "description": "Incumplimiento inaceptable"}
},
"is_active": true,
"is_required": true,
"is_critical": true,
"weight": 1.0,
"min_value": 5.0,
"max_value": 6.0,
"target_value": 6.0,
"unit": "items",
"tolerance_percentage": null,
"applicable_stages": ["mixing", "shaping", "baking", "packaging"]
}
],
"notas": {
"total_plantillas": 12,
"distribucion": {
"mediciones": 7,
"visuales": 3,
"checklist": 1,
"quimicas": 1
},
"criticidad": {
"criticas": 6,
"no_criticas": 6
},
"etapas_aplicables": [
"mixing",
"proofing",
"baking",
"cooling",
"packaging",
"finishing"
]
}
}

View File

@@ -0,0 +1,302 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Production Batches Seeding Script for Production Service
Creates production batches for demo template tenants
This script runs as a Kubernetes init job inside the production-service container.
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.production import ProductionBatch, ProductionStatus, ProductionPriority, ProcessStage
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_batches_data():
"""Load production batches data from JSON file"""
data_file = Path(__file__).parent / "lotes_produccion_es.json"
if not data_file.exists():
raise FileNotFoundError(f"Production batches data file not found: {data_file}")
with open(data_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_datetime_from_offset(offset_days: int, hour: int, minute: int) -> datetime:
"""Calculate a datetime based on offset from BASE_REFERENCE_DATE"""
base_date = BASE_REFERENCE_DATE.replace(hour=hour, minute=minute, second=0, microsecond=0)
return base_date + timedelta(days=offset_days)
def map_status(status_str: str) -> ProductionStatus:
"""Map status string to enum"""
mapping = {
"PENDING": ProductionStatus.PENDING,
"IN_PROGRESS": ProductionStatus.IN_PROGRESS,
"COMPLETED": ProductionStatus.COMPLETED,
"CANCELLED": ProductionStatus.CANCELLED,
"ON_HOLD": ProductionStatus.ON_HOLD,
"QUALITY_CHECK": ProductionStatus.QUALITY_CHECK,
"FAILED": ProductionStatus.FAILED
}
return mapping.get(status_str, ProductionStatus.PENDING)
def map_priority(priority_str: str) -> ProductionPriority:
"""Map priority string to enum"""
mapping = {
"LOW": ProductionPriority.LOW,
"MEDIUM": ProductionPriority.MEDIUM,
"HIGH": ProductionPriority.HIGH,
"URGENT": ProductionPriority.URGENT
}
return mapping.get(priority_str, ProductionPriority.MEDIUM)
def map_process_stage(stage_str: str) -> ProcessStage:
"""Map process stage string to enum"""
if not stage_str:
return None
mapping = {
"mixing": ProcessStage.MIXING,
"proofing": ProcessStage.PROOFING,
"shaping": ProcessStage.SHAPING,
"baking": ProcessStage.BAKING,
"cooling": ProcessStage.COOLING,
"packaging": ProcessStage.PACKAGING,
"finishing": ProcessStage.FINISHING
}
return mapping.get(stage_str, None)
async def seed_batches_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
batches_list: list
):
"""Seed production batches for a specific tenant"""
logger.info(f"Seeding production batches for: {tenant_name}", tenant_id=str(tenant_id))
# Check if batches already exist
result = await db.execute(
select(ProductionBatch).where(ProductionBatch.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Production batches already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "batches_created": 0, "skipped": True}
count = 0
for batch_data in batches_list:
# Calculate planned start and end times
planned_start = calculate_datetime_from_offset(
batch_data["planned_start_offset_days"],
batch_data["planned_start_hour"],
batch_data["planned_start_minute"]
)
planned_end = planned_start + timedelta(minutes=batch_data["planned_duration_minutes"])
# Calculate actual times for completed batches
actual_start = None
actual_end = None
completed_at = None
actual_duration = None
if batch_data["status"] in ["COMPLETED", "QUALITY_CHECK"]:
actual_start = planned_start # Assume started on time
actual_duration = batch_data["planned_duration_minutes"]
actual_end = actual_start + timedelta(minutes=actual_duration)
completed_at = actual_end
elif batch_data["status"] == "IN_PROGRESS":
actual_start = planned_start
actual_duration = None
actual_end = None
# For San Pablo, use original IDs. For La Espiga, generate new UUIDs
if tenant_id == DEMO_TENANT_SAN_PABLO:
batch_id = uuid.UUID(batch_data["id"])
else:
# Generate deterministic UUID for La Espiga based on original ID
base_uuid = uuid.UUID(batch_data["id"])
# Add a fixed offset to create a unique but deterministic ID
batch_id = uuid.UUID(int=base_uuid.int + 0x10000000000000000000000000000000)
# Map enums
status = map_status(batch_data["status"])
priority = map_priority(batch_data["priority"])
current_stage = map_process_stage(batch_data.get("current_process_stage"))
# Create unique batch number for each tenant
if tenant_id == DEMO_TENANT_SAN_PABLO:
batch_number = batch_data["batch_number"]
else:
# For La Espiga, append tenant suffix to make batch number unique
batch_number = batch_data["batch_number"] + "-LE"
# Create production batch
batch = ProductionBatch(
id=batch_id,
tenant_id=tenant_id,
batch_number=batch_number,
product_id=uuid.UUID(batch_data["product_id"]),
product_name=batch_data["product_name"],
recipe_id=uuid.UUID(batch_data["recipe_id"]) if batch_data.get("recipe_id") else None,
planned_start_time=planned_start,
planned_end_time=planned_end,
planned_quantity=batch_data["planned_quantity"],
planned_duration_minutes=batch_data["planned_duration_minutes"],
actual_start_time=actual_start,
actual_end_time=actual_end,
actual_quantity=batch_data.get("actual_quantity"),
actual_duration_minutes=actual_duration,
status=status,
priority=priority,
current_process_stage=current_stage,
yield_percentage=batch_data.get("yield_percentage"),
quality_score=batch_data.get("quality_score"),
waste_quantity=batch_data.get("waste_quantity"),
defect_quantity=batch_data.get("defect_quantity"),
estimated_cost=batch_data.get("estimated_cost"),
actual_cost=batch_data.get("actual_cost"),
labor_cost=batch_data.get("labor_cost"),
material_cost=batch_data.get("material_cost"),
overhead_cost=batch_data.get("overhead_cost"),
equipment_used=batch_data.get("equipment_used"),
station_id=batch_data.get("station_id"),
is_rush_order=batch_data.get("is_rush_order", False),
is_special_recipe=batch_data.get("is_special_recipe", False),
production_notes=batch_data.get("production_notes"),
quality_notes=batch_data.get("quality_notes"),
created_at=BASE_REFERENCE_DATE,
updated_at=BASE_REFERENCE_DATE,
completed_at=completed_at
)
db.add(batch)
count += 1
logger.debug(f"Created production batch: {batch.batch_number}", batch_id=str(batch.id))
await db.commit()
logger.info(f"Successfully created {count} production batches for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"batches_created": count,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with production batches"""
logger.info("Starting demo production batches seed process")
# Load batches data
data = load_batches_data()
results = []
# Both tenants get the same production batches
result_san_pablo = await seed_batches_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
data["lotes_produccion"]
)
results.append(result_san_pablo)
result_la_espiga = await seed_batches_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
data["lotes_produccion"]
)
results.append(result_la_espiga)
total_created = sum(r["batches_created"] for r in results)
return {
"results": results,
"total_batches_created": total_created,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("PRODUCTION_DATABASE_URL")
if not database_url:
logger.error("PRODUCTION_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Production batches seed completed successfully!",
total_batches=result["total_batches_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO PRODUCTION BATCHES SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
count = tenant_result["batches_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {count} batches"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Batches Created: {result['total_batches_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Production batches seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Equipment Seeding Script for Production Service
Creates production equipment for demo template tenants
This script runs as a Kubernetes init job inside the production-service container.
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.production import Equipment, EquipmentType, EquipmentStatus
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_equipment_data():
"""Load equipment data from JSON file"""
data_file = Path(__file__).parent / "equipos_es.json"
if not data_file.exists():
raise FileNotFoundError(f"Equipment data file not found: {data_file}")
with open(data_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_date_from_offset(offset_days: int) -> datetime:
"""Calculate a date based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
async def seed_equipment_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
equipment_list: list
):
"""Seed equipment for a specific tenant"""
logger.info(f"Seeding equipment for: {tenant_name}", tenant_id=str(tenant_id))
# Check if equipment already exists
result = await db.execute(
select(Equipment).where(Equipment.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Equipment already exists for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "equipment_created": 0, "skipped": True}
count = 0
for equip_data in equipment_list:
# Calculate dates from offsets
install_date = None
if "install_date_offset_days" in equip_data:
install_date = calculate_date_from_offset(equip_data["install_date_offset_days"])
last_maintenance_date = None
if "last_maintenance_offset_days" in equip_data:
last_maintenance_date = calculate_date_from_offset(equip_data["last_maintenance_offset_days"])
# Calculate next maintenance date
next_maintenance_date = None
if last_maintenance_date and equip_data.get("maintenance_interval_days"):
next_maintenance_date = last_maintenance_date + timedelta(
days=equip_data["maintenance_interval_days"]
)
# Map status string to enum
status_mapping = {
"operational": EquipmentStatus.OPERATIONAL,
"warning": EquipmentStatus.WARNING,
"maintenance": EquipmentStatus.MAINTENANCE,
"down": EquipmentStatus.DOWN
}
status = status_mapping.get(equip_data["status"], EquipmentStatus.OPERATIONAL)
# Map type string to enum
type_mapping = {
"oven": EquipmentType.OVEN,
"mixer": EquipmentType.MIXER,
"proofer": EquipmentType.PROOFER,
"freezer": EquipmentType.FREEZER,
"packaging": EquipmentType.PACKAGING,
"other": EquipmentType.OTHER
}
equipment_type = type_mapping.get(equip_data["type"], EquipmentType.OTHER)
# Create equipment
equipment = Equipment(
id=uuid.UUID(equip_data["id"]),
tenant_id=tenant_id,
name=equip_data["name"],
type=equipment_type,
model=equip_data.get("model"),
serial_number=equip_data.get("serial_number"),
location=equip_data.get("location"),
status=status,
power_kw=equip_data.get("power_kw"),
capacity=equip_data.get("capacity"),
efficiency_percentage=equip_data.get("efficiency_percentage"),
current_temperature=equip_data.get("current_temperature"),
target_temperature=equip_data.get("target_temperature"),
maintenance_interval_days=equip_data.get("maintenance_interval_days"),
last_maintenance_date=last_maintenance_date,
next_maintenance_date=next_maintenance_date,
install_date=install_date,
notes=equip_data.get("notes"),
created_at=BASE_REFERENCE_DATE,
updated_at=BASE_REFERENCE_DATE
)
db.add(equipment)
count += 1
logger.debug(f"Created equipment: {equipment.name}", equipment_id=str(equipment.id))
await db.commit()
logger.info(f"Successfully created {count} equipment items for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"equipment_created": count,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with equipment"""
logger.info("Starting demo equipment seed process")
# Load equipment data
data = load_equipment_data()
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await seed_equipment_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
data["equipos_individual_bakery"]
)
results.append(result_san_pablo)
# Seed La Espiga (Central Bakery)
result_la_espiga = await seed_equipment_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
data["equipos_central_bakery"]
)
results.append(result_la_espiga)
total_created = sum(r["equipment_created"] for r in results)
return {
"results": results,
"total_equipment_created": total_created,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("PRODUCTION_DATABASE_URL")
if not database_url:
logger.error("PRODUCTION_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Equipment seed completed successfully!",
total_equipment=result["total_equipment_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO EQUIPMENT SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
count = tenant_result["equipment_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {count} items"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Equipment Created: {result['total_equipment_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Equipment seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Quality Templates Seeding Script for Production Service
Creates quality check templates for demo template tenants
This script runs as a Kubernetes init job inside the production-service container.
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.production import QualityCheckTemplate
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# System user ID (first admin user from auth service)
SYSTEM_USER_ID = uuid.UUID("30000000-0000-0000-0000-000000000001")
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_quality_templates_data():
"""Load quality templates data from JSON file"""
data_file = Path(__file__).parent / "plantillas_calidad_es.json"
if not data_file.exists():
raise FileNotFoundError(f"Quality templates data file not found: {data_file}")
with open(data_file, 'r', encoding='utf-8') as f:
return json.load(f)
# Model uses simple strings, no need for enum mapping functions
async def seed_quality_templates_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
templates_list: list
):
"""Seed quality templates for a specific tenant"""
logger.info(f"Seeding quality templates for: {tenant_name}", tenant_id=str(tenant_id))
# Check if templates already exist
result = await db.execute(
select(QualityCheckTemplate).where(QualityCheckTemplate.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Quality templates already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "templates_created": 0, "skipped": True}
count = 0
for template_data in templates_list:
# Use strings directly (model doesn't use enums)
check_type = template_data["check_type"]
applicable_stages = template_data.get("applicable_stages", [])
# For San Pablo, use original IDs. For La Espiga, generate new UUIDs
if tenant_id == DEMO_TENANT_SAN_PABLO:
template_id = uuid.UUID(template_data["id"])
else:
# Generate deterministic UUID for La Espiga based on original ID
base_uuid = uuid.UUID(template_data["id"])
# Add a fixed offset to create a unique but deterministic ID
template_id = uuid.UUID(int=base_uuid.int + 0x10000000000000000000000000000000)
# Create quality check template
template = QualityCheckTemplate(
id=template_id,
tenant_id=tenant_id,
name=template_data["name"],
template_code=template_data["template_code"],
check_type=check_type,
category=template_data.get("category"),
description=template_data.get("description"),
instructions=template_data.get("instructions"),
parameters=template_data.get("parameters"),
thresholds=template_data.get("thresholds"),
scoring_criteria=template_data.get("scoring_criteria"),
is_active=template_data.get("is_active", True),
is_required=template_data.get("is_required", False),
is_critical=template_data.get("is_critical", False),
weight=template_data.get("weight", 1.0),
min_value=template_data.get("min_value"),
max_value=template_data.get("max_value"),
target_value=template_data.get("target_value"),
unit=template_data.get("unit"),
tolerance_percentage=template_data.get("tolerance_percentage"),
applicable_stages=applicable_stages,
created_by=SYSTEM_USER_ID,
created_at=BASE_REFERENCE_DATE,
updated_at=BASE_REFERENCE_DATE
)
db.add(template)
count += 1
logger.debug(f"Created quality template: {template.name}", template_id=str(template.id))
await db.commit()
logger.info(f"Successfully created {count} quality templates for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"templates_created": count,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with quality templates"""
logger.info("Starting demo quality templates seed process")
# Load quality templates data
data = load_quality_templates_data()
results = []
# Both tenants get the same quality templates
result_san_pablo = await seed_quality_templates_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
data["plantillas_calidad"]
)
results.append(result_san_pablo)
result_la_espiga = await seed_quality_templates_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
data["plantillas_calidad"]
)
results.append(result_la_espiga)
total_created = sum(r["templates_created"] for r in results)
return {
"results": results,
"total_templates_created": total_created,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("PRODUCTION_DATABASE_URL")
if not database_url:
logger.error("PRODUCTION_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Quality templates seed completed successfully!",
total_templates=result["total_templates_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO QUALITY TEMPLATES SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
count = tenant_result["templates_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {count} templates"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Templates Created: {result['total_templates_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Quality templates seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Recipes Seeding Script for Recipes Service
Creates realistic Spanish recipes for demo template tenants

View File

@@ -4,7 +4,7 @@ Sales Analytics API - Reporting, statistics, and insights
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import Optional
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
@@ -12,6 +12,7 @@ import structlog
from app.services.sales_service import SalesService
from shared.routing import RouteBuilder
from shared.auth.access_control import analytics_tier_required
from shared.auth.decorators import get_current_user_dep
route_builder = RouteBuilder('sales')
router = APIRouter(tags=["sales-analytics"])
@@ -31,6 +32,7 @@ async def get_sales_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Start date filter"),
end_date: Optional[datetime] = Query(None, description="End date filter"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
sales_service: SalesService = Depends(get_sales_service)
):
"""Get sales analytics summary for a tenant (Professional+ tier required)"""

View File

@@ -39,7 +39,7 @@ class SalesDataBase(BaseModel):
@validator('source')
def validate_source(cls, v):
allowed_sources = ['manual', 'pos', 'online', 'import', 'api', 'csv']
allowed_sources = ['manual', 'pos', 'online', 'import', 'api', 'csv', 'demo_clone']
if v not in allowed_sources:
raise ValueError(f'Source must be one of: {allowed_sources}')
return v

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Sales Seeding Script for Sales Service
Creates realistic historical sales data for demo template tenants

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Suppliers Seeding Script for Suppliers Service
Creates realistic Spanish suppliers for demo template tenants using pre-defined UUIDs

View File

@@ -179,23 +179,122 @@ async def clone_demo_data(
db.add(tenant)
await db.flush() # Flush to get the tenant ID
# Create tenant member record for the demo owner
# Create tenant member records for demo owner and staff
from app.models.tenants import TenantMember
import json
tenant_member = TenantMember(
tenant_id=virtual_uuid,
user_id=demo_owner_uuid,
role="owner",
permissions=json.dumps(["read", "write", "admin"]), # Convert list to JSON string
is_active=True,
invited_by=demo_owner_uuid,
invited_at=datetime.now(timezone.utc),
joined_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc)
)
# Helper function to get permissions for role
def get_permissions_for_role(role: str) -> str:
permission_map = {
"owner": ["read", "write", "admin", "delete"],
"admin": ["read", "write", "admin"],
"production_manager": ["read", "write"],
"baker": ["read", "write"],
"sales": ["read", "write"],
"quality_control": ["read", "write"],
"warehouse": ["read", "write"],
"logistics": ["read", "write"],
"procurement": ["read", "write"],
"maintenance": ["read", "write"],
"member": ["read", "write"],
"viewer": ["read"]
}
permissions = permission_map.get(role, ["read"])
return json.dumps(permissions)
db.add(tenant_member)
# Define staff users for each demo account type (must match seed_demo_tenant_members.py)
STAFF_USERS = {
"individual_bakery": [
# Owner
{
"user_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"),
"role": "owner"
},
# Staff
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000001"),
"role": "baker"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000002"),
"role": "sales"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000003"),
"role": "quality_control"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000004"),
"role": "admin"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000005"),
"role": "warehouse"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000006"),
"role": "production_manager"
}
],
"central_baker": [
# Owner
{
"user_id": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"),
"role": "owner"
},
# Staff
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000011"),
"role": "production_manager"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000012"),
"role": "quality_control"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000013"),
"role": "logistics"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000014"),
"role": "sales"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000015"),
"role": "procurement"
},
{
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000016"),
"role": "maintenance"
}
]
}
# Get staff users for this demo account type
staff_users = STAFF_USERS.get(demo_account_type, [])
# Create tenant member records for all users (owner + staff)
members_created = 0
for staff_member in staff_users:
tenant_member = TenantMember(
tenant_id=virtual_uuid,
user_id=staff_member["user_id"],
role=staff_member["role"],
permissions=get_permissions_for_role(staff_member["role"]),
is_active=True,
invited_by=demo_owner_uuid,
invited_at=datetime.now(timezone.utc),
joined_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc)
)
db.add(tenant_member)
members_created += 1
logger.info(
"Created tenant members for virtual tenant",
virtual_tenant_id=virtual_tenant_id,
members_created=members_created
)
# Clone subscription from template tenant
from app.models.tenants import Subscription
@@ -255,17 +354,21 @@ async def clone_demo_data(
duration_ms=duration_ms
)
records_cloned = 1 + members_created # Tenant + TenantMembers
if template_subscription:
records_cloned += 1 # Subscription
return {
"service": "tenant",
"status": "completed",
"records_cloned": 3 if template_subscription else 2, # Tenant + TenantMember + Subscription (if found)
"records_cloned": records_cloned,
"duration_ms": duration_ms,
"details": {
"tenant_id": str(tenant.id),
"tenant_name": tenant.name,
"business_model": tenant.business_model,
"owner_id": str(demo_owner_uuid),
"member_created": True,
"members_created": members_created,
"subscription_plan": subscription_plan,
"subscription_cloned": template_subscription is not None
}

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Subscription Seeding Script for Tenant Service
Creates subscriptions for demo template tenants

View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Tenant Members Seeding Script for Tenant Service
Links demo staff users to their respective template tenants
This script creates TenantMember records that link the demo staff users
(created by auth service) to the demo template tenants. Without these links,
staff users won't appear in the "Gestión de equipos" (team management) section.
Usage:
python /app/scripts/demo/seed_demo_tenant_members.py
Environment Variables Required:
TENANT_DATABASE_URL - PostgreSQL connection string for tenant database
LOG_LEVEL - Logging level (default: INFO)
"""
import asyncio
import uuid
import sys
import os
from datetime import datetime, timezone
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
import json
from app.models.tenants import TenantMember, Tenant
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match seed_demo_tenants.py)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
# Owner user IDs (must match seed_demo_users.py)
OWNER_SAN_PABLO = uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6") # María García López
OWNER_LA_ESPIGA = uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7") # Carlos Martínez Ruiz
def get_permissions_for_role(role: str) -> str:
"""Get default permissions JSON string for a role"""
permission_map = {
"owner": ["read", "write", "admin", "delete"],
"admin": ["read", "write", "admin"],
"production_manager": ["read", "write"],
"baker": ["read", "write"],
"sales": ["read", "write"],
"quality_control": ["read", "write"],
"warehouse": ["read", "write"],
"logistics": ["read", "write"],
"procurement": ["read", "write"],
"maintenance": ["read", "write"],
"member": ["read", "write"],
"viewer": ["read"]
}
permissions = permission_map.get(role, ["read"])
return json.dumps(permissions)
# Tenant Members Data
# These IDs and roles must match usuarios_staff_es.json
TENANT_MEMBERS_DATA = [
# San Pablo Members (Panadería Individual)
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"), # María García López
"role": "owner",
"invited_by": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"),
"is_owner": True
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000001"), # Juan Pérez Moreno - Panadero Senior
"role": "baker",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000002"), # Ana Rodríguez Sánchez - Responsable de Ventas
"role": "sales",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000003"), # Luis Fernández García - Inspector de Calidad
"role": "quality_control",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000004"), # Carmen López Martínez - Administradora
"role": "admin",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000005"), # Pedro González Torres - Encargado de Almacén
"role": "warehouse",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_SAN_PABLO,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000006"), # Isabel Romero Díaz - Jefa de Producción
"role": "production_manager",
"invited_by": OWNER_SAN_PABLO,
"is_owner": False
},
# La Espiga Members (Obrador Central)
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"), # Carlos Martínez Ruiz
"role": "owner",
"invited_by": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"),
"is_owner": True
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000011"), # Roberto Sánchez Vargas - Director de Producción
"role": "production_manager",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000012"), # Sofía Jiménez Ortega - Responsable de Control de Calidad
"role": "quality_control",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000013"), # Miguel Herrera Castro - Coordinador de Logística
"role": "logistics",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000014"), # Elena Morales Ruiz - Directora Comercial
"role": "sales",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000015"), # Javier Navarro Prieto - Responsable de Compras
"role": "procurement",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
{
"tenant_id": DEMO_TENANT_LA_ESPIGA,
"user_id": uuid.UUID("50000000-0000-0000-0000-000000000016"), # Laura Delgado Santos - Técnica de Mantenimiento
"role": "maintenance",
"invited_by": OWNER_LA_ESPIGA,
"is_owner": False
},
]
async def seed_tenant_members(db: AsyncSession) -> dict:
"""
Seed tenant members for demo template tenants
Returns:
Dict with seeding statistics
"""
logger.info("=" * 80)
logger.info("👥 Starting Demo Tenant Members Seeding")
logger.info("=" * 80)
created_count = 0
updated_count = 0
skipped_count = 0
# First, verify that template tenants exist
for tenant_id in [DEMO_TENANT_SAN_PABLO, DEMO_TENANT_LA_ESPIGA]:
result = await db.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
tenant = result.scalars().first()
if not tenant:
logger.error(
f"Template tenant not found: {tenant_id}",
tenant_id=str(tenant_id)
)
logger.error("Please run seed_demo_tenants.py first!")
return {
"service": "tenant_members",
"created": 0,
"updated": 0,
"skipped": 0,
"error": "Template tenants not found"
}
logger.info(
f"✓ Template tenant found: {tenant.name}",
tenant_id=str(tenant_id),
tenant_name=tenant.name
)
# Now seed the tenant members
for member_data in TENANT_MEMBERS_DATA:
tenant_id = member_data["tenant_id"]
user_id = member_data["user_id"]
role = member_data["role"]
invited_by = member_data["invited_by"]
is_owner = member_data.get("is_owner", False)
# Check if member already exists
result = await db.execute(
select(TenantMember).where(
TenantMember.tenant_id == tenant_id,
TenantMember.user_id == user_id
)
)
existing_member = result.scalars().first()
if existing_member:
# Member exists - check if update needed
needs_update = (
existing_member.role != role or
existing_member.is_active != True or
existing_member.invited_by != invited_by
)
if needs_update:
logger.info(
"Tenant member exists - updating",
tenant_id=str(tenant_id),
user_id=str(user_id),
old_role=existing_member.role,
new_role=role
)
existing_member.role = role
existing_member.is_active = True
existing_member.invited_by = invited_by
existing_member.permissions = get_permissions_for_role(role)
existing_member.updated_at = datetime.now(timezone.utc)
updated_count += 1
else:
logger.debug(
"Tenant member already exists - skipping",
tenant_id=str(tenant_id),
user_id=str(user_id),
role=role
)
skipped_count += 1
continue
# Create new tenant member
logger.info(
"Creating tenant member",
tenant_id=str(tenant_id),
user_id=str(user_id),
role=role,
is_owner=is_owner
)
tenant_member = TenantMember(
tenant_id=tenant_id,
user_id=user_id,
role=role,
permissions=get_permissions_for_role(role),
is_active=True,
invited_by=invited_by,
invited_at=datetime.now(timezone.utc),
joined_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc)
)
db.add(tenant_member)
created_count += 1
# Commit all changes
await db.commit()
logger.info("=" * 80)
logger.info(
"✅ Demo Tenant Members Seeding Completed",
created=created_count,
updated=updated_count,
skipped=skipped_count,
total=len(TENANT_MEMBERS_DATA)
)
logger.info("=" * 80)
return {
"service": "tenant_members",
"created": created_count,
"updated": updated_count,
"skipped": skipped_count,
"total": len(TENANT_MEMBERS_DATA)
}
async def main():
"""Main execution function"""
logger.info("Demo Tenant Members Seeding Script Starting")
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("TENANT_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ TENANT_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Convert to async URL if needed
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to tenant database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with async_session() as session:
result = await seed_tenant_members(session)
if "error" in result:
logger.error(f"❌ Seeding failed: {result['error']}")
return 1
logger.info("")
logger.info("📊 Seeding Summary:")
logger.info(f" ✅ Created: {result['created']}")
logger.info(f" 🔄 Updated: {result['updated']}")
logger.info(f" ⏭️ Skipped: {result['skipped']}")
logger.info(f" 📦 Total: {result['total']}")
logger.info("")
logger.info("🎉 Success! Demo staff users are now linked to their tenants.")
logger.info("")
logger.info("Next steps:")
logger.info(" 1. Verify tenant members in database")
logger.info(" 2. Test 'Gestión de equipos' in the frontend")
logger.info(" 3. All staff users should now be visible!")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Tenant Members Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Tenant Seeding Script for Tenant Service
Creates the two demo template tenants: San Pablo and La Espiga