301 lines
11 KiB
Python
301 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Demo Distribution History Seeding Script for Distribution Service
|
||
Creates 30 days of historical delivery routes and shipments for enterprise demo
|
||
|
||
This is the CRITICAL missing piece that connects parent (Obrador) to children (retail outlets).
|
||
It populates the template with realistic VRP-optimized delivery routes.
|
||
|
||
Usage:
|
||
python /app/scripts/demo/seed_demo_distribution_history.py
|
||
|
||
Environment Variables Required:
|
||
DISTRIBUTION_DATABASE_URL - PostgreSQL connection string
|
||
DEMO_MODE - Set to 'production' for production seeding
|
||
"""
|
||
|
||
import asyncio
|
||
import uuid
|
||
import sys
|
||
import os
|
||
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))
|
||
# Add shared to path
|
||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
from sqlalchemy import select
|
||
import structlog
|
||
|
||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||
from app.models import DeliveryRoute, Shipment, DeliveryRouteStatus, ShipmentStatus
|
||
|
||
structlog.configure(
|
||
processors=[
|
||
structlog.stdlib.add_log_level,
|
||
structlog.processors.TimeStamper(fmt="iso"),
|
||
structlog.dev.ConsoleRenderer()
|
||
]
|
||
)
|
||
|
||
logger = structlog.get_logger()
|
||
|
||
# Fixed Demo Tenant IDs
|
||
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Parent (Obrador)
|
||
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
|
||
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
|
||
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
|
||
|
||
CHILD_TENANTS = [
|
||
(DEMO_TENANT_CHILD_1, "Madrid Centro", 150.0),
|
||
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia", 120.0),
|
||
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa", 100.0)
|
||
]
|
||
|
||
# Delivery schedule: Mon/Wed/Fri (as per distribution service)
|
||
DELIVERY_WEEKDAYS = [0, 2, 4] # Monday, Wednesday, Friday
|
||
|
||
|
||
async def seed_distribution_history(db: AsyncSession):
|
||
"""
|
||
Seed 30 days of distribution data (routes + shipments) centered around BASE_REFERENCE_DATE
|
||
|
||
Creates delivery routes for Mon/Wed/Fri pattern spanning from 15 days before to 15 days after BASE_REFERENCE_DATE.
|
||
This ensures data exists for today when BASE_REFERENCE_DATE is set to the current date.
|
||
"""
|
||
logger.info("=" * 80)
|
||
logger.info("🚚 Starting Demo Distribution History Seeding")
|
||
logger.info("=" * 80)
|
||
logger.info(f"Parent Tenant: {DEMO_TENANT_ENTERPRISE_CHAIN} (Obrador Madrid)")
|
||
logger.info(f"Child Tenants: {len(CHILD_TENANTS)}")
|
||
logger.info(f"Delivery Pattern: Mon/Wed/Fri (3x per week)")
|
||
logger.info(f"Date Range: {(BASE_REFERENCE_DATE - timedelta(days=15)).strftime('%Y-%m-%d')} to {(BASE_REFERENCE_DATE + timedelta(days=15)).strftime('%Y-%m-%d')}")
|
||
logger.info(f"Reference Date (today): {BASE_REFERENCE_DATE.strftime('%Y-%m-%d')}")
|
||
logger.info("")
|
||
|
||
routes_created = 0
|
||
shipments_created = 0
|
||
|
||
# Generate 30 days of routes centered around BASE_REFERENCE_DATE (-15 to +15 days)
|
||
# This ensures we have past data, current data, and future data
|
||
# Range is inclusive of start, exclusive of end, so -15 to 16 gives -15..15
|
||
for days_offset in range(-15, 16): # -15 to +15 = 31 days total
|
||
delivery_date = BASE_REFERENCE_DATE + timedelta(days=days_offset)
|
||
|
||
# Only create routes for Mon/Wed/Fri
|
||
if delivery_date.weekday() not in DELIVERY_WEEKDAYS:
|
||
continue
|
||
|
||
# Check if route already exists
|
||
result = await db.execute(
|
||
select(DeliveryRoute).where(
|
||
DeliveryRoute.tenant_id == DEMO_TENANT_ENTERPRISE_CHAIN,
|
||
DeliveryRoute.route_date == delivery_date
|
||
).limit(1)
|
||
)
|
||
existing_route = result.scalar_one_or_none()
|
||
|
||
if existing_route:
|
||
logger.debug(f"Route already exists for {delivery_date.strftime('%Y-%m-%d')}, skipping")
|
||
continue
|
||
|
||
# Create delivery route
|
||
route_number = f"DEMO-{delivery_date.strftime('%Y%m%d')}-001"
|
||
|
||
# Realistic VRP metrics for 3-stop route
|
||
# Distance: Madrid Centro (closest) + Barcelona Gràcia (medium) + Valencia Ruzafa (farthest)
|
||
total_distance_km = random.uniform(75.0, 95.0) # Realistic for 3 retail outlets in region
|
||
estimated_duration_minutes = random.randint(180, 240) # 3-4 hours for 3 stops
|
||
|
||
# Route sequence (order of deliveries) with full GPS coordinates for map display
|
||
# Determine status based on date
|
||
is_past = delivery_date < BASE_REFERENCE_DATE
|
||
point_status = "delivered" if is_past else "pending"
|
||
|
||
route_sequence = [
|
||
{
|
||
"tenant_id": str(DEMO_TENANT_CHILD_1),
|
||
"name": "Madrid Centro",
|
||
"address": "Calle Gran Vía 28, 28013 Madrid, Spain",
|
||
"latitude": 40.4168,
|
||
"longitude": -3.7038,
|
||
"status": point_status,
|
||
"id": str(uuid.uuid4()),
|
||
"sequence": 1
|
||
},
|
||
{
|
||
"tenant_id": str(DEMO_TENANT_CHILD_2),
|
||
"name": "Barcelona Gràcia",
|
||
"address": "Carrer Gran de Gràcia 15, 08012 Barcelona, Spain",
|
||
"latitude": 41.4036,
|
||
"longitude": 2.1561,
|
||
"status": point_status,
|
||
"id": str(uuid.uuid4()),
|
||
"sequence": 2
|
||
},
|
||
{
|
||
"tenant_id": str(DEMO_TENANT_CHILD_3),
|
||
"name": "Valencia Ruzafa",
|
||
"address": "Carrer de Sueca 51, 46006 Valencia, Spain",
|
||
"latitude": 39.4647,
|
||
"longitude": -0.3679,
|
||
"status": point_status,
|
||
"id": str(uuid.uuid4()),
|
||
"sequence": 3
|
||
}
|
||
]
|
||
|
||
# Route status (already determined is_past above)
|
||
route_status = DeliveryRouteStatus.completed if is_past else DeliveryRouteStatus.planned
|
||
|
||
route = DeliveryRoute(
|
||
id=uuid.uuid4(),
|
||
tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
|
||
route_number=route_number,
|
||
route_date=delivery_date,
|
||
total_distance_km=Decimal(str(round(total_distance_km, 2))),
|
||
estimated_duration_minutes=estimated_duration_minutes,
|
||
route_sequence=route_sequence,
|
||
status=route_status,
|
||
driver_id=uuid.uuid4(), # Use a random UUID for the driver_id
|
||
vehicle_id=f"VEH-{random.choice(['001', '002', '003'])}",
|
||
created_at=delivery_date - timedelta(days=1), # Routes created day before
|
||
updated_at=delivery_date,
|
||
created_by=uuid.uuid4(), # Add required audit field
|
||
updated_by=uuid.uuid4() # Add required audit field
|
||
)
|
||
|
||
db.add(route)
|
||
routes_created += 1
|
||
|
||
# Create shipments for each child tenant on this route
|
||
for child_tenant_id, child_name, avg_weight_kg in CHILD_TENANTS:
|
||
# Vary weight slightly
|
||
shipment_weight = avg_weight_kg * random.uniform(0.9, 1.1)
|
||
|
||
shipment_number = f"DEMOSHP-{delivery_date.strftime('%Y%m%d')}-{child_name.split()[0].upper()[:3]}"
|
||
|
||
# Determine shipment status based on date
|
||
shipment_status = ShipmentStatus.delivered if is_past else ShipmentStatus.pending
|
||
|
||
shipment = Shipment(
|
||
id=uuid.uuid4(),
|
||
tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
|
||
parent_tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
|
||
child_tenant_id=child_tenant_id,
|
||
shipment_number=shipment_number,
|
||
shipment_date=delivery_date,
|
||
status=shipment_status,
|
||
total_weight_kg=Decimal(str(round(shipment_weight, 2))),
|
||
delivery_route_id=route.id,
|
||
delivery_notes=f"Entrega regular a {child_name}",
|
||
created_at=delivery_date - timedelta(days=1),
|
||
updated_at=delivery_date,
|
||
created_by=uuid.uuid4(), # Add required audit field
|
||
updated_by=uuid.uuid4() # Add required audit field
|
||
)
|
||
|
||
db.add(shipment)
|
||
shipments_created += 1
|
||
|
||
logger.debug(
|
||
f" ✅ {delivery_date.strftime('%a %Y-%m-%d')}: "
|
||
f"Route {route_number} with {len(CHILD_TENANTS)} shipments"
|
||
)
|
||
|
||
# Commit all changes
|
||
await db.commit()
|
||
|
||
logger.info("")
|
||
logger.info("=" * 80)
|
||
logger.info("✅ Demo Distribution History Seeding Completed")
|
||
logger.info("=" * 80)
|
||
logger.info(f" 📊 Routes created: {routes_created}")
|
||
logger.info(f" 📦 Shipments created: {shipments_created}")
|
||
logger.info("")
|
||
logger.info("Distribution characteristics:")
|
||
logger.info(" ✓ 30 days of historical data")
|
||
logger.info(" ✓ Mon/Wed/Fri delivery schedule (3x per week)")
|
||
logger.info(" ✓ VRP-optimized route sequencing")
|
||
logger.info(" ✓ ~13 routes (30 days ÷ 7 days/week × 3 delivery days)")
|
||
logger.info(" ✓ ~39 shipments (13 routes × 3 children)")
|
||
logger.info(" ✓ Realistic distances and durations")
|
||
logger.info("")
|
||
|
||
return {
|
||
"service": "distribution",
|
||
"routes_created": routes_created,
|
||
"shipments_created": shipments_created
|
||
}
|
||
|
||
|
||
async def main():
|
||
"""Main execution function"""
|
||
|
||
logger.info("Demo Distribution History Seeding Script Starting")
|
||
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
|
||
|
||
# Get database URL from environment
|
||
database_url = os.getenv("DISTRIBUTION_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||
if not database_url:
|
||
logger.error("❌ DISTRIBUTION_DATABASE_URL or DATABASE_URL environment variable must be set")
|
||
return 1
|
||
|
||
# Convert to async URL if needed
|
||
if database_url.startswith("postgresql://"):
|
||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||
|
||
logger.info("Connecting to distribution database")
|
||
|
||
# Create engine and session
|
||
engine = create_async_engine(
|
||
database_url,
|
||
echo=False,
|
||
pool_pre_ping=True,
|
||
pool_size=5,
|
||
max_overflow=10
|
||
)
|
||
|
||
async_session = sessionmaker(
|
||
engine,
|
||
class_=AsyncSession,
|
||
expire_on_commit=False
|
||
)
|
||
|
||
try:
|
||
async with async_session() as session:
|
||
result = await seed_distribution_history(session)
|
||
|
||
logger.info("🎉 Success! Distribution history is ready for cloning.")
|
||
logger.info("")
|
||
logger.info("Next steps:")
|
||
logger.info(" 1. Create Kubernetes job YAMLs for all child scripts")
|
||
logger.info(" 2. Update kustomization.yaml with proper execution order")
|
||
logger.info(" 3. Test enterprise demo end-to-end")
|
||
logger.info("")
|
||
|
||
return 0
|
||
|
||
except Exception as e:
|
||
logger.error("=" * 80)
|
||
logger.error("❌ Demo Distribution History 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)
|