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

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

View File

@@ -0,0 +1,377 @@
"""
Internal Demo Cloning API for Recipes Service
Service-to-service endpoint for cloning recipe and production 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, timedelta
from typing import Optional
import os
from app.core.database import get_db
from app.models.recipes import (
Recipe, RecipeIngredient, ProductionBatch, ProductionIngredientConsumption,
RecipeStatus, ProductionStatus, MeasurementUnit, ProductionPriority
)
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 recipes service data for a virtual demo tenant
Clones:
- Recipes (master recipe definitions)
- Recipe ingredients (with measurements)
- Production batches (historical production runs)
- Production ingredient consumption (actual usage tracking)
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 recipes 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 = {
"recipes": 0,
"recipe_ingredients": 0,
"production_batches": 0,
"ingredient_consumptions": 0
}
# Recipe ID mapping (old -> new)
recipe_id_map = {}
recipe_ingredient_map = {}
# Clone Recipes
result = await db.execute(
select(Recipe).where(Recipe.tenant_id == base_uuid)
)
base_recipes = result.scalars().all()
logger.info(
"Found recipes to clone",
count=len(base_recipes),
base_tenant=str(base_uuid)
)
for recipe in base_recipes:
new_recipe_id = uuid.uuid4()
recipe_id_map[recipe.id] = new_recipe_id
new_recipe = Recipe(
id=new_recipe_id,
tenant_id=virtual_uuid,
name=recipe.name,
recipe_code=f"REC-{uuid.uuid4().hex[:8].upper()}", # New unique code
version=recipe.version,
finished_product_id=recipe.finished_product_id, # Keep product reference
description=recipe.description,
category=recipe.category,
cuisine_type=recipe.cuisine_type,
difficulty_level=recipe.difficulty_level,
yield_quantity=recipe.yield_quantity,
yield_unit=recipe.yield_unit,
prep_time_minutes=recipe.prep_time_minutes,
cook_time_minutes=recipe.cook_time_minutes,
total_time_minutes=recipe.total_time_minutes,
rest_time_minutes=recipe.rest_time_minutes,
estimated_cost_per_unit=recipe.estimated_cost_per_unit,
last_calculated_cost=recipe.last_calculated_cost,
cost_calculation_date=recipe.cost_calculation_date,
target_margin_percentage=recipe.target_margin_percentage,
suggested_selling_price=recipe.suggested_selling_price,
instructions=recipe.instructions,
preparation_notes=recipe.preparation_notes,
storage_instructions=recipe.storage_instructions,
quality_standards=recipe.quality_standards,
serves_count=recipe.serves_count,
nutritional_info=recipe.nutritional_info,
allergen_info=recipe.allergen_info,
dietary_tags=recipe.dietary_tags,
batch_size_multiplier=recipe.batch_size_multiplier,
minimum_batch_size=recipe.minimum_batch_size,
maximum_batch_size=recipe.maximum_batch_size,
optimal_production_temperature=recipe.optimal_production_temperature,
optimal_humidity=recipe.optimal_humidity,
quality_check_points=recipe.quality_check_points,
quality_check_configuration=recipe.quality_check_configuration,
common_issues=recipe.common_issues,
status=recipe.status,
is_seasonal=recipe.is_seasonal,
season_start_month=recipe.season_start_month,
season_end_month=recipe.season_end_month,
is_signature_item=recipe.is_signature_item,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=recipe.created_by,
updated_by=recipe.updated_by
)
db.add(new_recipe)
stats["recipes"] += 1
# Flush to get recipe IDs for foreign keys
await db.flush()
# Clone Recipe Ingredients
for old_recipe_id, new_recipe_id in recipe_id_map.items():
result = await db.execute(
select(RecipeIngredient).where(RecipeIngredient.recipe_id == old_recipe_id)
)
recipe_ingredients = result.scalars().all()
for ingredient in recipe_ingredients:
new_ingredient_id = uuid.uuid4()
recipe_ingredient_map[ingredient.id] = new_ingredient_id
new_ingredient = RecipeIngredient(
id=new_ingredient_id,
tenant_id=virtual_uuid,
recipe_id=new_recipe_id,
ingredient_id=ingredient.ingredient_id, # Keep ingredient reference
quantity=ingredient.quantity,
unit=ingredient.unit,
quantity_in_base_unit=ingredient.quantity_in_base_unit,
alternative_quantity=ingredient.alternative_quantity,
alternative_unit=ingredient.alternative_unit,
preparation_method=ingredient.preparation_method,
ingredient_notes=ingredient.ingredient_notes,
is_optional=ingredient.is_optional,
ingredient_order=ingredient.ingredient_order,
ingredient_group=ingredient.ingredient_group,
substitution_options=ingredient.substitution_options,
substitution_ratio=ingredient.substitution_ratio,
unit_cost=ingredient.unit_cost,
total_cost=ingredient.total_cost,
cost_updated_at=ingredient.cost_updated_at
)
db.add(new_ingredient)
stats["recipe_ingredients"] += 1
# Flush to get recipe ingredient IDs
await db.flush()
# Clone Production Batches
result = await db.execute(
select(ProductionBatch).where(ProductionBatch.tenant_id == base_uuid)
)
base_batches = result.scalars().all()
logger.info(
"Found production batches to clone",
count=len(base_batches),
base_tenant=str(base_uuid)
)
# Calculate date offset to make production recent
if base_batches:
max_date = max(batch.production_date for batch in base_batches)
today = datetime.now(timezone.utc)
date_offset = today - max_date
else:
date_offset = timedelta(days=0)
batch_id_map = {}
for batch in base_batches:
new_batch_id = uuid.uuid4()
batch_id_map[batch.id] = new_batch_id
# Get the new recipe ID
new_recipe_id = recipe_id_map.get(batch.recipe_id, batch.recipe_id)
new_batch = ProductionBatch(
id=new_batch_id,
tenant_id=virtual_uuid,
recipe_id=new_recipe_id,
batch_number=f"BATCH-{uuid.uuid4().hex[:8].upper()}", # New batch number
production_date=batch.production_date + date_offset,
planned_start_time=batch.planned_start_time + date_offset if batch.planned_start_time else None,
actual_start_time=batch.actual_start_time + date_offset if batch.actual_start_time else None,
planned_end_time=batch.planned_end_time + date_offset if batch.planned_end_time else None,
actual_end_time=batch.actual_end_time + date_offset if batch.actual_end_time else None,
planned_quantity=batch.planned_quantity,
actual_quantity=batch.actual_quantity,
yield_percentage=batch.yield_percentage,
batch_size_multiplier=batch.batch_size_multiplier,
status=batch.status,
priority=batch.priority,
assigned_staff=batch.assigned_staff,
production_notes=batch.production_notes,
quality_score=batch.quality_score,
quality_notes=batch.quality_notes,
defect_rate=batch.defect_rate,
rework_required=batch.rework_required,
planned_material_cost=batch.planned_material_cost,
actual_material_cost=batch.actual_material_cost,
labor_cost=batch.labor_cost,
overhead_cost=batch.overhead_cost,
total_production_cost=batch.total_production_cost,
cost_per_unit=batch.cost_per_unit,
production_temperature=batch.production_temperature,
production_humidity=batch.production_humidity,
oven_temperature=batch.oven_temperature,
baking_time_minutes=batch.baking_time_minutes,
waste_quantity=batch.waste_quantity,
waste_reason=batch.waste_reason,
efficiency_percentage=batch.efficiency_percentage,
customer_order_reference=batch.customer_order_reference,
pre_order_quantity=batch.pre_order_quantity,
shelf_quantity=batch.shelf_quantity,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=batch.created_by,
completed_by=batch.completed_by
)
db.add(new_batch)
stats["production_batches"] += 1
# Flush to get batch IDs
await db.flush()
# Clone Production Ingredient Consumption
for old_batch_id, new_batch_id in batch_id_map.items():
result = await db.execute(
select(ProductionIngredientConsumption).where(
ProductionIngredientConsumption.production_batch_id == old_batch_id
)
)
consumptions = result.scalars().all()
for consumption in consumptions:
# Get the new recipe ingredient ID
new_recipe_ingredient_id = recipe_ingredient_map.get(
consumption.recipe_ingredient_id,
consumption.recipe_ingredient_id
)
new_consumption = ProductionIngredientConsumption(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
production_batch_id=new_batch_id,
recipe_ingredient_id=new_recipe_ingredient_id,
ingredient_id=consumption.ingredient_id, # Keep ingredient reference
stock_id=None, # Don't clone stock references
planned_quantity=consumption.planned_quantity,
actual_quantity=consumption.actual_quantity,
unit=consumption.unit,
variance_quantity=consumption.variance_quantity,
variance_percentage=consumption.variance_percentage,
unit_cost=consumption.unit_cost,
total_cost=consumption.total_cost,
consumption_time=consumption.consumption_time + date_offset,
consumption_notes=consumption.consumption_notes,
staff_member=consumption.staff_member,
ingredient_condition=consumption.ingredient_condition,
quality_impact=consumption.quality_impact,
substitution_used=consumption.substitution_used,
substitution_details=consumption.substitution_details
)
db.add(new_consumption)
stats["ingredient_consumptions"] += 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(
"Recipes data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "recipes",
"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 recipes data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "recipes",
"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": "recipes",
"clone_endpoint": "available",
"version": "2.0.0"
}

View File

@@ -14,7 +14,7 @@ from .core.database import db_manager
from shared.service_base import StandardFastAPIService
# Import API routers
from .api import recipes, recipe_quality_configs, recipe_operations
from .api import recipes, recipe_quality_configs, recipe_operations, internal_demo
# Import models to register them with SQLAlchemy metadata
from .models import recipes as recipe_models
@@ -118,6 +118,7 @@ service.setup_custom_middleware()
service.add_router(recipes.router)
service.add_router(recipe_quality_configs.router)
service.add_router(recipe_operations.router)
service.add_router(internal_demo.router)
if __name__ == "__main__":

View File

@@ -17,20 +17,20 @@ from shared.database.base import Base
class RecipeStatus(enum.Enum):
"""Recipe lifecycle status"""
DRAFT = "draft"
ACTIVE = "active"
TESTING = "testing"
ARCHIVED = "archived"
DISCONTINUED = "discontinued"
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
TESTING = "TESTING"
ARCHIVED = "ARCHIVED"
DISCONTINUED = "DISCONTINUED"
class ProductionStatus(enum.Enum):
"""Production batch status"""
PLANNED = "planned"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
PLANNED = "PLANNED"
IN_PROGRESS = "IN_PROGRESS"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class MeasurementUnit(enum.Enum):

View File

@@ -12,7 +12,7 @@ from datetime import datetime
import structlog
from shared.database.repository import BaseRepository
from ..models.recipes import Recipe, RecipeIngredient
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
from ..schemas.recipes import RecipeCreate, RecipeUpdate
logger = structlog.get_logger()
@@ -197,7 +197,7 @@ class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
select(func.count(Recipe.id)).where(
and_(
Recipe.tenant_id == tenant_id,
Recipe.status == "active"
Recipe.status == RecipeStatus.ACTIVE
)
)
)
@@ -231,15 +231,18 @@ class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
.where(Recipe.tenant_id == tenant_id)
.group_by(Recipe.category)
)
categories = dict(category_result.all())
category_data = category_result.all()
# Convert to list of dicts for the schema
category_breakdown = [
{"category": category or "Uncategorized", "count": count}
for category, count in category_data
]
return {
"total_recipes": total_recipes,
"active_recipes": active_recipes,
"signature_recipes": signature_recipes,
"seasonal_recipes": seasonal_recipes,
"draft_recipes": total_recipes - active_recipes,
"categories": categories,
"average_difficulty": 3.0, # Could calculate from actual data
"total_categories": len(categories)
"category_breakdown": category_breakdown
}

View File

@@ -0,0 +1,447 @@
{
"recetas": [
{
"id": "30000000-0000-0000-0000-000000000001",
"finished_product_id": "20000000-0000-0000-0000-000000000001",
"name": "Baguette Francesa Tradicional",
"category": "Panes",
"cuisine_type": "Francesa",
"difficulty_level": 2,
"yield_quantity": 10.0,
"yield_unit": "units",
"prep_time_minutes": 20,
"cook_time_minutes": 25,
"total_time_minutes": 165,
"rest_time_minutes": 120,
"description": "Baguette francesa tradicional con corteza crujiente y miga alveolada. Perfecta para acompañar cualquier comida.",
"instructions": {
"steps": [
{
"step": 1,
"title": "Amasado",
"description": "Mezclar harina, agua, sal y levadura. Amasar durante 15 minutos hasta obtener una masa lisa y elástica.",
"duration_minutes": 15
},
{
"step": 2,
"title": "Primera Fermentación",
"description": "Dejar reposar la masa en un recipiente tapado durante 60 minutos a temperatura ambiente (22-24°C).",
"duration_minutes": 60
},
{
"step": 3,
"title": "División y Formado",
"description": "Dividir la masa en 10 piezas de 250g cada una. Formar las baguettes dándoles la forma alargada característica.",
"duration_minutes": 20
},
{
"step": 4,
"title": "Segunda Fermentación",
"description": "Colocar las baguettes en un lienzo enharinado y dejar fermentar 60 minutos más.",
"duration_minutes": 60
},
{
"step": 5,
"title": "Greñado y Horneado",
"description": "Hacer cortes diagonales en la superficie con una cuchilla. Hornear a 240°C con vapor inicial durante 25 minutos.",
"duration_minutes": 25
}
]
},
"preparation_notes": "Es crucial usar vapor al inicio del horneado para lograr una corteza crujiente. La temperatura del agua debe estar entre 18-20°C.",
"storage_instructions": "Consumir el mismo día de producción. Se puede congelar después del horneado.",
"quality_standards": "Color dorado uniforme, corteza muy crujiente, miga alveolada con alveolos irregulares, aroma característico a trigo.",
"is_seasonal": false,
"is_signature_item": true,
"ingredientes": [
{
"ingredient_id": "10000000-0000-0000-0000-000000000001",
"ingredient_sku": "HAR-T55-001",
"quantity": 1000.0,
"unit": "g",
"preparation_method": "tamizada",
"ingredient_order": 1,
"ingredient_group": "Secos"
},
{
"ingredient_id": "10000000-0000-0000-0000-000000000033",
"ingredient_sku": "BAS-AGU-003",
"quantity": 650.0,
"unit": "ml",
"preparation_method": "temperatura ambiente",
"ingredient_order": 2,
"ingredient_group": "Líquidos"
},
{
"ingredient_id": "10000000-0000-0000-0000-000000000031",
"ingredient_sku": "BAS-SAL-001",
"quantity": 20.0,
"unit": "g",
"ingredient_order": 3,
"ingredient_group": "Secos"
},
{
"ingredient_id": "10000000-0000-0000-0000-000000000021",
"ingredient_sku": "LEV-FRE-001",
"quantity": 15.0,
"unit": "g",
"preparation_method": "desmenuzada",
"ingredient_order": 4,
"ingredient_group": "Fermentos"
}
]
},
{
"id": "30000000-0000-0000-0000-000000000002",
"finished_product_id": "20000000-0000-0000-0000-000000000002",
"name": "Croissant de Mantequilla Artesanal",
"category": "Bollería",
"cuisine_type": "Francesa",
"difficulty_level": 4,
"yield_quantity": 12.0,
"yield_unit": "units",
"prep_time_minutes": 45,
"cook_time_minutes": 18,
"total_time_minutes": 333,
"rest_time_minutes": 270,
"description": "Croissant de mantequilla con laminado perfecto y textura hojaldrada. Elaboración artesanal con mantequilla de alta calidad.",
"instructions": {
"steps": [
{
"step": 1,
"title": "Preparación de la Masa Base",
"description": "Mezclar todos los ingredientes excepto la mantequilla de laminado. Amasar hasta obtener una masa homogénea.",
"duration_minutes": 20
},
{
"step": 2,
"title": "Reposo en Frío",
"description": "Envolver la masa en film y refrigerar durante 2 horas.",
"duration_minutes": 120
},
{
"step": 3,
"title": "Laminado",
"description": "Extender la masa en rectángulo. Colocar la mantequilla en el centro y hacer 3 dobleces sencillos con 30 minutos de reposo entre cada uno.",
"duration_minutes": 90
},
{
"step": 4,
"title": "Formado",
"description": "Extender a 3mm de grosor, cortar triángulos y enrollar para formar los croissants.",
"duration_minutes": 25
},
{
"step": 5,
"title": "Fermentación Final",
"description": "Dejar fermentar a 26°C durante 2-3 horas hasta que dupliquen su volumen.",
"duration_minutes": 150
},
{
"step": 6,
"title": "Horneado",
"description": "Pintar con huevo batido y hornear a 200°C durante 18 minutos hasta dorar.",
"duration_minutes": 18
}
]
},
"preparation_notes": "La mantequilla para laminar debe estar a 15-16°C, flexible pero no blanda. Trabajar en ambiente fresco.",
"storage_instructions": "Consumir el día de producción. Se puede congelar la masa formada antes de la fermentación final.",
"quality_standards": "Laminado perfecto con capas visibles, color marrón brillante, estructura hojaldrada bien definida, aroma intenso a mantequilla.",
"is_seasonal": false,
"is_signature_item": true,
"ingredientes": [
{
"ingredient_sku": "HAR-T55-001",
"quantity": 500.0,
"unit": "g",
"ingredient_order": 1,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000001"
},
{
"ingredient_sku": "LAC-LEC-002",
"quantity": 120.0,
"unit": "ml",
"preparation_method": "tibia",
"ingredient_order": 2,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000012"
},
{
"ingredient_sku": "BAS-AGU-003",
"quantity": 80.0,
"unit": "ml",
"ingredient_order": 3,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000033"
},
{
"ingredient_sku": "BAS-AZU-002",
"quantity": 50.0,
"unit": "g",
"ingredient_order": 4,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000032"
},
{
"ingredient_sku": "BAS-SAL-001",
"quantity": 10.0,
"unit": "g",
"ingredient_order": 5,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000031"
},
{
"ingredient_sku": "LEV-FRE-001",
"quantity": 20.0,
"unit": "g",
"ingredient_order": 6,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000021"
},
{
"ingredient_sku": "LAC-MAN-001",
"quantity": 25.0,
"unit": "g",
"preparation_method": "en la masa",
"ingredient_order": 7,
"ingredient_group": "Masa base",
"ingredient_id": "10000000-0000-0000-0000-000000000011"
},
{
"ingredient_sku": "LAC-MAN-001",
"quantity": 250.0,
"unit": "g",
"preparation_method": "para laminar (15-16°C)",
"ingredient_order": 8,
"ingredient_group": "Laminado",
"ingredient_id": "10000000-0000-0000-0000-000000000011"
}
]
},
{
"name": "Pan de Pueblo con Masa Madre",
"category": "Panes Artesanales",
"cuisine_type": "Española",
"difficulty_level": 3,
"yield_quantity": 4.0,
"yield_unit": "units",
"prep_time_minutes": 30,
"cook_time_minutes": 45,
"total_time_minutes": 435,
"rest_time_minutes": 360,
"description": "Hogaza de pan rústico elaborada con masa madre natural. Corteza gruesa y miga densa con sabor ligeramente ácido.",
"instructions": {
"steps": [
{
"step": 1,
"title": "Autolisis",
"description": "Mezclar harinas y agua, dejar reposar 30 minutos para desarrollar el gluten.",
"duration_minutes": 30
},
{
"step": 2,
"title": "Incorporación de Masa Madre y Sal",
"description": "Añadir la masa madre y la sal. Amasar suavemente hasta integrar completamente.",
"duration_minutes": 15
},
{
"step": 3,
"title": "Fermentación en Bloque con Pliegues",
"description": "Realizar 4 series de pliegues cada 30 minutos durante las primeras 2 horas. Luego dejar reposar 2 horas más.",
"duration_minutes": 240
},
{
"step": 4,
"title": "División y Preformado",
"description": "Dividir en 4 piezas de 800g. Preformar en bolas y dejar reposar 30 minutos.",
"duration_minutes": 30
},
{
"step": 5,
"title": "Formado Final",
"description": "Formar las hogazas dándoles tensión superficial. Colocar en banneton o lienzo enharinado.",
"duration_minutes": 15
},
{
"step": 6,
"title": "Fermentación Final",
"description": "Dejar fermentar a temperatura ambiente durante 2 horas o en frío durante la noche.",
"duration_minutes": 120
},
{
"step": 7,
"title": "Horneado",
"description": "Hacer cortes en la superficie. Hornear a 230°C con vapor inicial durante 45 minutos.",
"duration_minutes": 45
}
]
},
"preparation_notes": "La masa madre debe estar activa y en su punto óptimo. La temperatura final de la masa debe ser 24-25°C.",
"storage_instructions": "Se conserva hasta 5-7 días en bolsa de papel. Mejora al segundo día.",
"quality_standards": "Corteza gruesa y oscura, miga densa pero húmeda, alveolos irregulares, sabor complejo ligeramente ácido.",
"is_seasonal": false,
"is_signature_item": true,
"ingredientes": [
{
"ingredient_sku": "HAR-T65-002",
"quantity": 800.0,
"unit": "g",
"ingredient_order": 1,
"ingredient_group": "Harinas",
"ingredient_id": "10000000-0000-0000-0000-000000000002"
},
{
"ingredient_sku": "HAR-INT-004",
"quantity": 200.0,
"unit": "g",
"ingredient_order": 2,
"ingredient_group": "Harinas",
"ingredient_id": "10000000-0000-0000-0000-000000000004"
},
{
"ingredient_sku": "LEV-MAD-003",
"quantity": 300.0,
"unit": "g",
"preparation_method": "activa y alimentada",
"ingredient_order": 3,
"ingredient_group": "Fermentos",
"ingredient_id": "10000000-0000-0000-0000-000000000023"
},
{
"ingredient_sku": "BAS-AGU-003",
"quantity": 650.0,
"unit": "ml",
"preparation_method": "temperatura ambiente",
"ingredient_order": 4,
"ingredient_group": "Líquidos",
"ingredient_id": "10000000-0000-0000-0000-000000000033"
},
{
"ingredient_sku": "BAS-SAL-001",
"quantity": 22.0,
"unit": "g",
"ingredient_order": 5,
"ingredient_group": "Condimentos",
"ingredient_id": "10000000-0000-0000-0000-000000000031"
}
],
"id": "30000000-0000-0000-0000-000000000003",
"finished_product_id": "20000000-0000-0000-0000-000000000003"
},
{
"name": "Napolitana de Chocolate",
"category": "Bollería",
"cuisine_type": "Española",
"difficulty_level": 3,
"yield_quantity": 16.0,
"yield_unit": "units",
"prep_time_minutes": 40,
"cook_time_minutes": 15,
"total_time_minutes": 325,
"rest_time_minutes": 270,
"description": "Bollería de hojaldre rectangular rellena de chocolate. Clásico de las panaderías españolas.",
"instructions": {
"steps": [
{
"step": 1,
"title": "Masa Base y Laminado",
"description": "Preparar masa de hojaldre siguiendo el mismo proceso que los croissants.",
"duration_minutes": 180
},
{
"step": 2,
"title": "Corte y Formado",
"description": "Extender la masa y cortar rectángulos de 10x15cm. Colocar barritas de chocolate en el centro.",
"duration_minutes": 20
},
{
"step": 3,
"title": "Sellado",
"description": "Doblar la masa sobre sí misma para cubrir el chocolate. Sellar bien los bordes.",
"duration_minutes": 20
},
{
"step": 4,
"title": "Fermentación",
"description": "Dejar fermentar a 26°C durante 90 minutos.",
"duration_minutes": 90
},
{
"step": 5,
"title": "Horneado",
"description": "Pintar con huevo y hornear a 190°C durante 15 minutos.",
"duration_minutes": 15
}
]
},
"preparation_notes": "El chocolate debe ser de buena calidad para un mejor resultado. No sobrecargar de chocolate.",
"storage_instructions": "Consumir preferiblemente el día de producción.",
"quality_standards": "Hojaldre bien desarrollado, chocolate fundido en el interior, color dorado brillante.",
"is_seasonal": false,
"is_signature_item": false,
"ingredientes": [
{
"ingredient_sku": "HAR-T55-001",
"quantity": 500.0,
"unit": "g",
"ingredient_order": 1,
"ingredient_group": "Masa",
"ingredient_id": "10000000-0000-0000-0000-000000000001"
},
{
"ingredient_sku": "LAC-MAN-001",
"quantity": 300.0,
"unit": "g",
"ingredient_order": 2,
"ingredient_group": "Laminado",
"ingredient_id": "10000000-0000-0000-0000-000000000011"
},
{
"ingredient_sku": "ESP-CHO-001",
"quantity": 200.0,
"unit": "g",
"preparation_method": "en barritas",
"ingredient_order": 3,
"ingredient_group": "Relleno",
"ingredient_id": "10000000-0000-0000-0000-000000000041"
},
{
"ingredient_sku": "BAS-AZU-002",
"quantity": 60.0,
"unit": "g",
"ingredient_order": 4,
"ingredient_group": "Masa",
"ingredient_id": "10000000-0000-0000-0000-000000000032"
},
{
"ingredient_sku": "BAS-SAL-001",
"quantity": 10.0,
"unit": "g",
"ingredient_order": 5,
"ingredient_group": "Masa",
"ingredient_id": "10000000-0000-0000-0000-000000000031"
},
{
"ingredient_sku": "LEV-FRE-001",
"quantity": 15.0,
"unit": "g",
"ingredient_order": 6,
"ingredient_group": "Masa",
"ingredient_id": "10000000-0000-0000-0000-000000000021"
},
{
"ingredient_sku": "LAC-LEC-002",
"quantity": 150.0,
"unit": "ml",
"ingredient_order": 7,
"ingredient_group": "Masa",
"ingredient_id": "10000000-0000-0000-0000-000000000012"
}
],
"id": "30000000-0000-0000-0000-000000000004",
"finished_product_id": "20000000-0000-0000-0000-000000000004"
}
]
}

View File

@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""
Demo Recipes Seeding Script for Recipes Service
Creates realistic Spanish recipes for demo template tenants
This script runs as a Kubernetes init job inside the recipes-service container.
It populates the template tenants with a comprehensive catalog of recipes using pre-defined UUIDs.
Usage:
python /app/scripts/demo/seed_demo_recipes.py
Environment Variables Required:
RECIPES_DATABASE_URL - PostgreSQL connection string for recipes 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, timedelta
from pathlib import Path
import random
# 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.recipes import (
Recipe, RecipeIngredient, ProductionBatch,
RecipeStatus, ProductionStatus, ProductionPriority, MeasurementUnit
)
# 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_recipes_data():
"""Load recipes data from JSON file"""
# Look for data file in the same directory as this script
data_file = Path(__file__).parent / "recetas_es.json"
if not data_file.exists():
raise FileNotFoundError(
f"Recipes data file not found: {data_file}. "
"Make sure recetas_es.json is in the same directory as this script."
)
logger.info("Loading recipes data", file=str(data_file))
with open(data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
recipes = data.get("recetas", [])
logger.info(f"Loaded {len(recipes)} recipes from JSON")
return recipes
async def seed_recipes_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
recipes_data: list
) -> dict:
"""
Seed recipes 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)
recipes_data: List of recipe dictionaries with pre-defined IDs
Returns:
Dict with seeding statistics
"""
logger.info("" * 80)
logger.info(f"Seeding recipes for: {tenant_name}")
logger.info(f"Tenant ID: {tenant_id}")
logger.info("" * 80)
created_recipes = 0
skipped_recipes = 0
created_ingredients = 0
created_batches = 0
for recipe_data in recipes_data:
recipe_name = recipe_data["name"]
# Generate tenant-specific UUIDs (same approach as inventory)
base_recipe_id = uuid.UUID(recipe_data["id"])
base_product_id = uuid.UUID(recipe_data["finished_product_id"])
tenant_int = int(tenant_id.hex, 16)
recipe_id = uuid.UUID(int=tenant_int ^ int(base_recipe_id.hex, 16))
finished_product_id = uuid.UUID(int=tenant_int ^ int(base_product_id.hex, 16))
# Check if recipe already exists
result = await db.execute(
select(Recipe).where(
Recipe.tenant_id == tenant_id,
Recipe.id == recipe_id
)
)
existing_recipe = result.scalars().first()
if existing_recipe:
logger.debug(f" ⏭️ Skipping recipe (exists): {recipe_name}")
skipped_recipes += 1
continue
# Create recipe using pre-defined UUID
recipe = Recipe(
id=recipe_id,
tenant_id=tenant_id,
name=recipe_name,
recipe_code=f"REC-{created_recipes + 1:03d}",
version="1.0",
finished_product_id=finished_product_id,
description=recipe_data.get("description"),
category=recipe_data.get("category"),
cuisine_type=recipe_data.get("cuisine_type"),
difficulty_level=recipe_data.get("difficulty_level", 1),
yield_quantity=recipe_data.get("yield_quantity"),
yield_unit=MeasurementUnit(recipe_data.get("yield_unit", "units")),
prep_time_minutes=recipe_data.get("prep_time_minutes"),
cook_time_minutes=recipe_data.get("cook_time_minutes"),
total_time_minutes=recipe_data.get("total_time_minutes"),
rest_time_minutes=recipe_data.get("rest_time_minutes"),
instructions=recipe_data.get("instructions"),
preparation_notes=recipe_data.get("preparation_notes"),
storage_instructions=recipe_data.get("storage_instructions"),
quality_standards=recipe_data.get("quality_standards"),
status=RecipeStatus.ACTIVE,
is_seasonal=recipe_data.get("is_seasonal", False),
is_signature_item=recipe_data.get("is_signature_item", False),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(recipe)
created_recipes += 1
logger.debug(f" ✅ Created recipe: {recipe_name}")
# Create recipe ingredients using tenant-specific ingredient IDs
for ing_data in recipe_data.get("ingredientes", []):
base_ingredient_id = uuid.UUID(ing_data["ingredient_id"])
ingredient_id = uuid.UUID(int=tenant_int ^ int(base_ingredient_id.hex, 16))
# Parse unit
unit_str = ing_data.get("unit", "g")
try:
unit = MeasurementUnit(unit_str)
except ValueError:
logger.warning(f" ⚠️ Invalid unit: {unit_str}, using GRAMS")
unit = MeasurementUnit.GRAMS
recipe_ingredient = RecipeIngredient(
id=uuid.uuid4(),
tenant_id=tenant_id,
recipe_id=recipe_id,
ingredient_id=ingredient_id,
quantity=ing_data["quantity"],
unit=unit,
preparation_method=ing_data.get("preparation_method"),
ingredient_order=ing_data.get("ingredient_order", 1),
ingredient_group=ing_data.get("ingredient_group")
)
db.add(recipe_ingredient)
created_ingredients += 1
# Create some sample production batches (historical data)
num_batches = random.randint(3, 8)
for i in range(num_batches):
# Random date in the past 30 days
days_ago = random.randint(1, 30)
production_date = datetime.now(timezone.utc) - timedelta(days=days_ago)
# Random multiplier and quantity
multiplier = random.choice([0.5, 1.0, 1.5, 2.0])
planned_qty = recipe_data.get("yield_quantity", 10) * multiplier
actual_qty = planned_qty * random.uniform(0.95, 1.05)
batch = ProductionBatch(
id=uuid.uuid4(),
tenant_id=tenant_id,
recipe_id=recipe_id,
batch_number=f"BATCH-{tenant_id.hex[:8].upper()}-{i+1:04d}",
production_date=production_date,
planned_quantity=planned_qty,
actual_quantity=actual_qty,
yield_percentage=(actual_qty / planned_qty * 100) if planned_qty > 0 else 100,
batch_size_multiplier=multiplier,
status=ProductionStatus.COMPLETED,
priority=ProductionPriority.NORMAL,
quality_score=random.uniform(7.5, 9.5),
created_at=production_date,
updated_at=production_date
)
db.add(batch)
created_batches += 1
# Commit all changes for this tenant
await db.commit()
logger.info(f" 📊 Recipes: {created_recipes}, Ingredients: {created_ingredients}, Batches: {created_batches}")
logger.info("")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"recipes_created": created_recipes,
"recipes_skipped": skipped_recipes,
"recipe_ingredients_created": created_ingredients,
"production_batches_created": created_batches,
"total_recipes": len(recipes_data)
}
async def seed_recipes(db: AsyncSession):
"""
Seed recipes for all demo template tenants
Args:
db: Database session
Returns:
Dict with overall seeding statistics
"""
logger.info("=" * 80)
logger.info("📚 Starting Demo Recipes Seeding")
logger.info("=" * 80)
# Load recipes data once
try:
recipes_data = load_recipes_data()
except FileNotFoundError as e:
logger.error(str(e))
raise
results = []
# Seed for San Pablo (Traditional Bakery)
logger.info("")
result_san_pablo = await seed_recipes_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Traditional)",
recipes_data
)
results.append(result_san_pablo)
# Seed for La Espiga (Central Workshop)
result_la_espiga = await seed_recipes_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Workshop)",
recipes_data
)
results.append(result_la_espiga)
# Calculate totals
total_recipes = sum(r["recipes_created"] for r in results)
total_ingredients = sum(r["recipe_ingredients_created"] for r in results)
total_batches = sum(r["production_batches_created"] for r in results)
total_skipped = sum(r["recipes_skipped"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Recipes Seeding Completed")
logger.info("=" * 80)
return {
"service": "recipes",
"tenants_seeded": len(results),
"total_recipes_created": total_recipes,
"total_recipe_ingredients_created": total_ingredients,
"total_production_batches_created": total_batches,
"total_skipped": total_skipped,
"results": results
}
async def main():
"""Main execution function"""
logger.info("Demo Recipes Seeding Script Starting")
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URLs from environment
database_url = os.getenv("RECIPES_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ RECIPES_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 recipes database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
session_maker = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with session_maker() as session:
result = await seed_recipes(session)
logger.info("")
logger.info("📊 Seeding Summary:")
logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}")
logger.info(f" ✅ Recipes created: {result['total_recipes_created']}")
logger.info(f" ✅ Recipe ingredients: {result['total_recipe_ingredients_created']}")
logger.info(f" ✅ Production batches: {result['total_production_batches_created']}")
logger.info(f" ⏭️ 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['recipes_created']} recipes, "
f"{tenant_result['recipe_ingredients_created']} ingredients, "
f"{tenant_result['production_batches_created']} batches"
)
logger.info("")
logger.info("🎉 Success! Recipe catalog is ready for cloning.")
logger.info("")
logger.info("Recipes created:")
logger.info(" • Baguette Francesa Tradicional")
logger.info(" • Croissant de Mantequilla Artesanal")
logger.info(" • Pan de Pueblo con Masa Madre")
logger.info(" • Napolitana de Chocolate")
logger.info("")
logger.info("Note: All IDs are pre-defined and hardcoded for cross-service consistency")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Recipes 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)