#!/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, StockMovement, StockMovementType # 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 1-2 stock batches for a single ingredient (optimized for demo performance) 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(1, 2) # Reduced from 3-5 for faster demo loading # Calculate target total stock for this ingredient # Use 40-80% of max_stock_level to allow for realistic variation # If max_stock_level is not set, use reorder_point * 3 as a reasonable target if ingredient.max_stock_level: target_total_stock = float(ingredient.max_stock_level) * random.uniform(0.4, 0.8) else: target_total_stock = float(ingredient.reorder_point or 50.0) * 3.0 # Distribute total stock across batches batch_quantities = [] remaining = target_total_stock for i in range(num_batches): if i == num_batches - 1: # Last batch gets whatever is remaining batch_quantities.append(remaining) else: # Earlier batches get a random portion of remaining portion = remaining * random.uniform(0.3, 0.7) batch_quantities.append(portion) remaining -= portion 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 # Use pre-calculated batch quantity current_quantity = round(batch_quantities[i], 2) # Reserve 0-30% of current quantity if available reserved_quantity = round(random.uniform(0.0, current_quantity * 0.3), 2) 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 create_waste_movements_for_tenant( db: AsyncSession, tenant_id: uuid.UUID, base_date: datetime ) -> list: """ Create realistic waste stock movements for the past 30 days Args: db: Database session tenant_id: UUID of the tenant base_date: Base reference date for movement calculations Returns: List of created StockMovement instances """ # Get all stock for this tenant (including expired) result = await db.execute( select(Stock, Ingredient).join( Ingredient, Stock.ingredient_id == Ingredient.id ).where( Stock.tenant_id == tenant_id ) ) stock_items = result.all() if not stock_items: return [] movements = [] waste_reasons = [ ("spoilage", 0.40), # 40% of waste is spoilage ("expired", 0.30), # 30% is expiration ("damage", 0.20), # 20% is damage ("contamination", 0.10) # 10% is contamination ] # Create waste movements for expired stock for stock, ingredient in stock_items: if stock.is_expired and stock.current_quantity > 0: # Create waste movement for expired stock waste_quantity = stock.current_quantity movement_date = stock.expiration_date + timedelta(days=random.randint(1, 3)) movement = StockMovement( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, stock_id=stock.id, movement_type=StockMovementType.WASTE, quantity=waste_quantity, unit_cost=stock.unit_cost, total_cost=stock.unit_cost * Decimal(str(waste_quantity)) if stock.unit_cost else None, reason_code="expired", notes=f"Lote {stock.batch_number} caducado - movimiento automático de desperdicio", reference_number=f"WASTE-EXP-{stock.batch_number}", movement_date=movement_date, created_at=movement_date, created_by=None # System-generated ) movements.append(movement) # Create additional random waste movements for the past 30 days # to show waste patterns from spoilage, damage, etc. num_waste_movements = random.randint(8, 15) # 8-15 waste incidents in 30 days for i in range(num_waste_movements): # Select random non-expired stock available_stock = [(s, i) for s, i in stock_items if not s.is_expired and s.current_quantity > 5.0] if not available_stock: continue stock, ingredient = random.choice(available_stock) # Random date in the past 30 days days_ago = random.randint(1, 30) movement_date = base_date - timedelta(days=days_ago) # Random waste quantity (1-10% of current stock) waste_percentage = random.uniform(0.01, 0.10) waste_quantity = round(stock.current_quantity * waste_percentage, 2) # Select random waste reason reason, _ = random.choices( waste_reasons, weights=[w for _, w in waste_reasons] )[0] # Create waste movement movement = StockMovement( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, stock_id=stock.id, movement_type=StockMovementType.WASTE, quantity=waste_quantity, unit_cost=stock.unit_cost, total_cost=stock.unit_cost * Decimal(str(waste_quantity)) if stock.unit_cost else None, reason_code=reason, notes=f"Desperdicio de {ingredient.name} por {reason}", reference_number=f"WASTE-{reason.upper()}-{i+1:03d}", movement_date=movement_date, created_at=movement_date, created_by=None # System-generated ) movements.append(movement) return movements async def create_purchase_movements_for_stock( db: AsyncSession, tenant_id: uuid.UUID, base_date: datetime ) -> list: """ Create PURCHASE movements for all stock batches Each stock batch should have a corresponding PURCHASE movement representing when it was received from the supplier. Args: db: Database session tenant_id: UUID of the tenant base_date: Base reference date for movement calculations Returns: List of created StockMovement instances """ # Get all stock for this tenant result = await db.execute( select(Stock, Ingredient).join( Ingredient, Stock.ingredient_id == Ingredient.id ).where( Stock.tenant_id == tenant_id ) ) stock_items = result.all() if not stock_items: return [] movements = [] for stock, ingredient in stock_items: # Create PURCHASE movement for each stock batch # Movement date is the received date of the stock movement_date = stock.received_date movement = StockMovement( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, stock_id=stock.id, movement_type=StockMovementType.PURCHASE, quantity=stock.current_quantity + stock.reserved_quantity, # Total received unit_cost=stock.unit_cost, total_cost=stock.total_cost, quantity_before=0.0, # Was zero before purchase quantity_after=stock.current_quantity + stock.reserved_quantity, reference_number=f"PO-{movement_date.strftime('%Y%m')}-{random.randint(1000, 9999)}", supplier_id=stock.supplier_id, notes=f"Compra de {ingredient.name} - Lote {stock.batch_number}", movement_date=movement_date, created_at=movement_date, created_by=None # System-generated ) movements.append(movement) return movements async def create_production_use_movements( db: AsyncSession, tenant_id: uuid.UUID, base_date: datetime ) -> list: """ Create realistic PRODUCTION_USE movements for the past 30 days Simulates ingredients being consumed in production runs. Args: db: Database session tenant_id: UUID of the tenant base_date: Base reference date for movement calculations Returns: List of created StockMovement instances """ # Get all available stock for this tenant result = await db.execute( select(Stock, Ingredient).join( Ingredient, Stock.ingredient_id == Ingredient.id ).where( Stock.tenant_id == tenant_id, Stock.is_available == True, Stock.current_quantity > 10.0 # Only use stock with sufficient quantity ) ) stock_items = result.all() if not stock_items: return [] movements = [] # Create 15-25 production use movements spread over 30 days num_production_runs = random.randint(15, 25) production_types = [ ("Pan Rústico", 20.0, 50.0), # 20-50 kg flour ("Pan de Molde", 15.0, 40.0), ("Croissants", 10.0, 30.0), ("Baguettes", 25.0, 60.0), ("Bollería Variada", 12.0, 35.0), ("Pan Integral", 18.0, 45.0) ] for i in range(num_production_runs): # Select random stock item if not stock_items: break stock, ingredient = random.choice(stock_items) # Random date in the past 30 days days_ago = random.randint(1, 30) movement_date = base_date - timedelta(days=days_ago) # Random production type and quantity production_name, min_qty, max_qty = random.choice(production_types) # Production quantity (5-20% of current stock, within min/max range) use_percentage = random.uniform(0.05, 0.20) use_quantity = round(min( stock.current_quantity * use_percentage, random.uniform(min_qty, max_qty) ), 2) # Ensure we don't consume more than available if use_quantity > stock.available_quantity: use_quantity = round(stock.available_quantity * 0.5, 2) if use_quantity < 1.0: continue # Create production use movement movement = StockMovement( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, stock_id=stock.id, movement_type=StockMovementType.PRODUCTION_USE, quantity=use_quantity, unit_cost=stock.unit_cost, total_cost=stock.unit_cost * Decimal(str(use_quantity)) if stock.unit_cost else None, quantity_before=stock.current_quantity, quantity_after=stock.current_quantity - use_quantity, reference_number=f"PROD-{movement_date.strftime('%Y%m%d')}-{i+1:03d}", notes=f"Producción de {production_name} - Consumo de {ingredient.name}", movement_date=movement_date, created_at=movement_date, created_by=None # System-generated ) movements.append(movement) # Update stock quantity for realistic simulation (don't commit, just for calculation) stock.current_quantity -= use_quantity stock.available_quantity -= use_quantity return movements async def create_adjustment_movements( db: AsyncSession, tenant_id: uuid.UUID, base_date: datetime ) -> list: """ Create inventory ADJUSTMENT movements Represents inventory counts and corrections. Args: db: Database session tenant_id: UUID of the tenant base_date: Base reference date for movement calculations Returns: List of created StockMovement instances """ # Get all stock for this tenant result = await db.execute( select(Stock, Ingredient).join( Ingredient, Stock.ingredient_id == Ingredient.id ).where( Stock.tenant_id == tenant_id, Stock.current_quantity > 5.0 ) ) stock_items = result.all() if not stock_items: return [] movements = [] adjustment_reasons = [ ("inventory_count", "Conteo de inventario mensual"), ("correction", "Corrección de entrada incorrecta"), ("shrinkage", "Ajuste por merma natural"), ("reconciliation", "Reconciliación de stock") ] # Create 3-5 adjustment movements num_adjustments = random.randint(3, 5) for i in range(num_adjustments): if not stock_items: break stock, ingredient = random.choice(stock_items) # Random date in the past 30 days days_ago = random.randint(5, 30) movement_date = base_date - timedelta(days=days_ago) # Random adjustment (±5% of current stock) adjustment_percentage = random.uniform(-0.05, 0.05) adjustment_quantity = round(stock.current_quantity * adjustment_percentage, 2) if abs(adjustment_quantity) < 0.1: continue reason_code, reason_note = random.choice(adjustment_reasons) # Create adjustment movement movement = StockMovement( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, stock_id=stock.id, movement_type=StockMovementType.ADJUSTMENT, quantity=abs(adjustment_quantity), unit_cost=stock.unit_cost, total_cost=stock.unit_cost * Decimal(str(abs(adjustment_quantity))) if stock.unit_cost else None, quantity_before=stock.current_quantity, quantity_after=stock.current_quantity + adjustment_quantity, reference_number=f"ADJ-{movement_date.strftime('%Y%m%d')}-{i+1:03d}", reason_code=reason_code, notes=f"{reason_note} - {ingredient.name}: {'+' if adjustment_quantity > 0 else ''}{adjustment_quantity:.2f} {ingredient.unit_of_measure.value}", movement_date=movement_date, created_at=movement_date, created_by=None # System-generated ) movements.append(movement) return movements async def create_initial_stock_movements( db: AsyncSession, tenant_id: uuid.UUID, base_date: datetime ) -> list: """ Create INITIAL_STOCK movements for opening inventory Represents the initial inventory when the system was set up. Args: db: Database session tenant_id: UUID of the tenant base_date: Base reference date for movement calculations Returns: List of created StockMovement instances """ # Get all stock for this tenant result = await db.execute( select(Stock, Ingredient).join( Ingredient, Stock.ingredient_id == Ingredient.id ).where( Stock.tenant_id == tenant_id ) ) stock_items = result.all() if not stock_items: return [] movements = [] # Create initial stock for 20% of ingredients (opening inventory) # Date is 60-90 days before base_date initial_stock_date = base_date - timedelta(days=random.randint(60, 90)) # Select 20% of stock items randomly num_initial = max(1, int(len(stock_items) * 0.20)) initial_stock_items = random.sample(stock_items, num_initial) for stock, ingredient in initial_stock_items: # Initial quantity (50-80% of current quantity) initial_quantity = round(stock.current_quantity * random.uniform(0.5, 0.8), 2) if initial_quantity < 1.0: continue # Create initial stock movement movement = StockMovement( id=uuid.uuid4(), tenant_id=tenant_id, ingredient_id=ingredient.id, stock_id=stock.id, movement_type=StockMovementType.INITIAL_STOCK, quantity=initial_quantity, unit_cost=stock.unit_cost, total_cost=stock.unit_cost * Decimal(str(initial_quantity)) if stock.unit_cost else None, quantity_before=0.0, quantity_after=initial_quantity, reference_number=f"INIT-{initial_stock_date.strftime('%Y%m%d')}", notes=f"Inventario inicial de {ingredient.name}", movement_date=initial_stock_date, created_at=initial_stock_date, created_by=None # System-generated ) movements.append(movement) return movements 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) # Check if stock already exists for this tenant (idempotency) existing_stock_check = await db.execute( select(Stock).where(Stock.tenant_id == tenant_id).limit(1) ) existing_stock = existing_stock_check.scalars().first() if existing_stock: logger.warning(f"Stock already exists for tenant {tenant_id} - skipping to prevent duplicates") # Count existing stock for reporting stock_count_result = await db.execute( select(Stock).where(Stock.tenant_id == tenant_id) ) existing_stocks = stock_count_result.scalars().all() return { "tenant_id": str(tenant_id), "tenant_name": tenant_name, "stock_created": 0, "ingredients_processed": 0, "skipped": True, "existing_stock_count": len(existing_stocks), "expired_count": 0, "expiring_soon_count": 0, "movements_created": 0, "purchase_movements": 0, "initial_movements": 0, "production_movements": 0, "adjustment_movements": 0, "waste_movements": 0 } # 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 stock changes await db.commit() # Create all types of stock movements logger.info(f" 📦 Creating stock movements...") # 1. Create PURCHASE movements (for all stock received) logger.info(f" 💰 Creating purchase movements...") purchase_movements = await create_purchase_movements_for_stock(db, tenant_id, base_date) for movement in purchase_movements: db.add(movement) # 2. Create INITIAL_STOCK movements (opening inventory) logger.info(f" 📋 Creating initial stock movements...") initial_movements = await create_initial_stock_movements(db, tenant_id, base_date) for movement in initial_movements: db.add(movement) # 3. Create PRODUCTION_USE movements (ingredients consumed) logger.info(f" 🍞 Creating production use movements...") production_movements = await create_production_use_movements(db, tenant_id, base_date) for movement in production_movements: db.add(movement) # 4. Create ADJUSTMENT movements (inventory corrections) logger.info(f" 🔧 Creating adjustment movements...") adjustment_movements = await create_adjustment_movements(db, tenant_id, base_date) for movement in adjustment_movements: db.add(movement) # 5. Create WASTE movements (spoilage, expiration, etc.) logger.info(f" 🗑️ Creating waste movements...") waste_movements = await create_waste_movements_for_tenant(db, tenant_id, base_date) for movement in waste_movements: db.add(movement) # Commit all movements await db.commit() total_movements = ( len(purchase_movements) + len(initial_movements) + len(production_movements) + len(adjustment_movements) + len(waste_movements) ) 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(f" 📝 Stock Movements Created: {total_movements}") logger.info(f" 💰 Purchase: {len(purchase_movements)}") logger.info(f" 📋 Initial Stock: {len(initial_movements)}") logger.info(f" 🍞 Production Use: {len(production_movements)}") logger.info(f" 🔧 Adjustments: {len(adjustment_movements)}") logger.info(f" 🗑️ Waste: {len(waste_movements)}") 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, "movements_created": total_movements, "purchase_movements": len(purchase_movements), "initial_movements": len(initial_movements), "production_movements": len(production_movements), "adjustment_movements": len(adjustment_movements), "waste_movements": len(waste_movements) } 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) total_movements = sum(r.get("movements_created", r.get("waste_movements_created", 0)) 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, "total_movements_created": total_movements, "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(f" 📝 Total movements: {result['total_movements_created']}") logger.info("") # Print per-tenant details for tenant_result in result['results']: movements_count = tenant_result.get('movements_created', tenant_result.get('waste_movements_created', 0)) 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, " f"{movements_count} movements)" ) 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)