Files
bakery-ia/services/procurement/scripts/demo/seed_demo_procurement_plans.py
2025-11-27 15:52:40 +01:00

680 lines
25 KiB
Python

#!/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)