Improve demo seed

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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