Improve the demo feature of the project

This commit is contained in:
Urtzi Alfaro
2025-10-12 18:47:33 +02:00
parent dbc7f2fa0d
commit 7556a00db7
168 changed files with 10102 additions and 18869 deletions

View File

@@ -28,8 +28,7 @@ COPY --from=shared /shared /app/shared
# Copy application code
COPY services/inventory/ .
# Copy scripts directory
COPY scripts/ /app/scripts/
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:${PYTHONPATH:-}"

View File

@@ -0,0 +1,182 @@
"""
Internal Demo Cloning API for Inventory Service
Service-to-service endpoint for cloning inventory data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import structlog
import uuid
from datetime import datetime, timezone
from typing import Optional
import os
from app.core.database import get_db
from app.models.inventory import Ingredient
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Clone inventory service data for a virtual demo tenant
Clones:
- Ingredients from template tenant
- (Future: recipes, stock data, etc.)
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
logger.info(
"Starting inventory data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"ingredients": 0,
# Add other entities here in future
}
# Clone Ingredients
result = await db.execute(
select(Ingredient).where(Ingredient.tenant_id == base_uuid)
)
base_ingredients = result.scalars().all()
logger.info(
"Found ingredients to clone",
count=len(base_ingredients),
base_tenant=str(base_uuid)
)
for ingredient in base_ingredients:
# Create new ingredient with same attributes but new ID and tenant
new_ingredient = Ingredient(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
name=ingredient.name,
sku=ingredient.sku,
barcode=ingredient.barcode,
product_type=ingredient.product_type,
ingredient_category=ingredient.ingredient_category,
product_category=ingredient.product_category,
subcategory=ingredient.subcategory,
description=ingredient.description,
brand=ingredient.brand,
unit_of_measure=ingredient.unit_of_measure,
package_size=ingredient.package_size,
average_cost=ingredient.average_cost,
last_purchase_price=ingredient.last_purchase_price,
standard_cost=ingredient.standard_cost,
low_stock_threshold=ingredient.low_stock_threshold,
reorder_point=ingredient.reorder_point,
reorder_quantity=ingredient.reorder_quantity,
max_stock_level=ingredient.max_stock_level,
shelf_life_days=ingredient.shelf_life_days,
is_perishable=ingredient.is_perishable,
is_active=ingredient.is_active,
allergen_info=ingredient.allergen_info
)
db.add(new_ingredient)
stats["ingredients"] += 1
# Commit all changes
await db.commit()
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Inventory data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "inventory",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone inventory data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "inventory",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "inventory",
"clone_endpoint": "available",
"version": "2.0.0"
}

View File

@@ -23,7 +23,8 @@ from app.api import (
food_safety_alerts,
food_safety_operations,
dashboard,
analytics
analytics,
internal_demo
)
@@ -126,6 +127,7 @@ service.add_router(food_safety_alerts.router)
service.add_router(food_safety_operations.router)
service.add_router(dashboard.router)
service.add_router(analytics.router)
service.add_router(internal_demo.router)
if __name__ == "__main__":

View File

@@ -128,12 +128,24 @@ class IngredientResponse(InventoryBaseSchema):
created_at: datetime
updated_at: datetime
created_by: Optional[str]
# Computed fields
current_stock: Optional[float] = None
is_low_stock: Optional[bool] = None
needs_reorder: Optional[bool] = None
@validator('allergen_info', pre=True)
def validate_allergen_info(cls, v):
"""Convert empty lists or lists to empty dict, handle None"""
if v is None:
return None
if isinstance(v, list):
# If it's an empty list or a list, convert to empty dict
return {} if len(v) == 0 else None
if isinstance(v, dict):
return v
return None
# ===== STOCK SCHEMAS =====

View File

@@ -0,0 +1,449 @@
{
"harinas": [
{
"id": "10000000-0000-0000-0000-000000000001",
"name": "Harina de Trigo T55",
"sku": "HAR-T55-001",
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"description": "Harina de trigo refinada tipo 55, ideal para panes tradicionales y bollería",
"brand": "Molinos San José",
"unit_of_measure": "KILOGRAMS",
"average_cost": 0.85,
"low_stock_threshold": 100.0,
"reorder_point": 150.0,
"allergen_info": [
"gluten"
]
},
{
"id": "10000000-0000-0000-0000-000000000002",
"name": "Harina de Trigo T65",
"sku": "HAR-T65-002",
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"description": "Harina de trigo semi-integral tipo 65, perfecta para panes rústicos",
"brand": "Molinos San José",
"unit_of_measure": "KILOGRAMS",
"average_cost": 0.95,
"low_stock_threshold": 80.0,
"reorder_point": 120.0,
"allergen_info": [
"gluten"
]
},
{
"id": "10000000-0000-0000-0000-000000000003",
"name": "Harina de Fuerza W300",
"sku": "HAR-FUE-003",
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"description": "Harina de gran fuerza W300, ideal para masas con alta hidratación",
"brand": "Harinas Premium",
"unit_of_measure": "KILOGRAMS",
"average_cost": 1.15,
"low_stock_threshold": 50.0,
"reorder_point": 80.0,
"allergen_info": [
"gluten"
]
},
{
"id": "10000000-0000-0000-0000-000000000004",
"name": "Harina Integral de Trigo",
"sku": "HAR-INT-004",
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"description": "Harina integral 100% con salvado, rica en fibra",
"brand": "Bio Cereales",
"unit_of_measure": "KILOGRAMS",
"average_cost": 1.2,
"low_stock_threshold": 60.0,
"reorder_point": 90.0,
"allergen_info": [
"gluten"
]
},
{
"id": "10000000-0000-0000-0000-000000000005",
"name": "Harina de Centeno",
"sku": "HAR-CEN-005",
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"description": "Harina de centeno pura, para panes con sabor intenso",
"brand": "Harinas del Campo",
"unit_of_measure": "KILOGRAMS",
"average_cost": 1.3,
"low_stock_threshold": 40.0,
"reorder_point": 60.0,
"allergen_info": [
"gluten"
]
},
{
"id": "10000000-0000-0000-0000-000000000006",
"name": "Harina de Espelta Ecológica",
"sku": "HAR-ESP-006",
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"description": "Harina de espelta certificada ecológica, de cultivo sostenible",
"brand": "Bio Cereales",
"unit_of_measure": "KILOGRAMS",
"average_cost": 2.45,
"low_stock_threshold": 30.0,
"reorder_point": 50.0,
"allergen_info": [
"gluten"
]
}
],
"lacteos": [
{
"id": "10000000-0000-0000-0000-000000000011",
"name": "Mantequilla sin Sal 82% MG",
"sku": "LAC-MAN-001",
"product_type": "INGREDIENT",
"ingredient_category": "DAIRY",
"product_category": "OTHER_PRODUCTS",
"description": "Mantequilla de alta calidad 82% materia grasa, sin sal",
"brand": "Lácteos del Valle",
"unit_of_measure": "KILOGRAMS",
"average_cost": 6.5,
"low_stock_threshold": 20.0,
"reorder_point": 40.0,
"shelf_life_days": 90,
"is_perishable": true,
"allergen_info": [
"lacteos"
]
},
{
"id": "10000000-0000-0000-0000-000000000012",
"name": "Leche Entera Fresca",
"sku": "LAC-LEC-002",
"product_type": "INGREDIENT",
"ingredient_category": "DAIRY",
"product_category": "OTHER_PRODUCTS",
"description": "Leche entera fresca pasteurizada 3.5% MG",
"brand": "Granja Santa Clara",
"unit_of_measure": "LITERS",
"average_cost": 0.95,
"low_stock_threshold": 50.0,
"reorder_point": 80.0,
"shelf_life_days": 7,
"is_perishable": true,
"allergen_info": [
"lacteos"
]
},
{
"id": "10000000-0000-0000-0000-000000000013",
"name": "Nata para Montar 35% MG",
"sku": "LAC-NAT-003",
"product_type": "INGREDIENT",
"ingredient_category": "DAIRY",
"product_category": "OTHER_PRODUCTS",
"description": "Nata líquida para montar 35% materia grasa",
"brand": "Lácteos Premium",
"unit_of_measure": "LITERS",
"average_cost": 3.2,
"low_stock_threshold": 15.0,
"reorder_point": 30.0,
"shelf_life_days": 21,
"is_perishable": true,
"allergen_info": [
"lacteos"
]
},
{
"id": "10000000-0000-0000-0000-000000000014",
"name": "Huevos Frescos Categoría A",
"sku": "LAC-HUE-004",
"product_type": "INGREDIENT",
"ingredient_category": "DAIRY",
"product_category": "OTHER_PRODUCTS",
"description": "Huevos frescos de gallinas camperas, categoría A",
"brand": "Granja Los Nogales",
"unit_of_measure": "UNITS",
"average_cost": 0.25,
"low_stock_threshold": 200.0,
"reorder_point": 300.0,
"shelf_life_days": 28,
"is_perishable": true,
"allergen_info": [
"huevo"
]
}
],
"levaduras": [
{
"id": "10000000-0000-0000-0000-000000000021",
"name": "Levadura Fresca de Panadería",
"sku": "LEV-FRE-001",
"product_type": "INGREDIENT",
"ingredient_category": "YEAST",
"product_category": "OTHER_PRODUCTS",
"description": "Levadura fresca prensada de alta actividad",
"brand": "Lesaffre",
"unit_of_measure": "KILOGRAMS",
"average_cost": 4.8,
"low_stock_threshold": 5.0,
"reorder_point": 10.0,
"shelf_life_days": 45,
"is_perishable": true,
"allergen_info": []
},
{
"id": "10000000-0000-0000-0000-000000000022",
"name": "Levadura Seca Instantánea",
"sku": "LEV-SEC-002",
"product_type": "INGREDIENT",
"ingredient_category": "YEAST",
"product_category": "OTHER_PRODUCTS",
"description": "Levadura seca de rápida activación",
"brand": "Saf-Instant",
"unit_of_measure": "KILOGRAMS",
"average_cost": 12.5,
"low_stock_threshold": 3.0,
"reorder_point": 5.0,
"shelf_life_days": 730,
"is_perishable": false,
"allergen_info": []
},
{
"id": "10000000-0000-0000-0000-000000000023",
"name": "Masa Madre Líquida Natural",
"sku": "LEV-MAD-003",
"product_type": "INGREDIENT",
"ingredient_category": "YEAST",
"product_category": "OTHER_PRODUCTS",
"description": "Masa madre líquida artesanal de producción propia",
"brand": "Producción Propia",
"unit_of_measure": "KILOGRAMS",
"average_cost": 2.0,
"low_stock_threshold": 5.0,
"reorder_point": 8.0,
"shelf_life_days": 30,
"is_perishable": true,
"allergen_info": [
"gluten"
]
}
],
"ingredientes_basicos": [
{
"id": "10000000-0000-0000-0000-000000000031",
"name": "Sal Marina Fina",
"sku": "BAS-SAL-001",
"product_type": "INGREDIENT",
"ingredient_category": "SALT",
"product_category": "OTHER_PRODUCTS",
"description": "Sal marina fina para panadería",
"brand": "Sal del Mediterráneo",
"unit_of_measure": "KILOGRAMS",
"average_cost": 0.6,
"low_stock_threshold": 50.0,
"reorder_point": 80.0,
"allergen_info": []
},
{
"id": "10000000-0000-0000-0000-000000000032",
"name": "Azúcar Blanco Refinado",
"sku": "BAS-AZU-002",
"product_type": "INGREDIENT",
"ingredient_category": "SUGAR",
"product_category": "OTHER_PRODUCTS",
"description": "Azúcar blanco refinado de remolacha",
"brand": "Azucarera Española",
"unit_of_measure": "KILOGRAMS",
"average_cost": 0.9,
"low_stock_threshold": 80.0,
"reorder_point": 120.0,
"allergen_info": []
},
{
"id": "10000000-0000-0000-0000-000000000033",
"name": "Agua Filtrada",
"sku": "BAS-AGU-003",
"product_type": "INGREDIENT",
"ingredient_category": "OTHER",
"product_category": "OTHER_PRODUCTS",
"description": "Agua filtrada de calidad para panadería",
"brand": "Suministro Local",
"unit_of_measure": "LITERS",
"average_cost": 0.02,
"low_stock_threshold": 500.0,
"reorder_point": 800.0,
"allergen_info": []
}
],
"ingredientes_especiales": [
{
"id": "10000000-0000-0000-0000-000000000041",
"name": "Chocolate Negro 70% Cacao",
"sku": "ESP-CHO-001",
"product_type": "INGREDIENT",
"ingredient_category": "OTHER",
"product_category": "OTHER_PRODUCTS",
"description": "Chocolate de cobertura negro 70% cacao",
"brand": "Valrhona",
"unit_of_measure": "KILOGRAMS",
"average_cost": 15.5,
"low_stock_threshold": 10.0,
"reorder_point": 20.0,
"shelf_life_days": 365,
"allergen_info": [
"lacteos",
"soja"
]
},
{
"id": "10000000-0000-0000-0000-000000000042",
"name": "Almendras Laminadas",
"sku": "ESP-ALM-002",
"product_type": "INGREDIENT",
"ingredient_category": "OTHER",
"product_category": "OTHER_PRODUCTS",
"description": "Almendras españolas laminadas naturales",
"brand": "Frutos Secos Valencia",
"unit_of_measure": "KILOGRAMS",
"average_cost": 8.9,
"low_stock_threshold": 15.0,
"reorder_point": 25.0,
"shelf_life_days": 180,
"allergen_info": [
"frutos_secos"
]
},
{
"id": "10000000-0000-0000-0000-000000000043",
"name": "Pasas de Corinto",
"sku": "ESP-PAS-003",
"product_type": "INGREDIENT",
"ingredient_category": "OTHER",
"product_category": "OTHER_PRODUCTS",
"description": "Pasas de Corinto sin semilla",
"brand": "Frutas del Sol",
"unit_of_measure": "KILOGRAMS",
"average_cost": 4.5,
"low_stock_threshold": 10.0,
"reorder_point": 20.0,
"shelf_life_days": 365,
"allergen_info": []
},
{
"id": "10000000-0000-0000-0000-000000000044",
"name": "Vainilla en Rama Madagascar",
"sku": "ESP-VAI-004",
"product_type": "INGREDIENT",
"ingredient_category": "SPICES",
"product_category": "OTHER_PRODUCTS",
"description": "Vainas de vainilla bourbon de Madagascar",
"brand": "Especias Premium",
"unit_of_measure": "UNITS",
"average_cost": 3.5,
"low_stock_threshold": 20.0,
"reorder_point": 40.0,
"shelf_life_days": 730,
"allergen_info": []
},
{
"id": "10000000-0000-0000-0000-000000000045",
"name": "Crema Pastelera en Polvo",
"sku": "ESP-CRE-005",
"product_type": "INGREDIENT",
"ingredient_category": "OTHER",
"product_category": "OTHER_PRODUCTS",
"description": "Crema pastelera en polvo, fácil preparación",
"brand": "Sosa Ingredients",
"unit_of_measure": "KILOGRAMS",
"average_cost": 7.2,
"low_stock_threshold": 5.0,
"reorder_point": 10.0,
"shelf_life_days": 540,
"allergen_info": [
"lacteos",
"huevo"
]
}
],
"productos_terminados": [
{
"id": "20000000-0000-0000-0000-000000000001",
"name": "Baguette Tradicional",
"sku": "PRO-BAG-001",
"product_type": "FINISHED_PRODUCT",
"ingredient_category": "OTHER",
"product_category": "BREAD",
"description": "Baguette francesa tradicional de 250g",
"brand": "Producción Propia",
"unit_of_measure": "UNITS",
"average_cost": 0.45,
"shelf_life_days": 1,
"is_perishable": true,
"allergen_info": [
"gluten"
]
},
{
"id": "20000000-0000-0000-0000-000000000002",
"name": "Croissant de Mantequilla",
"sku": "PRO-CRO-001",
"product_type": "FINISHED_PRODUCT",
"ingredient_category": "OTHER",
"product_category": "CROISSANTS",
"description": "Croissant artesanal de mantequilla 70g",
"brand": "Producción Propia",
"unit_of_measure": "UNITS",
"average_cost": 0.68,
"shelf_life_days": 2,
"is_perishable": true,
"allergen_info": [
"gluten",
"lacteos"
]
},
{
"id": "20000000-0000-0000-0000-000000000003",
"name": "Pan de Pueblo",
"sku": "PRO-PUE-001",
"product_type": "FINISHED_PRODUCT",
"ingredient_category": "OTHER",
"product_category": "BREAD",
"description": "Hogaza de pan de pueblo con masa madre 800g",
"brand": "Producción Propia",
"unit_of_measure": "UNITS",
"average_cost": 1.85,
"shelf_life_days": 5,
"is_perishable": true,
"allergen_info": [
"gluten"
]
},
{
"id": "20000000-0000-0000-0000-000000000004",
"name": "Napolitana de Chocolate",
"sku": "PRO-NAP-001",
"product_type": "FINISHED_PRODUCT",
"ingredient_category": "OTHER",
"product_category": "PASTRIES",
"description": "Napolitana de hojaldre rellena de chocolate 90g",
"brand": "Producción Propia",
"unit_of_measure": "UNITS",
"average_cost": 0.72,
"shelf_life_days": 2,
"is_perishable": true,
"allergen_info": [
"gluten",
"lacteos",
"soja"
]
}
]
}

View File

@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Demo Inventory Seeding Script for Inventory Service
Creates realistic Spanish ingredients for demo template tenants
This script runs as a Kubernetes init job inside the inventory-service container.
It populates the template tenants with a comprehensive catalog of ingredients.
Usage:
python /app/scripts/demo/seed_demo_inventory.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 json
from datetime import datetime, timezone
from pathlib import Path
# 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
# 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")
def load_ingredients_data():
"""Load ingredients data from JSON file"""
# Look for data file in the same directory as this script
data_file = Path(__file__).parent / "ingredientes_es.json"
if not data_file.exists():
raise FileNotFoundError(
f"Ingredients data file not found: {data_file}. "
"Make sure ingredientes_es.json is in the same directory as this script."
)
logger.info("Loading ingredients data", file=str(data_file))
with open(data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Flatten all ingredient categories into a single list
all_ingredients = []
for category_name, ingredients in data.items():
logger.debug(f"Loading category: {category_name} ({len(ingredients)} items)")
all_ingredients.extend(ingredients)
logger.info(f"Loaded {len(all_ingredients)} ingredients from JSON")
return all_ingredients
async def seed_ingredients_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
ingredients_data: list
) -> dict:
"""
Seed ingredients for a specific tenant using pre-defined UUIDs
Args:
db: Database session
tenant_id: UUID of the tenant
tenant_name: Name of the tenant (for logging)
ingredients_data: List of ingredient dictionaries with pre-defined IDs
Returns:
Dict with seeding statistics
"""
logger.info("" * 80)
logger.info(f"Seeding ingredients for: {tenant_name}")
logger.info(f"Tenant ID: {tenant_id}")
logger.info("" * 80)
created_count = 0
updated_count = 0
skipped_count = 0
for ing_data in ingredients_data:
sku = ing_data["sku"]
name = ing_data["name"]
# Check if ingredient already exists for this tenant with this SKU
result = await db.execute(
select(Ingredient).where(
Ingredient.tenant_id == tenant_id,
Ingredient.sku == sku
)
)
existing_ingredient = result.scalars().first()
if existing_ingredient:
logger.debug(f" ⏭️ Skipping (exists): {sku} - {name}")
skipped_count += 1
continue
# Generate tenant-specific UUID by combining base UUID with tenant ID
# This ensures each tenant has unique IDs but they're deterministic (same on re-run)
base_id = uuid.UUID(ing_data["id"])
# XOR the base ID with the tenant ID to create a tenant-specific ID
tenant_int = int(tenant_id.hex, 16)
base_int = int(base_id.hex, 16)
ingredient_id = uuid.UUID(int=tenant_int ^ base_int)
# Create new ingredient
ingredient = Ingredient(
id=ingredient_id,
tenant_id=tenant_id,
name=name,
sku=sku,
barcode=None, # Could generate EAN-13 barcodes if needed
product_type=ing_data["product_type"],
ingredient_category=ing_data["ingredient_category"],
product_category=ing_data["product_category"],
subcategory=ing_data.get("subcategory"),
description=ing_data["description"],
brand=ing_data.get("brand"),
unit_of_measure=ing_data["unit_of_measure"],
package_size=None,
average_cost=ing_data["average_cost"],
last_purchase_price=ing_data["average_cost"],
standard_cost=ing_data["average_cost"],
low_stock_threshold=ing_data.get("low_stock_threshold", 10.0),
reorder_point=ing_data.get("reorder_point", 20.0),
reorder_quantity=ing_data.get("reorder_point", 20.0) * 2,
max_stock_level=ing_data.get("reorder_point", 20.0) * 5,
shelf_life_days=ing_data.get("shelf_life_days"),
is_perishable=ing_data.get("is_perishable", False),
is_active=True,
allergen_info=ing_data.get("allergen_info", []),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(ingredient)
created_count += 1
logger.debug(f" ✅ Created: {sku} - {name}")
# Commit all changes for this tenant
await db.commit()
logger.info(f" 📊 Created: {created_count}, Skipped: {skipped_count}")
logger.info("")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"created": created_count,
"skipped": skipped_count,
"total": len(ingredients_data)
}
async def seed_inventory(db: AsyncSession):
"""
Seed inventory for all demo template tenants
Args:
db: Database session
Returns:
Dict with overall seeding statistics
"""
logger.info("=" * 80)
logger.info("📦 Starting Demo Inventory Seeding")
logger.info("=" * 80)
# Load ingredients data once
try:
ingredients_data = load_ingredients_data()
except FileNotFoundError as e:
logger.error(str(e))
raise
results = []
# Seed for San Pablo (Traditional Bakery)
logger.info("")
result_san_pablo = await seed_ingredients_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Traditional)",
ingredients_data
)
results.append(result_san_pablo)
# Seed for La Espiga (Central Workshop)
result_la_espiga = await seed_ingredients_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Workshop)",
ingredients_data
)
results.append(result_la_espiga)
# Calculate totals
total_created = sum(r["created"] for r in results)
total_skipped = sum(r["skipped"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Inventory Seeding Completed")
logger.info("=" * 80)
return {
"service": "inventory",
"tenants_seeded": len(results),
"total_created": total_created,
"total_skipped": total_skipped,
"results": results
}
async def main():
"""Main execution function"""
logger.info("Demo Inventory 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_inventory(session)
logger.info("")
logger.info("📊 Seeding Summary:")
logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}")
logger.info(f" ✅ Total created: {result['total_created']}")
logger.info(f" ⏭️ Total skipped: {result['total_skipped']}")
logger.info("")
# Print per-tenant details
for tenant_result in result['results']:
logger.info(
f" {tenant_result['tenant_name']}: "
f"{tenant_result['created']} created, {tenant_result['skipped']} skipped"
)
logger.info("")
logger.info("🎉 Success! Ingredient catalog is ready for cloning.")
logger.info("")
logger.info("Ingredients by category:")
logger.info(" • Harinas: 6 tipos (T55, T65, Fuerza, Integral, Centeno, Espelta)")
logger.info(" • Lácteos: 4 tipos (Mantequilla, Leche, Nata, Huevos)")
logger.info(" • Levaduras: 3 tipos (Fresca, Seca, Masa Madre)")
logger.info(" • Básicos: 3 tipos (Sal, Azúcar, Agua)")
logger.info(" • Especiales: 5 tipos (Chocolate, Almendras, etc.)")
logger.info(" • Productos: 3 referencias")
logger.info("")
logger.info("Next steps:")
logger.info(" 1. Run seed jobs for other services (recipes, suppliers, etc.)")
logger.info(" 2. Verify ingredient data in database")
logger.info(" 3. Test demo session creation with inventory cloning")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Inventory 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)