#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Demo Procurement Plans Seeding Script for Procurement Service Creates realistic procurement plans for demo template tenants using pre-defined UUIDs This script runs as a Kubernetes init job inside the procurement-service container. It populates the template tenants with comprehensive procurement plans. Usage: python /app/scripts/demo/seed_demo_procurement_plans.py Environment Variables Required: PROCUREMENT_DATABASE_URL - PostgreSQL connection string for procurement database DEMO_MODE - Set to 'production' for production seeding LOG_LEVEL - Logging level (default: INFO) Note: No database lookups needed - all IDs are pre-defined in the JSON file """ import asyncio import uuid import sys import os import json from datetime import datetime, timezone, timedelta, date from pathlib import Path import random 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, text import structlog from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement # Add shared path for demo utilities sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from shared.utils.demo_dates import BASE_REFERENCE_DATE # 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") # Individual bakery DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery # Hardcoded SKU to Ingredient ID mapping (no database lookups needed!) INGREDIENT_ID_MAP = { "HAR-T55-001": "10000000-0000-0000-0000-000000000001", "HAR-T65-002": "10000000-0000-0000-0000-000000000002", "HAR-FUE-003": "10000000-0000-0000-0000-000000000003", "HAR-INT-004": "10000000-0000-0000-0000-000000000004", "HAR-CEN-005": "10000000-0000-0000-0000-000000000005", "HAR-ESP-006": "10000000-0000-0000-0000-000000000006", "LAC-MAN-001": "10000000-0000-0000-0000-000000000011", "LAC-LEC-002": "10000000-0000-0000-0000-000000000012", "LAC-NAT-003": "10000000-0000-0000-0000-000000000013", "LAC-HUE-004": "10000000-0000-0000-0000-000000000014", "LEV-FRE-001": "10000000-0000-0000-0000-000000000021", "LEV-SEC-002": "10000000-0000-0000-0000-000000000022", "BAS-SAL-001": "10000000-0000-0000-0000-000000000031", "BAS-AZU-002": "10000000-0000-0000-0000-000000000032", "ESP-CHO-001": "10000000-0000-0000-0000-000000000041", "ESP-ALM-002": "10000000-0000-0000-0000-000000000042", "ESP-VAI-004": "10000000-0000-0000-0000-000000000044", "ESP-CRE-005": "10000000-0000-0000-0000-000000000045", } # Ingredient costs (for requirement generation) INGREDIENT_COSTS = { "HAR-T55-001": 0.85, "HAR-T65-002": 0.95, "HAR-FUE-003": 1.15, "HAR-INT-004": 1.20, "HAR-CEN-005": 1.30, "HAR-ESP-006": 2.45, "LAC-MAN-001": 6.50, "LAC-LEC-002": 0.95, "LAC-NAT-003": 3.20, "LAC-HUE-004": 0.25, "LEV-FRE-001": 4.80, "LEV-SEC-002": 12.50, "BAS-SAL-001": 0.60, "BAS-AZU-002": 0.90, "ESP-CHO-001": 15.50, "ESP-ALM-002": 8.90, "ESP-VAI-004": 3.50, "ESP-CRE-005": 7.20, } 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 'weight' key""" total_weight = sum(c.get("weight", 1.0) for c in choices) r = random.uniform(0, total_weight) cumulative = 0 for choice in choices: cumulative += choice.get("weight", 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 ) -> dict: """Generate procurement plans and requirements for a specific tenant""" logger.info("─" * 80) logger.info(f"Generating procurement data for: {tenant_name}") logger.info(f"Tenant ID: {tenant_id}") logger.info("─" * 80) # 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["procurement_config"] total_plans = proc_config["plans_per_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["temporal_distribution"].items(): cumulative += details["percentage"] if rand_temporal <= cumulative: temporal_category = details break if not temporal_category: temporal_category = proc_config["temporal_distribution"]["completed"] # Calculate plan date offset_days = random.randint( temporal_category["offset_days_min"], temporal_category["offset_days_max"] ) plan_date = calculate_date_from_offset(offset_days) # Select status status = random.choice(temporal_category["statuses"]) # Select plan type plan_type_choice = weighted_choice(proc_config["plan_types"]) plan_type = plan_type_choice["type"] # Select priority priority_rand = random.random() cumulative_priority = 0 priority = "normal" for p, weight in proc_config["priorities"].items(): cumulative_priority += weight if priority_rand <= cumulative_priority: priority = p break # Select procurement strategy strategy_choice = weighted_choice(proc_config["procurement_strategies"]) procurement_strategy = strategy_choice["strategy"] # Select supply risk level risk_rand = random.random() cumulative_risk = 0 supply_risk_level = "low" for risk, weight in proc_config["risk_levels"].items(): cumulative_risk += weight if risk_rand <= cumulative_risk: supply_risk_level = risk break # Calculate planning horizon planning_horizon = proc_config["planning_horizon_days"][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["safety_stock_percentage"]["min"], proc_config["safety_stock_percentage"]["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["performance_metrics"] fulfillment_rate = Decimal(str(random.uniform( metrics["fulfillment_rate"]["min"], metrics["fulfillment_rate"]["max"] ))) on_time_delivery_rate = Decimal(str(random.uniform( metrics["on_time_delivery"]["min"], metrics["on_time_delivery"]["max"] ))) cost_accuracy = Decimal(str(random.uniform( metrics["cost_accuracy"]["min"], metrics["cost_accuracy"]["max"] ))) quality_score = Decimal(str(random.uniform( metrics["quality_score"]["min"], metrics["quality_score"]["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["requirements_per_plan"]["min"], proc_config["requirements_per_plan"]["max"] ) # Select random ingredients selected_ingredients = random.sample( list(INGREDIENT_ID_MAP.keys()), min(num_requirements, len(INGREDIENT_ID_MAP)) ) total_estimated_cost = Decimal("0.00") for req_num, ingredient_sku in enumerate(selected_ingredients, 1): # Get ingredient ID from hardcoded mapping ingredient_id_str = INGREDIENT_ID_MAP.get(ingredient_sku) if not ingredient_id_str: logger.warning(f" ⚠️ Ingredient SKU not in mapping: {ingredient_sku}") continue # Generate tenant-specific ingredient ID base_ingredient_id = uuid.UUID(ingredient_id_str) tenant_int = int(tenant_id.hex, 16) ingredient_id = uuid.UUID(int=tenant_int ^ int(base_ingredient_id.hex, 16)) # Get quantity range for category category = ingredient_sku.split("-")[0] # HAR, LAC, LEV, BAS, ESP cantidad_range = proc_config["quantity_ranges"].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_COSTS.get(ingredient_sku, 1.0))) * Decimal(str(random.uniform(0.95, 1.05))) estimated_total_cost = estimated_unit_cost * net_requirement # Timing lead_time_days = random.randint(1, 5) 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=ingredient_id, product_name=f"Ingrediente {ingredient_sku}", product_sku=ingredient_sku, product_category=category, product_type="ingredient", required_quantity=required_quantity, unit_of_measure="kg", 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(random.choice([1, 5, 10, 25]))), 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=random.choice([30, 60, 90, 180, 365]), 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}") logger.info("") 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("=" * 80) logger.info("🚚 Starting Demo Procurement Plans Seeding") logger.info("=" * 80) # Load configuration config = { "procurement_config": { "plans_per_tenant": 8, "requirements_per_plan": {"min": 3, "max": 8}, "planning_horizon_days": { "individual_bakery": 30, "central_bakery": 45 }, "safety_stock_percentage": {"min": 15.0, "max": 25.0}, "temporal_distribution": { "completed": { "percentage": 0.3, "offset_days_min": -15, "offset_days_max": -1, "statuses": ["completed"] }, "in_execution": { "percentage": 0.2, "offset_days_min": -5, "offset_days_max": 2, "statuses": ["in_execution", "partially_received"] }, "approved": { "percentage": 0.2, "offset_days_min": -2, "offset_days_max": 1, "statuses": ["approved"] }, "pending_approval": { "percentage": 0.15, "offset_days_min": 0, "offset_days_max": 3, "statuses": ["pending_approval"] }, "draft": { "percentage": 0.15, "offset_days_min": 0, "offset_days_max": 5, "statuses": ["draft"] } }, "plan_types": [ {"type": "regular", "weight": 0.7}, {"type": "seasonal", "weight": 0.2}, {"type": "emergency", "weight": 0.1} ], "priorities": { "normal": 0.7, "high": 0.25, "critical": 0.05 }, "procurement_strategies": [ {"strategy": "just_in_time", "weight": 0.6}, {"strategy": "bulk", "weight": 0.3}, {"strategy": "mixed", "weight": 0.1} ], "risk_levels": { "low": 0.6, "medium": 0.3, "high": 0.08, "critical": 0.02 }, "quantity_ranges": { "HAR": {"min": 50.0, "max": 500.0}, # Harinas "LAC": {"min": 20.0, "max": 200.0}, # Lácteos "LEV": {"min": 5.0, "max": 50.0}, # Levaduras "BAS": {"min": 10.0, "max": 100.0}, # Básicos "ESP": {"min": 1.0, "max": 20.0} # Especiales }, "performance_metrics": { "fulfillment_rate": {"min": 85.0, "max": 98.0}, "on_time_delivery": {"min": 80.0, "max": 95.0}, "cost_accuracy": {"min": 90.0, "max": 99.0}, "quality_score": {"min": 7.0, "max": 9.5} } } } results = [] # Seed San Pablo (Individual Bakery) result_san_pablo = await generate_procurement_for_tenant( db, DEMO_TENANT_SAN_PABLO, "Panadería 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, "Panadería 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) logger.info("=" * 80) logger.info("✅ Demo Procurement Plans Seeding Completed") logger.info("=" * 80) return { "results": results, "total_plans_created": total_plans, "total_requirements_created": total_requirements, "status": "completed" } async def main(): """Main execution function""" logger.info("Demo Procurement Plans 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("PROCUREMENT_DATABASE_URL") or os.getenv("DATABASE_URL") if not database_url: logger.error("❌ PROCUREMENT_DATABASE_URL or 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) logger.info("Connecting to procurement database") # Create async engine 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_all(session) logger.info("") logger.info("📊 Seeding Summary:") logger.info(f" ✅ Total Plans: {result['total_plans_created']}") logger.info(f" ✅ Total Requirements: {result['total_requirements_created']}") logger.info(f" ✅ Status: {result['status']}") logger.info("") # Print per-tenant details 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" logger.info(f" Tenant {tenant_id}: {status}") logger.info("") logger.info("🎉 Success! Procurement plans are ready for demo sessions.") logger.info("") logger.info("Plans created:") logger.info(" • 8 Regular procurement plans per tenant") logger.info(" • 3-8 Requirements per plan") logger.info(" • Various statuses: draft, pending, approved, in execution, completed") logger.info(" • Different priorities and risk levels") logger.info("") logger.info("Note: All IDs are pre-defined and hardcoded for cross-service consistency") logger.info("") return 0 except Exception as e: logger.error("=" * 80) logger.error("❌ Demo Procurement Plans 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)