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

1051 lines
36 KiB
Python

#!/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
# 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")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
# Daily consumption rates (kg/day) - aligned with procurement seed script
# Used to create realistic stock levels that trigger appropriate PO scenarios
DAILY_CONSUMPTION_RATES = {
"HAR-T55-001": 50.0, # Harina de Trigo T55
"HAR-INT-001": 15.0, # Harina Integral Ecológica
"MAN-SAL-001": 8.0, # Mantequilla sin Sal 82% MG
"HUE-FRE-001": 100.0, # Huevos Frescos (units, modeled as kg)
"LEV-SEC-001": 2.5, # Levadura Seca
"SAL-FIN-001": 3.0, # Sal Fina
"ACE-OLI-001": 5.0, # Aceite de Oliva Virgen
"AZU-MOR-001": 6.0, # Azúcar Moreno
"SEM-GIR-001": 2.0, # Semillas de Girasol
"MIE-AZA-001": 1.5, # Miel de Azahar
"CHO-NEG-001": 4.0, # Chocolate Negro 70%
"NUE-PEL-001": 3.5, # Nueces Peladas
"PAS-SUL-001": 2.5 # Pasas Sultanas
}
# Reorder points (kg) - when to trigger PO
REORDER_POINTS_BY_SKU = {
"HAR-T55-001": 150.0, # Critical ingredient
"HAR-INT-001": 50.0,
"MAN-SAL-001": 25.0,
"HUE-FRE-001": 300.0,
"LEV-SEC-001": 10.0,
"SAL-FIN-001": 20.0,
"ACE-OLI-001": 15.0,
"AZU-MOR-001": 20.0,
"SEM-GIR-001": 10.0,
"MIE-AZA-001": 5.0,
"CHO-NEG-001": 15.0,
"NUE-PEL-001": 12.0,
"PAS-SUL-001": 10.0
}
def calculate_realistic_stock_level(
ingredient_sku: str,
make_critical: bool = False,
variability_factor: float = 0.2
) -> float:
"""
Calculate realistic stock level based on consumption rates
Args:
ingredient_sku: SKU of the ingredient
make_critical: If True, create critically low stock (< 1 day supply)
variability_factor: Random variation (default 20%)
Returns:
Realistic stock level in kg
"""
daily_consumption = DAILY_CONSUMPTION_RATES.get(ingredient_sku, 5.0)
if make_critical:
# Critical: 0.5-6 hours worth of stock
days_of_supply = random.uniform(0.02, 0.25)
else:
# Normal: 5-15 days worth of stock (healthy inventory levels)
# This prevents all ingredients from triggering alerts
days_of_supply = random.uniform(5.0, 15.0)
stock_level = daily_consumption * days_of_supply
# Add realistic variability
stock_level *= random.uniform(1 - variability_factor, 1 + variability_factor)
return max(0.1, stock_level) # Minimum 0.1 kg
# 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
# CRITICAL DEMO SCENARIO: Create consumption-aware stock levels
# This creates realistic scenarios that trigger intelligent PO reasoning
# DASHBOARD SHOWCASE: Critical low stock scenarios for realistic alert demonstration
# These will trigger automatic alert generation by the inventory service
critical_low_stock_skus = [
"HAR-T55-001", # Harina Tipo 55 - URGENT: Will run out in <18h, triggers delivery overdue scenario
"LEV-SEC-001", # Levadura (Yeast) - TODAY: Recommend ordering today
"MAN-SAL-001", # Mantequilla (Butter) - For croissant production batch at risk
"CHO-NEG-001" # Chocolate Negro - For chocolate cake batch at risk in 5 hours
]
is_critical_low = ingredient.sku in critical_low_stock_skus
# Calculate target total stock using consumption-aware logic
if is_critical_low:
# Critical low: < 1 day supply (triggers urgent/critical PO reasoning)
target_total_stock = calculate_realistic_stock_level(
ingredient.sku,
make_critical=True
)
num_batches = 1 # Single nearly-empty batch for critical items
else:
# Normal low stock: 1-4 days supply (creates urgency but not critical)
target_total_stock = calculate_realistic_stock_level(
ingredient.sku,
make_critical=False
)
# 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)