Improve demo seed
This commit is contained in:
@@ -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:
|
||||
|
||||
204
services/auth/scripts/demo/usuarios_staff_es.json
Normal file
204
services/auth/scripts/demo/usuarios_staff_es.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
307
services/forecasting/scripts/demo/previsiones_config_es.json
Normal file
307
services/forecasting/scripts/demo/previsiones_config_es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
498
services/forecasting/scripts/demo/seed_demo_forecasts.py
Executable file
498
services/forecasting/scripts/demo/seed_demo_forecasts.py
Executable 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
423
services/inventory/scripts/demo/seed_demo_stock.py
Normal file
423
services/inventory/scripts/demo/seed_demo_stock.py
Normal 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)
|
||||
49
services/inventory/scripts/demo/stock_lotes_es.json
Normal file
49
services/inventory/scripts/demo/stock_lotes_es.json
Normal 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": []
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
266
services/orders/scripts/demo/compras_config_es.json
Normal file
266
services/orders/scripts/demo/compras_config_es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
220
services/orders/scripts/demo/pedidos_config_es.json
Normal file
220
services/orders/scripts/demo/pedidos_config_es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
230
services/orders/scripts/demo/seed_demo_customers.py
Executable file
230
services/orders/scripts/demo/seed_demo_customers.py
Executable 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)
|
||||
396
services/orders/scripts/demo/seed_demo_orders.py
Executable file
396
services/orders/scripts/demo/seed_demo_orders.py
Executable 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)
|
||||
496
services/orders/scripts/demo/seed_demo_procurement.py
Executable file
496
services/orders/scripts/demo/seed_demo_procurement.py
Executable 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class QualityCheckType(str, Enum):
|
||||
WEIGHT = "weight"
|
||||
BOOLEAN = "boolean"
|
||||
TIMING = "timing"
|
||||
CHECKLIST = "checklist"
|
||||
|
||||
|
||||
class QualityCheckTemplateBase(BaseModel):
|
||||
|
||||
219
services/production/scripts/demo/equipos_es.json
Normal file
219
services/production/scripts/demo/equipos_es.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
545
services/production/scripts/demo/lotes_produccion_es.json
Normal file
545
services/production/scripts/demo/lotes_produccion_es.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
444
services/production/scripts/demo/plantillas_calidad_es.json
Normal file
444
services/production/scripts/demo/plantillas_calidad_es.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
302
services/production/scripts/demo/seed_demo_batches.py
Executable file
302
services/production/scripts/demo/seed_demo_batches.py
Executable 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)
|
||||
235
services/production/scripts/demo/seed_demo_equipment.py
Executable file
235
services/production/scripts/demo/seed_demo_equipment.py
Executable 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)
|
||||
216
services/production/scripts/demo/seed_demo_quality_templates.py
Executable file
216
services/production/scripts/demo/seed_demo_quality_templates.py
Executable 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Subscription Seeding Script for Tenant Service
|
||||
Creates subscriptions for demo template tenants
|
||||
|
||||
397
services/tenant/scripts/demo/seed_demo_tenant_members.py
Normal file
397
services/tenant/scripts/demo/seed_demo_tenant_members.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user