Improve the demo feature of the project
This commit is contained in:
@@ -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:-}"
|
||||
|
||||
377
services/recipes/app/api/internal_demo.py
Normal file
377
services/recipes/app/api/internal_demo.py
Normal 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"
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
447
services/recipes/scripts/demo/recetas_es.json
Normal file
447
services/recipes/scripts/demo/recetas_es.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
387
services/recipes/scripts/demo/seed_demo_recipes.py
Executable file
387
services/recipes/scripts/demo/seed_demo_recipes.py
Executable 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)
|
||||
Reference in New Issue
Block a user