#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Demo Stock Seeding Script for Inventory Service Creates realistic stock batches with varied expiration dates for demo template tenants This script runs as a Kubernetes init job inside the inventory-service container. It populates the template tenants with stock data that will demonstrate inventory alerts. Usage: python /app/scripts/demo/seed_demo_stock.py Environment Variables Required: INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database DEMO_MODE - Set to 'production' for production seeding LOG_LEVEL - Logging level (default: INFO) """ import asyncio import uuid import sys import os import random import json from datetime import datetime, timezone, timedelta from pathlib import Path from decimal import Decimal # Add app to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy import select import structlog from app.models.inventory import Ingredient, Stock # Configure logging structlog.configure( processors=[ structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.dev.ConsoleRenderer() ] ) logger = structlog.get_logger() # Fixed Demo Tenant IDs (must match tenant service) DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Base reference date for demo data (all relative dates calculated from this) BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) # Load configuration from JSON def load_stock_config(): """Load stock configuration from JSON file""" config_file = Path(__file__).parent / "stock_lotes_es.json" if not config_file.exists(): raise FileNotFoundError(f"Stock configuration file not found: {config_file}") logger.info("Loading stock configuration", file=str(config_file)) with open(config_file, 'r', encoding='utf-8') as f: return json.load(f) # Load configuration STOCK_CONFIG = load_stock_config() STORAGE_LOCATIONS = STOCK_CONFIG["stock_distribution"]["storage_locations"] WAREHOUSE_ZONES = STOCK_CONFIG["stock_distribution"]["warehouse_zones"] QUALITY_STATUSES = ["good", "damaged", "expired", "quarantined"] def generate_batch_number(tenant_id: uuid.UUID, ingredient_sku: str, batch_index: int) -> str: """Generate a realistic batch number""" tenant_short = str(tenant_id).split('-')[0].upper()[:4] return f"LOTE-{tenant_short}-{ingredient_sku}-{batch_index:03d}" def calculate_expiration_distribution(): """ Calculate expiration date distribution for realistic demo alerts Distribution: - 5% expired (already past expiration) - 10% expiring soon (< 3 days) - 15% moderate alert (3-7 days) - 30% short-term (7-30 days) - 40% long-term (30-90 days) """ rand = random.random() if rand < 0.05: # 5% expired return random.randint(-10, -1) elif rand < 0.15: # 10% expiring soon return random.randint(1, 3) elif rand < 0.30: # 15% moderate alert return random.randint(3, 7) elif rand < 0.60: # 30% short-term return random.randint(7, 30) else: # 40% long-term return random.randint(30, 90) async def create_stock_batches_for_ingredient( db: AsyncSession, tenant_id: uuid.UUID, ingredient: Ingredient, base_date: datetime ) -> list: """ Create 3-5 stock batches for a single ingredient with varied properties Args: db: Database session tenant_id: Tenant UUID ingredient: Ingredient model instance base_date: Base reference date for calculating expiration dates Returns: List of created Stock instances """ stocks = [] num_batches = random.randint(3, 5) for i in range(num_batches): # Calculate expiration days offset days_offset = calculate_expiration_distribution() expiration_date = base_date + timedelta(days=days_offset) received_date = expiration_date - timedelta(days=ingredient.shelf_life_days or 30) # Determine if expired is_expired = days_offset < 0 # Quality status based on expiration if is_expired: quality_status = random.choice(["expired", "quarantined"]) is_available = False elif days_offset < 3: quality_status = random.choice(["good", "good", "good", "damaged"]) # Mostly good is_available = quality_status == "good" else: quality_status = "good" is_available = True # Generate quantities if ingredient.unit_of_measure.value in ['kg', 'l']: current_quantity = round(random.uniform(5.0, 50.0), 2) reserved_quantity = round(random.uniform(0.0, current_quantity * 0.3), 2) if is_available else 0.0 elif ingredient.unit_of_measure.value in ['g', 'ml']: current_quantity = round(random.uniform(500.0, 5000.0), 2) reserved_quantity = round(random.uniform(0.0, current_quantity * 0.3), 2) if is_available else 0.0 else: # units, pieces, etc. current_quantity = float(random.randint(10, 200)) reserved_quantity = float(random.randint(0, int(current_quantity * 0.3))) if is_available else 0.0 available_quantity = current_quantity - reserved_quantity # Calculate costs with variation base_cost = float(ingredient.average_cost or Decimal("1.0")) unit_cost = Decimal(str(round(base_cost * random.uniform(0.9, 1.1), 2))) total_cost = unit_cost * Decimal(str(current_quantity)) # Determine storage requirements requires_refrigeration = ingredient.is_perishable and ingredient.ingredient_category.value in ['dairy', 'eggs'] requires_freezing = False # Could be enhanced based on ingredient type stock = Stock( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, supplier_id=None, # Could link to suppliers in future batch_number=generate_batch_number(tenant_id, ingredient.sku or f"SKU{i}", i + 1), lot_number=f"LOT-{random.randint(1000, 9999)}", supplier_batch_ref=f"SUP-{random.randint(10000, 99999)}", production_stage='raw_ingredient', current_quantity=current_quantity, reserved_quantity=reserved_quantity, available_quantity=available_quantity, received_date=received_date, expiration_date=expiration_date, best_before_date=expiration_date - timedelta(days=2) if ingredient.is_perishable else None, unit_cost=unit_cost, total_cost=total_cost, storage_location=random.choice(STORAGE_LOCATIONS), warehouse_zone=random.choice(["A", "B", "C", "D"]), shelf_position=f"{random.randint(1, 20)}-{random.choice(['A', 'B', 'C'])}", requires_refrigeration=requires_refrigeration, requires_freezing=requires_freezing, storage_temperature_min=2.0 if requires_refrigeration else None, storage_temperature_max=8.0 if requires_refrigeration else None, shelf_life_days=ingredient.shelf_life_days, is_available=is_available, is_expired=is_expired, quality_status=quality_status, created_at=received_date, updated_at=datetime.now(timezone.utc) ) stocks.append(stock) return stocks async def seed_stock_for_tenant( db: AsyncSession, tenant_id: uuid.UUID, tenant_name: str, base_date: datetime ) -> dict: """ Seed stock batches for all ingredients of a specific tenant Args: db: Database session tenant_id: UUID of the tenant tenant_name: Name of the tenant (for logging) base_date: Base reference date for expiration calculations Returns: Dict with seeding statistics """ logger.info("─" * 80) logger.info(f"Seeding stock for: {tenant_name}") logger.info(f"Tenant ID: {tenant_id}") logger.info(f"Base Reference Date: {base_date.isoformat()}") logger.info("─" * 80) # Get all ingredients for this tenant result = await db.execute( select(Ingredient).where( Ingredient.tenant_id == tenant_id, Ingredient.is_active == True ) ) ingredients = result.scalars().all() if not ingredients: logger.warning(f"No ingredients found for tenant {tenant_id}") return { "tenant_id": str(tenant_id), "tenant_name": tenant_name, "stock_created": 0, "ingredients_processed": 0 } total_stock_created = 0 expired_count = 0 expiring_soon_count = 0 for ingredient in ingredients: stocks = await create_stock_batches_for_ingredient(db, tenant_id, ingredient, base_date) for stock in stocks: db.add(stock) total_stock_created += 1 if stock.is_expired: expired_count += 1 elif stock.expiration_date: days_until_expiry = (stock.expiration_date - base_date).days if days_until_expiry <= 3: expiring_soon_count += 1 logger.debug(f" ✅ Created {len(stocks)} stock batches for: {ingredient.name}") # Commit all changes await db.commit() logger.info(f" 📊 Total Stock Batches Created: {total_stock_created}") logger.info(f" ⚠️ Expired Batches: {expired_count}") logger.info(f" 🔔 Expiring Soon (≤3 days): {expiring_soon_count}") logger.info("") return { "tenant_id": str(tenant_id), "tenant_name": tenant_name, "stock_created": total_stock_created, "ingredients_processed": len(ingredients), "expired_count": expired_count, "expiring_soon_count": expiring_soon_count } async def seed_stock(db: AsyncSession): """ Seed stock for all demo template tenants Args: db: Database session Returns: Dict with overall seeding statistics """ logger.info("=" * 80) logger.info("📦 Starting Demo Stock Seeding") logger.info("=" * 80) results = [] # Seed for San Pablo (Traditional Bakery) logger.info("") result_san_pablo = await seed_stock_for_tenant( db, DEMO_TENANT_SAN_PABLO, "Panadería San Pablo (Traditional)", BASE_REFERENCE_DATE ) results.append(result_san_pablo) # Seed for La Espiga (Central Workshop) result_la_espiga = await seed_stock_for_tenant( db, DEMO_TENANT_LA_ESPIGA, "Panadería La Espiga (Central Workshop)", BASE_REFERENCE_DATE ) results.append(result_la_espiga) # Calculate totals total_stock = sum(r["stock_created"] for r in results) total_expired = sum(r["expired_count"] for r in results) total_expiring_soon = sum(r["expiring_soon_count"] for r in results) logger.info("=" * 80) logger.info("✅ Demo Stock Seeding Completed") logger.info("=" * 80) return { "service": "inventory", "tenants_seeded": len(results), "total_stock_created": total_stock, "total_expired": total_expired, "total_expiring_soon": total_expiring_soon, "results": results } async def main(): """Main execution function""" logger.info("Demo Stock Seeding Script Starting") logger.info("Mode: %s", os.getenv("DEMO_MODE", "development")) logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO")) # Get database URL from environment database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL") if not database_url: logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set") return 1 # Convert to async URL if needed if database_url.startswith("postgresql://"): database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) logger.info("Connecting to inventory database") # Create engine and session engine = create_async_engine( database_url, echo=False, pool_pre_ping=True, pool_size=5, max_overflow=10 ) async_session = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False ) try: async with async_session() as session: result = await seed_stock(session) logger.info("") logger.info("📊 Seeding Summary:") logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}") logger.info(f" ✅ Total stock batches: {result['total_stock_created']}") logger.info(f" ⚠️ Expired batches: {result['total_expired']}") logger.info(f" 🔔 Expiring soon (≤3 days): {result['total_expiring_soon']}") logger.info("") # Print per-tenant details for tenant_result in result['results']: logger.info( f" {tenant_result['tenant_name']}: " f"{tenant_result['stock_created']} batches " f"({tenant_result['expired_count']} expired, " f"{tenant_result['expiring_soon_count']} expiring soon)" ) logger.info("") logger.info("🎉 Success! Stock data ready for cloning and alert generation.") logger.info("") logger.info("Next steps:") logger.info(" 1. Update inventory clone endpoint to include stock") logger.info(" 2. Implement date offset during cloning") logger.info(" 3. Generate expiration alerts during clone") logger.info(" 4. Test demo session creation") logger.info("") return 0 except Exception as e: logger.error("=" * 80) logger.error("❌ Demo Stock Seeding Failed") logger.error("=" * 80) logger.error("Error: %s", str(e)) logger.error("", exc_info=True) return 1 finally: await engine.dispose() if __name__ == "__main__": exit_code = asyncio.run(main()) sys.exit(exit_code)