424 lines
14 KiB
Python
424 lines
14 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
|
|
|
|
# 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)
|