demo seed change
This commit is contained in:
@@ -8,10 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
import structlog
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
@@ -26,7 +28,7 @@ from app.models.recipes import (
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
||||
router = APIRouter()
|
||||
|
||||
# Base demo tenant IDs
|
||||
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
||||
@@ -40,7 +42,7 @@ def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/clone")
|
||||
@router.post("/internal/demo/clone")
|
||||
async def clone_demo_data(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
@@ -53,376 +55,238 @@ async def clone_demo_data(
|
||||
"""
|
||||
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)
|
||||
This endpoint creates fresh demo data by:
|
||||
1. Loading seed data from JSON files
|
||||
2. Applying XOR-based ID transformation
|
||||
3. Adjusting dates relative to session creation time
|
||||
4. Creating records in the virtual tenant
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID to clone from
|
||||
base_tenant_id: Template tenant UUID (for reference)
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
session_created_at: ISO timestamp when demo session was created (for date adjustment)
|
||||
session_created_at: Session creation timestamp for date adjustment
|
||||
|
||||
Returns:
|
||||
Cloning status and record counts
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Parse session_created_at or fallback to now
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning(
|
||||
"Invalid session_created_at format, using current time",
|
||||
session_created_at=session_created_at,
|
||||
error=str(e)
|
||||
)
|
||||
session_time = datetime.now(timezone.utc)
|
||||
else:
|
||||
logger.warning("session_created_at not provided, using current time")
|
||||
session_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,
|
||||
session_time=session_time.isoformat()
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Validate UUIDs
|
||||
base_uuid = uuid.UUID(base_tenant_id)
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
|
||||
# Parse session creation time for date adjustment
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_time = start_time
|
||||
else:
|
||||
session_time = start_time
|
||||
|
||||
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,
|
||||
session_created_at=session_created_at
|
||||
)
|
||||
|
||||
# Load seed data from JSON files
|
||||
try:
|
||||
from shared.utils.seed_data_paths import get_seed_data_path
|
||||
|
||||
if demo_account_type == "professional":
|
||||
json_file = get_seed_data_path("professional", "04-recipes.json")
|
||||
elif demo_account_type == "enterprise":
|
||||
json_file = get_seed_data_path("enterprise", "04-recipes.json")
|
||||
else:
|
||||
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
||||
|
||||
except ImportError:
|
||||
# Fallback to original path
|
||||
seed_data_dir = Path(__file__).parent.parent.parent.parent / "infrastructure" / "seed-data"
|
||||
if demo_account_type == "professional":
|
||||
json_file = seed_data_dir / "professional" / "04-recipes.json"
|
||||
elif demo_account_type == "enterprise":
|
||||
json_file = seed_data_dir / "enterprise" / "parent" / "04-recipes.json"
|
||||
else:
|
||||
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
||||
|
||||
if not json_file.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Seed data file not found: {json_file}"
|
||||
)
|
||||
|
||||
# Load JSON data
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
seed_data = json.load(f)
|
||||
|
||||
# Track cloning statistics
|
||||
stats = {
|
||||
"recipes": 0,
|
||||
"recipe_ingredients": 0,
|
||||
"production_batches": 0,
|
||||
"ingredient_consumptions": 0
|
||||
"recipe_ingredients": 0
|
||||
}
|
||||
|
||||
# Recipe ID mapping (old -> new)
|
||||
recipe_id_map = {}
|
||||
recipe_ingredient_map = {}
|
||||
|
||||
# Clone Recipes
|
||||
logger.info("Starting to clone recipes", base_tenant=str(base_uuid))
|
||||
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
|
||||
|
||||
# Validate required fields before creating new recipe
|
||||
if recipe.finished_product_id is None:
|
||||
logger.warning(
|
||||
"Recipe has null finished_product_id, skipping clone",
|
||||
recipe_id=recipe.id,
|
||||
recipe_name=recipe.name
|
||||
# Create Recipes
|
||||
for recipe_data in seed_data.get('recipes', []):
|
||||
# Transform recipe ID using XOR
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
recipe_uuid = uuid.UUID(recipe_data['id'])
|
||||
transformed_id = transform_id(recipe_data['id'], virtual_uuid)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to parse recipe UUID",
|
||||
recipe_id=recipe_data['id'],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format in recipe data: {str(e)}"
|
||||
)
|
||||
continue # Skip recipes with null required field
|
||||
|
||||
# Generate a unique recipe code to avoid potential duplicates
|
||||
recipe_code = f"REC-{uuid.uuid4().hex[:8].upper()}"
|
||||
# Adjust dates relative to session creation time
|
||||
adjusted_created_at = adjust_date_for_demo(
|
||||
datetime.fromisoformat(recipe_data['created_at'].replace('Z', '+00:00')),
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_updated_at = adjust_date_for_demo(
|
||||
datetime.fromisoformat(recipe_data['updated_at'].replace('Z', '+00:00')),
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
|
||||
# Map field names from seed data to model fields
|
||||
# Handle yield_quantity/yield_unit (may be named finished_product_quantity/unit in seed data)
|
||||
yield_quantity = recipe_data.get('yield_quantity') or recipe_data.get('finished_product_quantity', 1.0)
|
||||
yield_unit_str = recipe_data.get('yield_unit') or recipe_data.get('finished_product_unit', 'UNITS')
|
||||
|
||||
# Convert yield_unit string to enum if needed
|
||||
if isinstance(yield_unit_str, str):
|
||||
try:
|
||||
yield_unit = MeasurementUnit[yield_unit_str.upper()]
|
||||
except KeyError:
|
||||
yield_unit = MeasurementUnit.UNITS
|
||||
else:
|
||||
yield_unit = yield_unit_str
|
||||
|
||||
# Convert status string to enum if needed
|
||||
status = recipe_data.get('status', 'ACTIVE')
|
||||
if isinstance(status, str):
|
||||
try:
|
||||
status = RecipeStatus[status.upper()]
|
||||
except KeyError:
|
||||
status = RecipeStatus.ACTIVE
|
||||
|
||||
new_recipe = Recipe(
|
||||
id=new_recipe_id,
|
||||
id=str(transformed_id),
|
||||
tenant_id=virtual_uuid,
|
||||
name=recipe.name,
|
||||
recipe_code=recipe_code, # 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,
|
||||
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_configuration=recipe.quality_check_configuration,
|
||||
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=session_time,
|
||||
updated_at=session_time,
|
||||
created_by=recipe.created_by,
|
||||
updated_by=recipe.updated_by
|
||||
name=recipe_data['name'],
|
||||
description=recipe_data.get('description'),
|
||||
recipe_code=recipe_data.get('recipe_code'),
|
||||
version=recipe_data.get('version', '1.0'),
|
||||
status=status,
|
||||
finished_product_id=recipe_data['finished_product_id'],
|
||||
yield_quantity=yield_quantity,
|
||||
yield_unit=yield_unit,
|
||||
category=recipe_data.get('category'),
|
||||
difficulty_level=recipe_data.get('difficulty_level', 1),
|
||||
prep_time_minutes=recipe_data.get('prep_time_minutes') or recipe_data.get('preparation_time_minutes'),
|
||||
cook_time_minutes=recipe_data.get('cook_time_minutes') or recipe_data.get('baking_time_minutes'),
|
||||
total_time_minutes=recipe_data.get('total_time_minutes'),
|
||||
rest_time_minutes=recipe_data.get('rest_time_minutes') or recipe_data.get('cooling_time_minutes'),
|
||||
instructions=recipe_data.get('instructions'),
|
||||
preparation_notes=recipe_data.get('notes') or recipe_data.get('preparation_notes'),
|
||||
created_at=adjusted_created_at,
|
||||
updated_at=adjusted_updated_at
|
||||
)
|
||||
# Add to session
|
||||
db.add(new_recipe)
|
||||
stats["recipes"] += 1
|
||||
|
||||
# Flush to get recipe IDs for foreign keys
|
||||
logger.debug("Flushing recipe changes to get IDs")
|
||||
await db.flush()
|
||||
# Map recipe ID for ingredients
|
||||
recipe_id_map = {recipe_data['id']: str(transformed_id)}
|
||||
|
||||
# Clone Recipe Ingredients
|
||||
logger.info("Cloning recipe ingredients", recipe_ingredients_count=len(recipe_id_map))
|
||||
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
|
||||
# Create Recipe Ingredients
|
||||
for recipe_ingredient_data in seed_data.get('recipe_ingredients', []):
|
||||
# Transform ingredient ID using XOR
|
||||
try:
|
||||
ingredient_uuid = uuid.UUID(recipe_ingredient_data['id'])
|
||||
transformed_id = transform_id(ingredient_uuid, virtual_uuid)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to parse recipe ingredient UUID",
|
||||
ingredient_id=recipe_ingredient_data['id'],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format in recipe ingredient data: {str(e)}"
|
||||
)
|
||||
db.add(new_ingredient)
|
||||
stats["recipe_ingredients"] += 1
|
||||
|
||||
# Flush to get recipe ingredient IDs
|
||||
logger.debug("Flushing recipe ingredient changes to get IDs")
|
||||
await db.flush()
|
||||
|
||||
# Clone Production Batches
|
||||
logger.info("Starting to clone production batches", base_tenant=str(base_uuid))
|
||||
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)
|
||||
)
|
||||
|
||||
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 (this might be None if the recipe was skipped due to null finished_product_id)
|
||||
new_recipe_id = recipe_id_map.get(batch.recipe_id)
|
||||
if new_recipe_id is None:
|
||||
logger.warning(
|
||||
"Skipping production batch with no corresponding recipe",
|
||||
batch_id=batch.id,
|
||||
original_recipe_id=batch.recipe_id
|
||||
)
|
||||
# Get the transformed recipe ID
|
||||
recipe_id = recipe_id_map.get(recipe_ingredient_data['recipe_id'])
|
||||
if not recipe_id:
|
||||
logger.error("Recipe not found for ingredient",
|
||||
recipe_id=recipe_ingredient_data['recipe_id'])
|
||||
continue
|
||||
|
||||
# Adjust all date fields using the shared utility
|
||||
adjusted_production_date = adjust_date_for_demo(
|
||||
batch.production_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.production_date else None
|
||||
adjusted_planned_start = adjust_date_for_demo(
|
||||
batch.planned_start_time,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.planned_start_time else None
|
||||
adjusted_actual_start = adjust_date_for_demo(
|
||||
batch.actual_start_time,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.actual_start_time else None
|
||||
adjusted_planned_end = adjust_date_for_demo(
|
||||
batch.planned_end_time,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.planned_end_time else None
|
||||
adjusted_actual_end = adjust_date_for_demo(
|
||||
batch.actual_end_time,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.actual_end_time else None
|
||||
# Convert unit string to enum if needed
|
||||
unit_str = recipe_ingredient_data.get('unit', 'KILOGRAMS')
|
||||
if isinstance(unit_str, str):
|
||||
try:
|
||||
unit = MeasurementUnit[unit_str.upper()]
|
||||
except KeyError:
|
||||
# Try without 'S' for singular forms
|
||||
try:
|
||||
unit = MeasurementUnit[unit_str.upper().rstrip('S')]
|
||||
except KeyError:
|
||||
unit = MeasurementUnit.KILOGRAMS
|
||||
else:
|
||||
unit = unit_str
|
||||
|
||||
new_batch = ProductionBatch(
|
||||
id=new_batch_id,
|
||||
new_recipe_ingredient = RecipeIngredient(
|
||||
id=str(transformed_id),
|
||||
tenant_id=virtual_uuid,
|
||||
recipe_id=new_recipe_id,
|
||||
batch_number=f"BATCH-{uuid.uuid4().hex[:8].upper()}", # New batch number
|
||||
production_date=adjusted_production_date,
|
||||
planned_start_time=adjusted_planned_start,
|
||||
actual_start_time=adjusted_actual_start,
|
||||
planned_end_time=adjusted_planned_end,
|
||||
actual_end_time=adjusted_actual_end,
|
||||
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=session_time,
|
||||
updated_at=session_time,
|
||||
created_by=batch.created_by,
|
||||
completed_by=batch.completed_by
|
||||
recipe_id=recipe_id,
|
||||
ingredient_id=recipe_ingredient_data['ingredient_id'],
|
||||
quantity=recipe_ingredient_data['quantity'],
|
||||
unit=unit,
|
||||
unit_cost=recipe_ingredient_data.get('cost_per_unit') or recipe_ingredient_data.get('unit_cost', 0.0),
|
||||
total_cost=recipe_ingredient_data.get('total_cost'),
|
||||
ingredient_order=recipe_ingredient_data.get('sequence') or recipe_ingredient_data.get('ingredient_order', 1),
|
||||
is_optional=recipe_ingredient_data.get('is_optional', False),
|
||||
ingredient_notes=recipe_ingredient_data.get('notes') or recipe_ingredient_data.get('ingredient_notes')
|
||||
)
|
||||
db.add(new_batch)
|
||||
stats["production_batches"] += 1
|
||||
db.add(new_recipe_ingredient)
|
||||
stats["recipe_ingredients"] += 1
|
||||
|
||||
# Flush to get batch IDs
|
||||
logger.debug("Flushing production batch changes to get IDs")
|
||||
await db.flush()
|
||||
|
||||
# Clone Production Ingredient Consumption
|
||||
logger.info("Cloning production ingredient consumption")
|
||||
for old_batch_id, new_batch_id in batch_id_map.items():
|
||||
# Skip consumption if the batch was skipped (no corresponding recipe)
|
||||
if old_batch_id not in batch_id_map: # This condition was redundant/incorrect
|
||||
continue # This batch was skipped, so skip its consumption too
|
||||
|
||||
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 (skip if original ingredient's recipe was skipped)
|
||||
new_recipe_ingredient_id = recipe_ingredient_map.get(
|
||||
consumption.recipe_ingredient_id
|
||||
)
|
||||
if new_recipe_ingredient_id is None:
|
||||
logger.warning(
|
||||
"Skipping consumption with no corresponding recipe ingredient",
|
||||
consumption_id=consumption.id,
|
||||
original_recipe_ingredient_id=consumption.recipe_ingredient_id
|
||||
)
|
||||
continue
|
||||
|
||||
adjusted_consumption_time = adjust_date_for_demo(
|
||||
consumption.consumption_time,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if consumption.consumption_time else None
|
||||
|
||||
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=adjusted_consumption_time,
|
||||
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
|
||||
logger.debug("Committing all cloned 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",
|
||||
"Recipes data cloned successfully",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
total_records=total_records,
|
||||
stats=stats,
|
||||
records_cloned=stats,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "recipes",
|
||||
"status": "completed",
|
||||
"records_cloned": total_records,
|
||||
"records_cloned": sum(stats.values()),
|
||||
"duration_ms": duration_ms,
|
||||
"details": stats
|
||||
"details": {
|
||||
"recipes": stats["recipes"],
|
||||
"recipe_ingredients": stats["recipe_ingredients"],
|
||||
"virtual_tenant_id": str(virtual_tenant_id)
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e))
|
||||
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
@@ -459,80 +323,68 @@ async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
|
||||
|
||||
|
||||
@router.delete("/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_data(
|
||||
virtual_tenant_id: str,
|
||||
async def delete_demo_tenant_data(
|
||||
virtual_tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Delete all recipe data for a virtual demo tenant
|
||||
|
||||
Called by demo session cleanup service to remove ephemeral data
|
||||
when demo sessions expire or are destroyed.
|
||||
Delete all demo data for a virtual tenant.
|
||||
This endpoint is idempotent - safe to call multiple times.
|
||||
"""
|
||||
logger.info(
|
||||
"Deleting recipe data for virtual tenant",
|
||||
virtual_tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
start_time = datetime.now()
|
||||
|
||||
records_deleted = {
|
||||
"recipes": 0,
|
||||
"recipe_ingredients": 0,
|
||||
"total": 0
|
||||
}
|
||||
|
||||
try:
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
# Delete in reverse dependency order
|
||||
|
||||
# 1. Delete recipe ingredients (depends on recipes)
|
||||
result = await db.execute(
|
||||
delete(RecipeIngredient)
|
||||
.where(RecipeIngredient.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["recipe_ingredients"] = result.rowcount
|
||||
|
||||
# Count records before deletion
|
||||
recipe_count = await db.scalar(
|
||||
select(func.count(Recipe.id)).where(Recipe.tenant_id == virtual_uuid)
|
||||
)
|
||||
ingredient_count = await db.scalar(
|
||||
select(func.count(RecipeIngredient.id)).where(RecipeIngredient.tenant_id == virtual_uuid)
|
||||
# 2. Delete recipes
|
||||
result = await db.execute(
|
||||
delete(Recipe)
|
||||
.where(Recipe.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["recipes"] = result.rowcount
|
||||
|
||||
# Delete in correct order (RecipeIngredient references Recipe)
|
||||
await db.execute(
|
||||
delete(RecipeIngredient).where(RecipeIngredient.tenant_id == virtual_uuid)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Recipe).where(Recipe.tenant_id == virtual_uuid)
|
||||
)
|
||||
records_deleted["total"] = sum(records_deleted.values())
|
||||
|
||||
await db.commit()
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Recipe data deleted successfully",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
recipes_deleted=recipe_count,
|
||||
ingredients_deleted=ingredient_count,
|
||||
duration_ms=duration_ms
|
||||
"demo_data_deleted",
|
||||
service="recipes",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
records_deleted=records_deleted
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "recipes",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": virtual_tenant_id,
|
||||
"records_deleted": {
|
||||
"recipes": recipe_count,
|
||||
"recipe_ingredients": ingredient_count,
|
||||
"total": recipe_count + ingredient_count
|
||||
},
|
||||
"duration_ms": duration_ms
|
||||
"virtual_tenant_id": str(virtual_tenant_id),
|
||||
"records_deleted": records_deleted,
|
||||
"duration_ms": int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
}
|
||||
|
||||
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 delete recipe data",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"demo_data_deletion_failed",
|
||||
service="recipes",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete recipe data: {str(e)}"
|
||||
)
|
||||
detail=f"Failed to delete demo data: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user