Improve demo seed

This commit is contained in:
Urtzi Alfaro
2025-10-17 07:31:14 +02:00
parent b6cb800758
commit d4060962e4
56 changed files with 8235 additions and 339 deletions

View File

@@ -1,6 +1,6 @@
"""
Internal Demo Cloning API for Inventory Service
Service-to-service endpoint for cloning inventory data
Service-to-service endpoint for cloning inventory data with date adjustment
"""
from fastapi import APIRouter, Depends, HTTPException, Header
@@ -11,9 +11,15 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
import os
import sys
from pathlib import Path
# Add shared path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from app.core.database import get_db
from app.models.inventory import Ingredient
from app.models.inventory import Ingredient, Stock
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
@@ -48,7 +54,8 @@ async def clone_demo_data(
Clones:
- Ingredients from template tenant
- (Future: recipes, stock data, etc.)
- Stock batches with date-adjusted expiration dates
- Generates inventory alerts based on stock status
Args:
base_tenant_id: Template tenant UUID to clone from
@@ -60,13 +67,15 @@ async def clone_demo_data(
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
session_created_at = datetime.now(timezone.utc)
logger.info(
"Starting inventory data cloning",
"Starting inventory data cloning with date adjustment",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id
session_id=session_id,
session_created_at=session_created_at.isoformat()
)
try:
@@ -77,9 +86,13 @@ async def clone_demo_data(
# Track cloning statistics
stats = {
"ingredients": 0,
# Add other entities here in future
"stock_batches": 0,
"alerts_generated": 0
}
# Mapping from base ingredient ID to virtual ingredient ID
ingredient_id_mapping = {}
# Clone Ingredients
result = await db.execute(
select(Ingredient).where(Ingredient.tenant_id == base_uuid)
@@ -94,8 +107,9 @@ async def clone_demo_data(
for ingredient in base_ingredients:
# Create new ingredient with same attributes but new ID and tenant
new_ingredient_id = uuid.uuid4()
new_ingredient = Ingredient(
id=uuid.uuid4(),
id=new_ingredient_id,
tenant_id=virtual_uuid,
name=ingredient.name,
sku=ingredient.sku,
@@ -116,21 +130,123 @@ async def clone_demo_data(
reorder_quantity=ingredient.reorder_quantity,
max_stock_level=ingredient.max_stock_level,
shelf_life_days=ingredient.shelf_life_days,
display_life_hours=ingredient.display_life_hours,
best_before_hours=ingredient.best_before_hours,
storage_instructions=ingredient.storage_instructions,
is_perishable=ingredient.is_perishable,
is_active=ingredient.is_active,
allergen_info=ingredient.allergen_info
allergen_info=ingredient.allergen_info,
nutritional_info=ingredient.nutritional_info
)
db.add(new_ingredient)
stats["ingredients"] += 1
# Store mapping for stock cloning
ingredient_id_mapping[ingredient.id] = new_ingredient_id
await db.flush() # Ensure ingredients are persisted before stock
# Clone Stock batches with date adjustment
result = await db.execute(
select(Stock).where(Stock.tenant_id == base_uuid)
)
base_stocks = result.scalars().all()
logger.info(
"Found stock batches to clone",
count=len(base_stocks),
base_tenant=str(base_uuid)
)
for stock in base_stocks:
# Map ingredient ID
new_ingredient_id = ingredient_id_mapping.get(stock.ingredient_id)
if not new_ingredient_id:
logger.warning(
"Stock references non-existent ingredient, skipping",
stock_id=str(stock.id),
ingredient_id=str(stock.ingredient_id)
)
continue
# Adjust dates relative to session creation
adjusted_expiration = adjust_date_for_demo(
stock.expiration_date,
session_created_at,
BASE_REFERENCE_DATE
)
adjusted_received = adjust_date_for_demo(
stock.received_date,
session_created_at,
BASE_REFERENCE_DATE
)
adjusted_best_before = adjust_date_for_demo(
stock.best_before_date,
session_created_at,
BASE_REFERENCE_DATE
)
adjusted_created = adjust_date_for_demo(
stock.created_at,
session_created_at,
BASE_REFERENCE_DATE
) or session_created_at
# Create new stock batch
new_stock = Stock(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
ingredient_id=new_ingredient_id,
supplier_id=stock.supplier_id,
batch_number=stock.batch_number,
lot_number=stock.lot_number,
supplier_batch_ref=stock.supplier_batch_ref,
production_stage=stock.production_stage,
current_quantity=stock.current_quantity,
reserved_quantity=stock.reserved_quantity,
available_quantity=stock.available_quantity,
received_date=adjusted_received,
expiration_date=adjusted_expiration,
best_before_date=adjusted_best_before,
unit_cost=stock.unit_cost,
total_cost=stock.total_cost,
storage_location=stock.storage_location,
warehouse_zone=stock.warehouse_zone,
shelf_position=stock.shelf_position,
requires_refrigeration=stock.requires_refrigeration,
requires_freezing=stock.requires_freezing,
storage_temperature_min=stock.storage_temperature_min,
storage_temperature_max=stock.storage_temperature_max,
storage_humidity_max=stock.storage_humidity_max,
shelf_life_days=stock.shelf_life_days,
storage_instructions=stock.storage_instructions,
is_available=stock.is_available,
is_expired=stock.is_expired,
quality_status=stock.quality_status,
created_at=adjusted_created,
updated_at=session_created_at
)
db.add(new_stock)
stats["stock_batches"] += 1
# Commit all changes
await db.commit()
# Generate inventory alerts
try:
from shared.utils.alert_generator import generate_inventory_alerts
alerts_count = await generate_inventory_alerts(db, virtual_uuid, session_created_at)
stats["alerts_generated"] = alerts_count
await db.commit() # Commit alerts
logger.info(f"Generated {alerts_count} inventory alerts", virtual_tenant_id=virtual_tenant_id)
except Exception as e:
logger.warning(f"Failed to generate alerts: {str(e)}", exc_info=True)
stats["alerts_generated"] = 0
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Inventory data cloning completed",
"Inventory data cloning completed with date adjustment",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,

View File

@@ -303,6 +303,7 @@ class Stock(Base):
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'ingredient_id': str(self.ingredient_id),
'supplier_id': str(self.supplier_id) if self.supplier_id else None,
'batch_number': self.batch_number,
'lot_number': self.lot_number,
'supplier_batch_ref': self.supplier_batch_ref,

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Inventory Seeding Script for Inventory Service
Creates realistic Spanish ingredients for demo template tenants

View File

@@ -0,0 +1,423 @@
#!/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)

View File

@@ -0,0 +1,49 @@
{
"stock_distribution": {
"batches_per_ingredient": {
"min": 3,
"max": 5
},
"expiration_distribution": {
"expired": 0.05,
"expiring_soon_3days": 0.10,
"moderate_alert_7days": 0.15,
"short_term_30days": 0.30,
"long_term_90days": 0.40
},
"quality_status_weights": {
"good": 0.75,
"damaged": 0.10,
"expired": 0.10,
"quarantined": 0.05
},
"storage_locations": [
"Almacén Principal",
"Cámara Fría",
"Congelador",
"Zona Seca",
"Estantería A",
"Estantería B",
"Zona Refrigerada",
"Depósito Exterior"
],
"warehouse_zones": ["A", "B", "C", "D"]
},
"quantity_ranges": {
"kg": {"min": 5.0, "max": 50.0},
"l": {"min": 5.0, "max": 50.0},
"g": {"min": 500.0, "max": 5000.0},
"ml": {"min": 500.0, "max": 5000.0},
"units": {"min": 10, "max": 200},
"pcs": {"min": 10, "max": 200},
"pkg": {"min": 5, "max": 50},
"bags": {"min": 5, "max": 30},
"boxes": {"min": 5, "max": 25}
},
"cost_variation": {
"min_multiplier": 0.90,
"max_multiplier": 1.10
},
"refrigeration_categories": ["dairy", "eggs"],
"freezing_categories": []
}