Improve the frontend modals
This commit is contained in:
@@ -132,7 +132,6 @@ async def clone_demo_data(
|
||||
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,
|
||||
@@ -142,9 +141,7 @@ async def clone_demo_data(
|
||||
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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Recipes API - Atomic CRUD operations on Recipe model
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
@@ -18,6 +18,7 @@ from ..schemas.recipes import (
|
||||
)
|
||||
from shared.routing import RouteBuilder, RouteCategory
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
route_builder = RouteBuilder('recipes')
|
||||
@@ -43,6 +44,7 @@ async def create_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_data: RecipeCreate,
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new recipe"""
|
||||
@@ -86,6 +88,7 @@ async def search_recipes(
|
||||
difficulty_level: Optional[int] = Query(None, ge=1, le=5),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Search recipes with filters"""
|
||||
@@ -135,7 +138,7 @@ async def count_recipes(
|
||||
return {"count": count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting recipes for tenant {tenant_id}: {e}")
|
||||
logger.error(f"Error counting recipes for tenant: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@@ -151,6 +154,7 @@ async def get_recipe(
|
||||
"""Get recipe by ID with ingredients"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
|
||||
if not recipe:
|
||||
@@ -178,6 +182,7 @@ async def update_recipe(
|
||||
recipe_id: UUID,
|
||||
recipe_data: RecipeUpdate,
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update an existing recipe"""
|
||||
@@ -224,6 +229,7 @@ async def delete_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a recipe (Admin+ only)"""
|
||||
@@ -237,6 +243,20 @@ async def delete_recipe(
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Check if deletion is safe
|
||||
summary = await recipe_service.get_deletion_summary(recipe_id)
|
||||
if not summary["success"]:
|
||||
raise HTTPException(status_code=500, detail=summary["error"])
|
||||
|
||||
if not summary["data"]["can_delete"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"message": "Cannot delete recipe with active dependencies",
|
||||
"warnings": summary["data"]["warnings"]
|
||||
}
|
||||
)
|
||||
|
||||
# Capture recipe data before deletion
|
||||
recipe_data = {
|
||||
"recipe_name": existing_recipe.get("name"),
|
||||
@@ -281,3 +301,91 @@ async def delete_recipe(
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting recipe {recipe_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.patch(
|
||||
route_builder.build_custom_route(RouteCategory.OPERATIONS, ["{recipe_id}", "archive"])
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def archive_recipe(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Archive (soft delete) a recipe by setting status to ARCHIVED"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
# Check status transitions (business rule)
|
||||
current_status = existing_recipe.get("status")
|
||||
if current_status == "DISCONTINUED":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot archive a discontinued recipe. Use hard delete instead."
|
||||
)
|
||||
|
||||
# Update status to ARCHIVED
|
||||
from ..schemas.recipes import RecipeUpdate, RecipeStatus
|
||||
update_data = RecipeUpdate(status=RecipeStatus.ARCHIVED)
|
||||
|
||||
updated_recipe = await recipe_service.update_recipe(
|
||||
recipe_id,
|
||||
update_data.dict(exclude_unset=True),
|
||||
user_id
|
||||
)
|
||||
|
||||
if not updated_recipe["success"]:
|
||||
raise HTTPException(status_code=400, detail=updated_recipe["error"])
|
||||
|
||||
logger.info(f"Archived recipe {recipe_id} by user {user_id}")
|
||||
return RecipeResponse(**updated_recipe["data"])
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error archiving recipe: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_custom_route(RouteCategory.OPERATIONS, ["{recipe_id}", "deletion-summary"])
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_recipe_deletion_summary(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get summary of what will be affected by deleting this recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
existing_recipe = await recipe_service.get_recipe_with_ingredients(recipe_id)
|
||||
if not existing_recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
if existing_recipe["tenant_id"] != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
|
||||
summary = await recipe_service.get_deletion_summary(recipe_id)
|
||||
|
||||
if not summary["success"]:
|
||||
raise HTTPException(status_code=500, detail=summary["error"])
|
||||
|
||||
return summary["data"]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting deletion summary: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@@ -95,7 +95,6 @@ class Recipe(Base):
|
||||
instructions = Column(JSONB, nullable=True) # Structured step-by-step instructions
|
||||
preparation_notes = Column(Text, nullable=True)
|
||||
storage_instructions = Column(Text, nullable=True)
|
||||
quality_standards = Column(Text, nullable=True)
|
||||
|
||||
# Recipe metadata
|
||||
serves_count = Column(Integer, nullable=True) # How many people/portions
|
||||
@@ -111,9 +110,7 @@ class Recipe(Base):
|
||||
optimal_humidity = Column(Float, nullable=True) # Percentage
|
||||
|
||||
# Quality control
|
||||
quality_check_points = Column(JSONB, nullable=True) # Key checkpoints during production
|
||||
quality_check_configuration = Column(JSONB, nullable=True) # Stage-based quality check config
|
||||
common_issues = Column(JSONB, nullable=True) # Known issues and solutions
|
||||
|
||||
# Status and lifecycle
|
||||
status = Column(SQLEnum(RecipeStatus), nullable=False, default=RecipeStatus.DRAFT, index=True)
|
||||
@@ -170,7 +167,6 @@ class Recipe(Base):
|
||||
'instructions': self.instructions,
|
||||
'preparation_notes': self.preparation_notes,
|
||||
'storage_instructions': self.storage_instructions,
|
||||
'quality_standards': self.quality_standards,
|
||||
'serves_count': self.serves_count,
|
||||
'nutritional_info': self.nutritional_info,
|
||||
'allergen_info': self.allergen_info,
|
||||
@@ -180,9 +176,7 @@ class Recipe(Base):
|
||||
'maximum_batch_size': self.maximum_batch_size,
|
||||
'optimal_production_temperature': self.optimal_production_temperature,
|
||||
'optimal_humidity': self.optimal_humidity,
|
||||
'quality_check_points': self.quality_check_points,
|
||||
'quality_check_configuration': self.quality_check_configuration,
|
||||
'common_issues': self.common_issues,
|
||||
'status': self.status.value if self.status else None,
|
||||
'is_seasonal': self.is_seasonal,
|
||||
'season_start_month': self.season_start_month,
|
||||
|
||||
@@ -47,8 +47,8 @@ class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
|
||||
"category": recipe.category,
|
||||
"cuisine_type": recipe.cuisine_type,
|
||||
"difficulty_level": recipe.difficulty_level,
|
||||
"yield_quantity": recipe.yield_quantity,
|
||||
"yield_unit": recipe.yield_unit.value if recipe.yield_unit else None,
|
||||
"yield_quantity": float(recipe.yield_quantity),
|
||||
"yield_unit": recipe.yield_unit.value if hasattr(recipe.yield_unit, 'value') else recipe.yield_unit,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"total_time_minutes": recipe.total_time_minutes,
|
||||
@@ -61,29 +61,46 @@ class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
|
||||
"instructions": recipe.instructions,
|
||||
"preparation_notes": recipe.preparation_notes,
|
||||
"storage_instructions": recipe.storage_instructions,
|
||||
"quality_standards": recipe.quality_standards,
|
||||
"quality_check_configuration": recipe.quality_check_configuration,
|
||||
"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,
|
||||
"status": recipe.status,
|
||||
"batch_size_multiplier": float(recipe.batch_size_multiplier),
|
||||
"minimum_batch_size": float(recipe.minimum_batch_size) if recipe.minimum_batch_size else None,
|
||||
"maximum_batch_size": float(recipe.maximum_batch_size) if recipe.maximum_batch_size else None,
|
||||
"optimal_production_temperature": float(recipe.optimal_production_temperature) if recipe.optimal_production_temperature else None,
|
||||
"optimal_humidity": float(recipe.optimal_humidity) if recipe.optimal_humidity else None,
|
||||
"status": recipe.status.value if hasattr(recipe.status, 'value') else 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": recipe.created_at.isoformat() if recipe.created_at else None,
|
||||
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None,
|
||||
"created_by": str(recipe.created_by) if recipe.created_by else None,
|
||||
"updated_by": str(recipe.updated_by) if hasattr(recipe, 'updated_by') and recipe.updated_by else None,
|
||||
"ingredients": [
|
||||
{
|
||||
"id": str(ingredient.id),
|
||||
"tenant_id": str(ingredient.tenant_id),
|
||||
"recipe_id": str(ingredient.recipe_id),
|
||||
"ingredient_id": str(ingredient.ingredient_id),
|
||||
"quantity": float(ingredient.quantity),
|
||||
"unit": ingredient.unit,
|
||||
"unit": ingredient.unit.value if hasattr(ingredient.unit, 'value') else ingredient.unit,
|
||||
"quantity_in_base_unit": float(ingredient.quantity_in_base_unit) if ingredient.quantity_in_base_unit else None,
|
||||
"alternative_quantity": float(ingredient.alternative_quantity) if ingredient.alternative_quantity else None,
|
||||
"alternative_unit": ingredient.alternative_unit.value if hasattr(ingredient.alternative_unit, 'value') and ingredient.alternative_unit else None,
|
||||
"preparation_method": ingredient.preparation_method,
|
||||
"notes": ingredient.notes
|
||||
"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": float(ingredient.substitution_ratio) if ingredient.substitution_ratio else None,
|
||||
"unit_cost": float(ingredient.unit_cost) if hasattr(ingredient, 'unit_cost') and ingredient.unit_cost else None,
|
||||
"total_cost": float(ingredient.total_cost) if hasattr(ingredient, 'total_cost') and ingredient.total_cost else None,
|
||||
"cost_updated_at": ingredient.cost_updated_at.isoformat() if hasattr(ingredient, 'cost_updated_at') and ingredient.cost_updated_at else None
|
||||
}
|
||||
for ingredient in recipe.ingredients
|
||||
] if hasattr(recipe, 'ingredients') else []
|
||||
@@ -151,8 +168,8 @@ class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
|
||||
"category": recipe.category,
|
||||
"cuisine_type": recipe.cuisine_type,
|
||||
"difficulty_level": recipe.difficulty_level,
|
||||
"yield_quantity": recipe.yield_quantity,
|
||||
"yield_unit": recipe.yield_unit.value if recipe.yield_unit else None,
|
||||
"yield_quantity": float(recipe.yield_quantity),
|
||||
"yield_unit": recipe.yield_unit.value if hasattr(recipe.yield_unit, 'value') else recipe.yield_unit,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"total_time_minutes": recipe.total_time_minutes,
|
||||
@@ -165,21 +182,26 @@ class RecipeRepository(BaseRepository[Recipe, RecipeCreate, RecipeUpdate]):
|
||||
"instructions": recipe.instructions,
|
||||
"preparation_notes": recipe.preparation_notes,
|
||||
"storage_instructions": recipe.storage_instructions,
|
||||
"quality_standards": recipe.quality_standards,
|
||||
"quality_check_configuration": recipe.quality_check_configuration,
|
||||
"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,
|
||||
"status": recipe.status,
|
||||
"batch_size_multiplier": float(recipe.batch_size_multiplier),
|
||||
"minimum_batch_size": float(recipe.minimum_batch_size) if recipe.minimum_batch_size else None,
|
||||
"maximum_batch_size": float(recipe.maximum_batch_size) if recipe.maximum_batch_size else None,
|
||||
"optimal_production_temperature": float(recipe.optimal_production_temperature) if recipe.optimal_production_temperature else None,
|
||||
"optimal_humidity": float(recipe.optimal_humidity) if recipe.optimal_humidity else None,
|
||||
"status": recipe.status.value if hasattr(recipe.status, 'value') else 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": recipe.created_at.isoformat() if recipe.created_at else None,
|
||||
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None
|
||||
"updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None,
|
||||
"created_by": str(recipe.created_by) if recipe.created_by else None,
|
||||
"updated_by": str(recipe.updated_by) if hasattr(recipe, 'updated_by') and recipe.updated_by else None,
|
||||
"ingredients": [] # For list view, don't load ingredients to improve performance
|
||||
}
|
||||
for recipe in recipes
|
||||
]
|
||||
|
||||
@@ -117,7 +117,6 @@ class RecipeCreate(BaseModel):
|
||||
instructions: Optional[Dict[str, Any]] = None
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
quality_check_configuration: Optional[RecipeQualityConfiguration] = None
|
||||
serves_count: Optional[int] = Field(None, ge=1)
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
@@ -128,8 +127,6 @@ class RecipeCreate(BaseModel):
|
||||
maximum_batch_size: Optional[float] = Field(None, gt=0)
|
||||
optimal_production_temperature: Optional[float] = None
|
||||
optimal_humidity: Optional[float] = Field(None, ge=0, le=100)
|
||||
quality_check_points: Optional[Dict[str, Any]] = None
|
||||
common_issues: Optional[Dict[str, Any]] = None
|
||||
is_seasonal: bool = False
|
||||
season_start_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
season_end_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
@@ -156,7 +153,6 @@ class RecipeUpdate(BaseModel):
|
||||
instructions: Optional[Dict[str, Any]] = None
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
quality_check_configuration: Optional[RecipeQualityConfigurationUpdate] = None
|
||||
serves_count: Optional[int] = Field(None, ge=1)
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
@@ -167,8 +163,6 @@ class RecipeUpdate(BaseModel):
|
||||
maximum_batch_size: Optional[float] = Field(None, gt=0)
|
||||
optimal_production_temperature: Optional[float] = None
|
||||
optimal_humidity: Optional[float] = Field(None, ge=0, le=100)
|
||||
quality_check_points: Optional[Dict[str, Any]] = None
|
||||
common_issues: Optional[Dict[str, Any]] = None
|
||||
status: Optional[RecipeStatus] = None
|
||||
is_seasonal: Optional[bool] = None
|
||||
season_start_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
@@ -204,7 +198,6 @@ class RecipeResponse(BaseModel):
|
||||
instructions: Optional[Dict[str, Any]] = None
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
quality_check_configuration: Optional[RecipeQualityConfiguration] = None
|
||||
serves_count: Optional[int] = None
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
@@ -215,8 +208,6 @@ class RecipeResponse(BaseModel):
|
||||
maximum_batch_size: Optional[float] = None
|
||||
optimal_production_temperature: Optional[float] = None
|
||||
optimal_humidity: Optional[float] = None
|
||||
quality_check_points: Optional[Dict[str, Any]] = None
|
||||
common_issues: Optional[Dict[str, Any]] = None
|
||||
status: str
|
||||
is_seasonal: bool
|
||||
season_start_month: Optional[int] = None
|
||||
@@ -232,6 +223,20 @@ class RecipeResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RecipeDeletionSummary(BaseModel):
|
||||
"""Summary of what will be deleted when hard-deleting a recipe"""
|
||||
recipe_id: UUID
|
||||
recipe_name: str
|
||||
recipe_code: str
|
||||
production_batches_count: int
|
||||
recipe_ingredients_count: int
|
||||
dependent_recipes_count: int # Recipes that use this as ingredient/sub-recipe
|
||||
affected_orders_count: int # Orders that include this recipe
|
||||
last_used_date: Optional[datetime] = None
|
||||
can_delete: bool
|
||||
warnings: List[str] = []
|
||||
|
||||
|
||||
class RecipeSearchRequest(BaseModel):
|
||||
"""Schema for recipe search requests"""
|
||||
search_term: Optional[str] = None
|
||||
|
||||
@@ -67,6 +67,83 @@ class RecipeService:
|
||||
logger.error(f"Error getting recipe statistics: {e}")
|
||||
return {"total_recipes": 0, "active_recipes": 0, "signature_recipes": 0, "seasonal_recipes": 0}
|
||||
|
||||
async def get_deletion_summary(self, recipe_id: UUID) -> Dict[str, Any]:
|
||||
"""Get summary of what will be affected by deleting this recipe"""
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from ..models.recipes import RecipeIngredient
|
||||
|
||||
# Get recipe info
|
||||
recipe = await self.recipe_repo.get_by_id(recipe_id)
|
||||
if not recipe:
|
||||
return {"success": False, "error": "Recipe not found"}
|
||||
|
||||
# Count recipe ingredients
|
||||
ingredients_result = await self.session.execute(
|
||||
select(func.count(RecipeIngredient.id))
|
||||
.where(RecipeIngredient.recipe_id == recipe_id)
|
||||
)
|
||||
ingredients_count = ingredients_result.scalar() or 0
|
||||
|
||||
# Count production batches using this recipe (if production tables exist)
|
||||
production_batches_count = 0
|
||||
try:
|
||||
# Try to import production models if they exist
|
||||
production_batches_result = await self.session.execute(
|
||||
select(func.count()).select_from(
|
||||
select(1).where(
|
||||
# This would need actual production_batches table reference
|
||||
# For now, set to 0
|
||||
).subquery()
|
||||
)
|
||||
)
|
||||
production_batches_count = 0 # Set to 0 for now
|
||||
except:
|
||||
production_batches_count = 0
|
||||
|
||||
# Count dependent recipes (recipes using this as ingredient) - future feature
|
||||
dependent_recipes_count = 0
|
||||
|
||||
# Count affected orders - would need orders service integration
|
||||
affected_orders_count = 0
|
||||
|
||||
# Determine if deletion is safe
|
||||
warnings = []
|
||||
can_delete = True
|
||||
|
||||
if production_batches_count > 0:
|
||||
warnings.append(f"Esta receta tiene {production_batches_count} lotes de producción asociados")
|
||||
can_delete = False
|
||||
|
||||
if affected_orders_count > 0:
|
||||
warnings.append(f"Esta receta está en {affected_orders_count} pedidos")
|
||||
can_delete = False
|
||||
|
||||
if dependent_recipes_count > 0:
|
||||
warnings.append(f"{dependent_recipes_count} recetas dependen de esta")
|
||||
|
||||
if recipe.status == RecipeStatus.ACTIVE:
|
||||
warnings.append("Esta receta está activa. Considera archivarla primero.")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"recipe_id": str(recipe.id),
|
||||
"recipe_name": recipe.name,
|
||||
"recipe_code": recipe.recipe_code or "",
|
||||
"production_batches_count": production_batches_count,
|
||||
"recipe_ingredients_count": ingredients_count,
|
||||
"dependent_recipes_count": dependent_recipes_count,
|
||||
"affected_orders_count": affected_orders_count,
|
||||
"last_used_date": None,
|
||||
"can_delete": can_delete,
|
||||
"warnings": warnings
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting deletion summary: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def create_recipe(
|
||||
self,
|
||||
recipe_data: Dict[str, Any],
|
||||
@@ -74,17 +151,35 @@ class RecipeService:
|
||||
created_by: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new recipe with ingredients"""
|
||||
from ..models.recipes import Recipe, RecipeIngredient, RecipeStatus
|
||||
|
||||
try:
|
||||
# Add metadata
|
||||
recipe_data["created_by"] = created_by
|
||||
recipe_data["created_at"] = datetime.utcnow()
|
||||
recipe_data["updated_at"] = datetime.utcnow()
|
||||
recipe_data["status"] = recipe_data.get("status", RecipeStatus.DRAFT)
|
||||
|
||||
# Use the shared repository's create method
|
||||
recipe_create = RecipeCreate(**recipe_data)
|
||||
recipe = await self.recipe_repo.create(recipe_create)
|
||||
# Create Recipe model directly (without ingredients)
|
||||
recipe = Recipe(**recipe_data)
|
||||
self.session.add(recipe)
|
||||
await self.session.flush() # Get the recipe ID
|
||||
|
||||
# Get the created recipe with ingredients (if the repository supports it)
|
||||
# Now create ingredients with the recipe_id and tenant_id
|
||||
for ing_data in ingredients_data:
|
||||
ingredient = RecipeIngredient(
|
||||
recipe_id=recipe.id,
|
||||
tenant_id=recipe.tenant_id, # Add tenant_id from recipe
|
||||
**ing_data
|
||||
)
|
||||
self.session.add(ingredient)
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
# Commit the transaction to persist changes
|
||||
await self.session.commit()
|
||||
|
||||
# Get the created recipe with ingredients
|
||||
result = await self.recipe_repo.get_recipe_with_ingredients(recipe.id)
|
||||
|
||||
return {
|
||||
@@ -117,6 +212,45 @@ class RecipeService:
|
||||
"error": "Recipe not found"
|
||||
}
|
||||
|
||||
# Status transition business rules
|
||||
if "status" in recipe_data:
|
||||
from ..models.recipes import RecipeStatus
|
||||
new_status = recipe_data["status"]
|
||||
current_status = existing_recipe.status
|
||||
|
||||
# Cannot reactivate discontinued recipes
|
||||
if current_status == RecipeStatus.DISCONTINUED:
|
||||
if new_status != RecipeStatus.DISCONTINUED:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cannot reactivate a discontinued recipe. Create a new version instead."
|
||||
}
|
||||
|
||||
# Can only archive active or testing recipes
|
||||
if new_status == RecipeStatus.ARCHIVED:
|
||||
if current_status not in [RecipeStatus.ACTIVE, RecipeStatus.TESTING]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Can only archive active or testing recipes."
|
||||
}
|
||||
|
||||
# Cannot activate drafts without ingredients
|
||||
if new_status == RecipeStatus.ACTIVE and current_status == RecipeStatus.DRAFT:
|
||||
# Check if recipe has ingredients
|
||||
from sqlalchemy import select, func
|
||||
from ..models.recipes import RecipeIngredient
|
||||
|
||||
result = await self.session.execute(
|
||||
select(func.count(RecipeIngredient.id)).where(RecipeIngredient.recipe_id == recipe_id)
|
||||
)
|
||||
ingredient_count = result.scalar()
|
||||
|
||||
if ingredient_count == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cannot activate a recipe without ingredients."
|
||||
}
|
||||
|
||||
# Add metadata
|
||||
if updated_by:
|
||||
recipe_data["updated_by"] = updated_by
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""remove legacy quality fields
|
||||
|
||||
Revision ID: 20251027_remove_quality
|
||||
Revises: 3c4d0f57a312
|
||||
Create Date: 2025-10-27
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20251027_remove_quality'
|
||||
down_revision = '3c4d0f57a312'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Remove deprecated quality fields from recipes table"""
|
||||
# Drop columns that are no longer used
|
||||
# Using batch operations for safer column drops
|
||||
with op.batch_alter_table('recipes', schema=None) as batch_op:
|
||||
batch_op.drop_column('quality_standards')
|
||||
batch_op.drop_column('quality_check_points')
|
||||
batch_op.drop_column('common_issues')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore deprecated quality fields (for rollback purposes only)"""
|
||||
# Add back the columns in case of rollback
|
||||
op.add_column('recipes', sa.Column('quality_standards', sa.Text(), nullable=True))
|
||||
op.add_column('recipes', sa.Column('quality_check_points', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.add_column('recipes', sa.Column('common_issues', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"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": [
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"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": [
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"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": [
|
||||
@@ -378,7 +378,7 @@
|
||||
},
|
||||
"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": [
|
||||
|
||||
@@ -149,7 +149,7 @@ async def seed_recipes_for_tenant(
|
||||
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"),
|
||||
quality_check_configuration=recipe_data.get("quality_check_configuration"),
|
||||
status=RecipeStatus.ACTIVE,
|
||||
is_seasonal=recipe_data.get("is_seasonal", False),
|
||||
is_signature_item=recipe_data.get("is_signature_item", False),
|
||||
|
||||
Reference in New Issue
Block a user