""" Recipe API — CRUD, costing, sub-recipe cycle check, scaling, menu sections, cost snapshots, print HTML (full + kitchen card). """ import os import uuid import logging from datetime import date, datetime, timedelta from decimal import Decimal from typing import Optional from html import escape as html_escape from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, text, and_, delete from sqlalchemy.orm import selectinload from pydantic import BaseModel, field_serializer from database import get_db from models.user import User from models.recipe import ( Recipe, MenuSection, RecipeIngredient, RecipeSubRecipe, RecipeStep, RecipeImage, RecipeChangeLog, RecipeCostSnapshot, ) from models.ingredient import Ingredient, IngredientSource from models.food_flag import RecipeFlag, FoodFlagCategory, FoodFlag from models.ingredient import IngredientFlag, IngredientFlagNone from models.settings import KitchenSettings from auth import get_current_user, require_cap, get_current_user_from_token from api.ingredients import convert_to_standard, UNIT_CONVERSIONS from models.menu import Menu, MenuItem logger = logging.getLogger(__name__) router = APIRouter() DATA_DIR = "/app/data" # ── Pydantic schemas ───────────────────────────────────────────────────────── class MenuSectionCreate(BaseModel): name: str sort_order: int = 0 section_type: str = "recipe" # "recipe" | "dish" class MenuSectionUpdate(BaseModel): name: Optional[str] = None sort_order: Optional[int] = None class MenuSectionResponse(BaseModel): id: int name: str sort_order: int section_type: str = "recipe" recipe_count: int = 0 class RecipeCreate(BaseModel): name: str recipe_type: str = "component" # "component" | "dish" menu_section_id: Optional[int] = None description: Optional[str] = None batch_portions: int = 1 batch_output_type: str = "portions" # "portions" | "bulk" batch_yield_qty: Optional[float] = None batch_yield_unit: Optional[str] = None # g, kg, ml, ltr prep_time_minutes: Optional[int] = None cook_time_minutes: Optional[int] = None notes: Optional[str] = None class RecipeUpdate(BaseModel): name: Optional[str] = None recipe_type: Optional[str] = None menu_section_id: Optional[int] = None description: Optional[str] = None batch_portions: Optional[int] = None batch_output_type: Optional[str] = None batch_yield_qty: Optional[float] = None batch_yield_unit: Optional[str] = None prep_time_minutes: Optional[int] = None cook_time_minutes: Optional[int] = None notes: Optional[str] = None is_archived: Optional[bool] = None kds_menu_item_name: Optional[str] = None sambapos_portion_name: Optional[str] = None gross_sell_price: Optional[float] = None class IngredientAdd(BaseModel): ingredient_id: int quantity: float unit: Optional[str] = None # override display unit (e.g. kg when ingredient std is g) yield_percent: float = 100.0 notes: Optional[str] = None sort_order: int = 0 class IngredientUpdateSchema(BaseModel): quantity: Optional[float] = None unit: Optional[str] = None yield_percent: Optional[float] = None notes: Optional[str] = None sort_order: Optional[int] = None class SubRecipeAdd(BaseModel): child_recipe_id: int portions_needed: float portions_needed_unit: Optional[str] = None # override unit (e.g. ml when child yields ltr) notes: Optional[str] = None sort_order: int = 0 class SubRecipeUpdateSchema(BaseModel): portions_needed: Optional[float] = None portions_needed_unit: Optional[str] = None notes: Optional[str] = None sort_order: Optional[int] = None class StepCreate(BaseModel): title: Optional[str] = None instruction: str step_number: int = 0 duration_minutes: Optional[int] = None notes: Optional[str] = None class StepUpdate(BaseModel): title: Optional[str] = None instruction: Optional[str] = None step_number: Optional[int] = None duration_minutes: Optional[int] = None notes: Optional[str] = None class StepReorder(BaseModel): step_ids: list[int] class IngredientReorder(BaseModel): ingredient_ids: list[int] class SubRecipeReorder(BaseModel): sub_recipe_ids: list[int] class RecipeListItem(BaseModel): id: int name: str recipe_type: str menu_section_id: Optional[int] = None menu_section_name: Optional[str] = None batch_portions: int = 1 batch_output_type: str = "portions" batch_yield_qty: Optional[float] = None batch_yield_unit: Optional[str] = None output_unit: str = "portion" cost_per_portion: Optional[float] = None total_cost: Optional[float] = None is_archived: bool = False prep_time_minutes: Optional[int] = None cook_time_minutes: Optional[int] = None flag_summary: list[dict] = [] image_count: int = 0 gross_sell_price: Optional[float] = None kds_menu_item_name: Optional[str] = None sambapos_portion_name: Optional[str] = None created_at: str = "" updated_at: str = "" # ── Menu Section endpoints ─────────────────────────────────────────────────── @router.get("/menu-sections") async def list_menu_sections( section_type: Optional[str] = Query(None), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): count_sub = ( select(Recipe.menu_section_id, func.count(Recipe.id).label("cnt")) .where(Recipe.kitchen_id == user.kitchen_id, Recipe.is_archived == False) .group_by(Recipe.menu_section_id) .subquery() ) query = ( select(MenuSection, func.coalesce(count_sub.c.cnt, 0).label("recipe_count")) .outerjoin(count_sub, MenuSection.id == count_sub.c.menu_section_id) .where(MenuSection.kitchen_id == user.kitchen_id) ) if section_type: query = query.where(MenuSection.section_type == section_type) query = query.order_by(MenuSection.sort_order, MenuSection.name) result = await db.execute(query) return [ MenuSectionResponse(id=s.id, name=s.name, sort_order=s.sort_order, section_type=s.section_type, recipe_count=cnt) for s, cnt in result.all() ] @router.post("/menu-sections") async def create_menu_section( data: MenuSectionCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): sec = MenuSection(kitchen_id=user.kitchen_id, name=data.name, sort_order=data.sort_order, section_type=data.section_type) db.add(sec) await db.commit() await db.refresh(sec) return MenuSectionResponse(id=sec.id, name=sec.name, sort_order=sec.sort_order) @router.patch("/menu-sections/{section_id}") async def update_menu_section( section_id: int, data: MenuSectionUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id) ) sec = result.scalar_one_or_none() if not sec: raise HTTPException(404, "Section not found") if data.name is not None: sec.name = data.name if data.sort_order is not None: sec.sort_order = data.sort_order await db.commit() return {"ok": True} @router.delete("/menu-sections/{section_id}") async def delete_menu_section( section_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id) ) sec = result.scalar_one_or_none() if not sec: raise HTTPException(404, "Section not found") # Null out recipes in this section from sqlalchemy import update await db.execute( update(Recipe).where(Recipe.menu_section_id == section_id).values(menu_section_id=None) ) await db.delete(sec) await db.commit() return {"ok": True} # ── Default recipe sections and dish courses ──────────────────────────────── DEFAULT_RECIPE_SECTIONS = [ ("Meats & Protein", 0), ("Sauces & Jus", 1), ("Starch & Vegetables", 2), ("Sides & Accompaniments", 3), ("Pastry & Dessert", 4), ("Garnish & Toppings", 5), ("Marinades & Glazes", 6), ("Stews & Casseroles", 7), ] DEFAULT_DISH_COURSES = [ ("Starter", 0), ("Main", 1), ("Dessert", 2), ("Side", 3), ("Specials", 4), ] @router.post("/menu-sections/seed-defaults") async def seed_default_menu_sections( section_type: str = Query("recipe"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if section_type not in ("recipe", "dish"): raise HTTPException(400, "section_type must be 'recipe' or 'dish'") defaults = DEFAULT_RECIPE_SECTIONS if section_type == "recipe" else DEFAULT_DISH_COURSES kid = user.kitchen_id created = 0 for name, sort_order in defaults: exists = await db.execute( select(MenuSection).where( MenuSection.kitchen_id == kid, MenuSection.name == name, MenuSection.section_type == section_type, ) ) if exists.scalar_one_or_none(): continue db.add(MenuSection(kitchen_id=kid, name=name, section_type=section_type, sort_order=sort_order)) created += 1 await db.commit() return {"ok": True, "created": created} # ── Dashboard Stats ────────────────────────────────────────────────────────── @router.get("/dashboard-stats") async def recipe_dashboard_stats( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Lightweight stats for the main dashboard widget.""" kid = user.kitchen_id total = (await db.execute( select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) )).scalar() or 0 dishes = (await db.execute( select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.recipe_type == "dish", Recipe.is_archived == False) )).scalar() or 0 component = total - dishes # Ingredients with no sources unmapped = (await db.execute( select(func.count()).select_from(Ingredient).where( Ingredient.kitchen_id == kid, Ingredient.is_archived == False, ~Ingredient.id.in_( select(IngredientSource.ingredient_id).where(IngredientSource.kitchen_id == kid) ), ) )).scalar() or 0 # Recipes without any cost snapshots without_costing = (await db.execute( select(func.count()).select_from(Recipe).where( Recipe.kitchen_id == kid, Recipe.is_archived == False, ~Recipe.id.in_( select(RecipeCostSnapshot.recipe_id).distinct() ), ) )).scalar() or 0 # Dishes missing allergen assessment # Find required flag categories for this kitchen req_cats = (await db.execute( select(FoodFlagCategory.id).where( FoodFlagCategory.kitchen_id == kid, FoodFlagCategory.required == True, ) )).scalars().all() dishes_missing_allergens = 0 dishes_missing_list: list[dict] = [] if req_cats: # Get all dish recipes (non-archived) dish_rows = (await db.execute( select(Recipe.id, Recipe.name).where( Recipe.kitchen_id == kid, Recipe.recipe_type == "dish", Recipe.is_archived == False, ) )).all() for dish_id, dish_name in dish_rows: # Get all ingredient IDs used by this dish (direct + via sub-recipes) direct_ings = (await db.execute( select(RecipeIngredient.ingredient_id).where( RecipeIngredient.recipe_id == dish_id ) )).scalars().all() sub_recipe_ids = (await db.execute( select(RecipeSubRecipe.child_recipe_id).where( RecipeSubRecipe.parent_recipe_id == dish_id ) )).scalars().all() sub_ings: list[int] = [] for sr_id in sub_recipe_ids: sr_ings = (await db.execute( select(RecipeIngredient.ingredient_id).where( RecipeIngredient.recipe_id == sr_id ) )).scalars().all() sub_ings.extend(sr_ings) all_ing_ids = set(direct_ings) | set(sub_ings) if not all_ing_ids: continue # Check each ingredient against required categories has_unassessed = False for ing_id in all_ing_ids: for cat_id in req_cats: # Check if ingredient has any flag in this category has_flag = (await db.execute( select(IngredientFlag.id).where( IngredientFlag.ingredient_id == ing_id, IngredientFlag.food_flag_id.in_( select(FoodFlag.id).where(FoodFlag.category_id == cat_id) ), ).limit(1) )).scalar_one_or_none() if not has_flag: # Check if ingredient has "none" for this category has_none = (await db.execute( select(IngredientFlagNone.id).where( IngredientFlagNone.ingredient_id == ing_id, IngredientFlagNone.category_id == cat_id, ).limit(1) )).scalar_one_or_none() if not has_none: has_unassessed = True break if has_unassessed: break if has_unassessed: dishes_missing_allergens += 1 dishes_missing_list.append({"id": dish_id, "name": dish_name}) # Recipes affected by ingredient price changes in last 14 days cutoff = datetime.utcnow() - timedelta(days=14) price_change_logs = (await db.execute( select(RecipeChangeLog.recipe_id).where( RecipeChangeLog.created_at >= cutoff, RecipeChangeLog.user_id == None, (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), RecipeChangeLog.recipe_id.in_( select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) ), ).distinct() )).scalars().all() # Menu items needing republishing from api.menus import _compute_staleness menu_result = await db.execute( select(Menu).options(selectinload(Menu.items)).where( Menu.kitchen_id == kid, Menu.is_active == True ) ) active_menus = menu_result.scalars().all() stale_menu_items = 0 stale_menu_names: list[str] = [] for menu in active_menus: if menu.items: staleness = await _compute_staleness(list(menu.items), db) menu_stale = sum(1 for s in staleness.values() if s.get("is_stale")) if menu_stale > 0: stale_menu_items += menu_stale stale_menu_names.append(menu.name) return { "total_recipes": total, "dish_count": dishes, "component_recipes": component, "unmapped_ingredients": unmapped, "recipes_without_costing": without_costing, "dishes_missing_allergens": dishes_missing_allergens, "dishes_missing_allergens_list": dishes_missing_list, "recipes_with_price_changes": len(price_change_logs), "stale_menu_items": stale_menu_items, "stale_menu_names": stale_menu_names, } @router.get("/price-impact") async def price_impact_report( days: int = Query(14, ge=1, le=365), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Recipes affected by ingredient price changes within the given period.""" kid = user.kitchen_id cutoff = datetime.utcnow() - timedelta(days=days) # Find price-change log entries within the period log_result = await db.execute( select(RecipeChangeLog).where( RecipeChangeLog.created_at >= cutoff, RecipeChangeLog.user_id == None, (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), RecipeChangeLog.recipe_id.in_( select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) ), ).order_by(RecipeChangeLog.created_at.desc()) ) logs = log_result.scalars().all() if not logs: return {"days": days, "recipes": []} import re price_change_re = re.compile( r"^(.+?) price changed: £([\d.]+)(/(\w+))? → £([\d.]+)(/(\w+))?$" ) price_set_re = re.compile( r"^(.+?) price set: £([\d.]+)(/(\w+))?$" ) # Batch-load invoice numbers for logs that have source_invoice_id invoice_ids = {log.source_invoice_id for log in logs if log.source_invoice_id} invoice_map: dict[int, str] = {} if invoice_ids: from models.invoice import Invoice inv_result = await db.execute( select(Invoice.id, Invoice.invoice_number).where(Invoice.id.in_(invoice_ids)) ) invoice_map = {row[0]: row[1] for row in inv_result.fetchall()} # Group log entries by recipe, parsing structured data from summary recipe_changes: dict[int, list[dict]] = {} for log in logs: entry: dict = { "summary": log.change_summary, "date": str(log.created_at.date()) if log.created_at else None, "ingredient_name": None, "old_price": None, "new_price": None, "unit": None, "source_invoice_id": log.source_invoice_id, "source_invoice_number": invoice_map.get(log.source_invoice_id) if log.source_invoice_id else None, } m = price_change_re.match(log.change_summary) if m: entry["ingredient_name"] = m.group(1) entry["old_price"] = float(m.group(2)) entry["new_price"] = float(m.group(5)) entry["unit"] = m.group(4) or m.group(7) else: m2 = price_set_re.match(log.change_summary) if m2: entry["ingredient_name"] = m2.group(1) entry["new_price"] = float(m2.group(2)) entry["unit"] = m2.group(4) recipe_changes.setdefault(log.recipe_id, []).append(entry) # Get recipe info + cost snapshots for affected recipes recipe_ids = list(recipe_changes.keys()) recipe_result = await db.execute( select(Recipe) .options( selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient), selectinload(Recipe.sub_recipes) .selectinload(RecipeSubRecipe.child_recipe) .options( selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient), selectinload(Recipe.sub_recipes) .selectinload(RecipeSubRecipe.child_recipe) .selectinload(Recipe.ingredients) .selectinload(RecipeIngredient.ingredient), ), ) .where(Recipe.id.in_(recipe_ids)) ) recipes = {r.id: r for r in recipe_result.scalars().all()} def _build_ing_usage(recipe, scale: float = 1.0, depth: int = 0) -> dict[str, float]: """Recursively build ingredient name -> qty_in_std_unit map, expanding sub-recipes.""" usage: dict[str, float] = {} for ri in recipe.ingredients: if ri.ingredient and ri.quantity: display_unit = ri.unit or ri.ingredient.standard_unit qty_std = _convert_unit(float(ri.quantity), display_unit, ri.ingredient.standard_unit) yld = float(ri.yield_percent) if ri.yield_percent else 100.0 qty_effective = (qty_std / (yld / 100) if yld > 0 else qty_std) * scale key = ri.ingredient.name.lower() usage[key] = usage.get(key, 0) + qty_effective if depth < 3: try: for sr in recipe.sub_recipes: child = sr.child_recipe if not child: continue child_output = _get_output_qty(child) if not child_output: continue needed = float(sr.portions_needed) if sr.portions_needed else 0 if not needed: continue # Convert portions_needed unit to child output unit if needed (bulk sub-recipes) child_output_unit = (_get_output_unit(child)).lower().strip() needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip() if needed_unit != child_output_unit and child.batch_output_type == "bulk": converted = _convert_unit(needed, needed_unit, child_output_unit) if converted is not None: needed = converted sub_scale = scale * needed / child_output child_usage = _build_ing_usage(child, sub_scale, depth + 1) for k, v in child_usage.items(): usage[k] = usage.get(k, 0) + v except Exception: pass # lazy load guard return usage items = [] for rid in recipe_ids: r = recipes.get(rid) if not r: continue output_qty = _get_output_qty(r) # Build ingredient name -> usage map for cost impact calculation (including sub-recipes) ing_usage = _build_ing_usage(r) # Calculate per-change cost impact for change in recipe_changes[rid]: impact = None if change["old_price"] is not None and change["new_price"] is not None and change["ingredient_name"]: qty = ing_usage.get(change["ingredient_name"].lower()) if qty is not None: price_diff = change["new_price"] - change["old_price"] # Impact per output unit (portion/kg/etc) impact = round(price_diff * qty / output_qty, 4) if output_qty else None change["cost_impact"] = impact # Latest snapshot (current cost) latest_snap = (await db.execute( select(RecipeCostSnapshot).where( RecipeCostSnapshot.recipe_id == rid, ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1) )).scalar_one_or_none() # Snapshot just before the period (previous cost) prev_snap = (await db.execute( select(RecipeCostSnapshot).where( RecipeCostSnapshot.recipe_id == rid, RecipeCostSnapshot.snapshot_date < cutoff.date(), ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1) )).scalar_one_or_none() current_cost = float(latest_snap.cost_per_portion) if latest_snap else None previous_cost = float(prev_snap.cost_per_portion) if prev_snap else None cost_change = None cost_change_pct = None if current_cost is not None and previous_cost is not None and previous_cost > 0: cost_change = round(current_cost - previous_cost, 4) cost_change_pct = round((cost_change / previous_cost) * 100, 1) items.append({ "recipe_id": rid, "recipe_name": r.name, "recipe_type": r.recipe_type, "output_unit": _get_output_unit(r), "current_cost_per_unit": current_cost, "previous_cost_per_unit": previous_cost, "cost_change": cost_change, "cost_change_pct": cost_change_pct, "ingredient_changes": recipe_changes[rid], }) # Sort by absolute cost change descending (biggest movers first) items.sort(key=lambda x: abs(x["cost_change"] or 0), reverse=True) return {"days": days, "recipes": items} # ── Recipe List & CRUD ─────────────────────────────────────────────────────── @router.get("") async def list_recipes( recipe_type: Optional[str] = Query(None), menu_section_id: Optional[int] = Query(None), search: Optional[str] = Query(None), archived: bool = Query(False), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): query = ( select(Recipe) .options(selectinload(Recipe.menu_section), selectinload(Recipe.images)) .where(Recipe.kitchen_id == user.kitchen_id) ) if not archived: query = query.where(Recipe.is_archived == False) if recipe_type: query = query.where(Recipe.recipe_type == recipe_type) if menu_section_id: query = query.where(Recipe.menu_section_id == menu_section_id) if search: query = query.where(Recipe.name.ilike(f"%{search}%")) result = await db.execute(query.order_by(Recipe.name)) recipes = result.scalars().all() items = [] for r in recipes: # Get latest cost snapshot snap_result = await db.execute( select(RecipeCostSnapshot) .where(RecipeCostSnapshot.recipe_id == r.id) .order_by(RecipeCostSnapshot.snapshot_date.desc()) .limit(1) ) snap = snap_result.scalar_one_or_none() # Get flag summary (lightweight) from api.food_flags import compute_recipe_flags flags = await compute_recipe_flags(r.id, user.kitchen_id, db) flag_summary = [ {"name": f.flag_name, "code": f.flag_code, "icon": f.flag_icon, "category": f.category_name, "propagation": f.propagation_type, "active": f.is_active, "excludable": f.excludable_on_request} for f in flags if f.is_active ] items.append(RecipeListItem( id=r.id, name=r.name, recipe_type=r.recipe_type, menu_section_id=r.menu_section_id, menu_section_name=r.menu_section.name if r.menu_section else None, batch_portions=r.batch_portions, batch_output_type=r.batch_output_type or "portions", batch_yield_qty=float(r.batch_yield_qty) if r.batch_yield_qty else None, batch_yield_unit=r.batch_yield_unit, output_unit=_get_output_unit(r), cost_per_portion=float(snap.cost_per_portion) if snap else None, total_cost=float(snap.total_cost) if snap else None, is_archived=r.is_archived, prep_time_minutes=r.prep_time_minutes, cook_time_minutes=r.cook_time_minutes, flag_summary=flag_summary, image_count=len(r.images) if r.images else 0, gross_sell_price=float(r.gross_sell_price) if r.gross_sell_price else None, kds_menu_item_name=r.kds_menu_item_name, sambapos_portion_name=r.sambapos_portion_name, created_at=str(r.created_at) if r.created_at else "", updated_at=str(r.updated_at) if r.updated_at else "", )) return items @router.post("") async def create_recipe( data: RecipeCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if data.recipe_type not in ("component", "dish"): raise HTTPException(400, "recipe_type must be 'component' or 'dish'") # Validate bulk output if data.batch_output_type == "bulk": if not data.batch_yield_qty or data.batch_yield_qty <= 0: raise HTTPException(400, "Bulk recipes require a positive batch_yield_qty") if data.batch_yield_unit not in ("g", "kg", "ml", "ltr"): raise HTTPException(400, "batch_yield_unit must be g, kg, ml, or ltr") recipe = Recipe( kitchen_id=user.kitchen_id, name=data.name.strip(), recipe_type=data.recipe_type, menu_section_id=data.menu_section_id, description=data.description, batch_portions=1 if data.batch_output_type == "bulk" else (data.batch_portions if data.recipe_type == "component" else 1), batch_output_type=data.batch_output_type if data.recipe_type == "component" else "portions", batch_yield_qty=Decimal(str(data.batch_yield_qty)) if data.batch_output_type == "bulk" and data.batch_yield_qty else None, batch_yield_unit=data.batch_yield_unit if data.batch_output_type == "bulk" else None, prep_time_minutes=data.prep_time_minutes, cook_time_minutes=data.cook_time_minutes, notes=data.notes, created_by=user.id, ) db.add(recipe) await db.commit() await db.refresh(recipe) return {"id": recipe.id, "name": recipe.name} @router.get("/{recipe_id}") async def get_recipe( recipe_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Recipe) .options( selectinload(Recipe.menu_section), selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources), selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe), selectinload(Recipe.steps), selectinload(Recipe.images), ) .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) ) recipe = result.scalar_one_or_none() if not recipe: raise HTTPException(404, "Recipe not found") # Build detailed response ingredients = [] for ri in sorted(recipe.ingredients, key=lambda x: x.sort_order): ing = ri.ingredient if not ing: continue display_unit = ri.unit or ing.standard_unit # Get effective price (yield from recipe ingredient, not raw ingredient) yld = float(ri.yield_percent) if ri.yield_percent else 100.0 eff_price = None if ing.sources: priced = [s for s in ing.sources if s.price_per_std_unit] if priced: latest = max(priced, key=lambda s: s.latest_invoice_date or date.min) raw = float(latest.price_per_std_unit) eff_price = raw / (yld / 100) if yld > 0 else raw is_manual_price = False if eff_price is None and ing.manual_price: eff_price = float(ing.manual_price) / (yld / 100) if yld > 0 else float(ing.manual_price) is_manual_price = True # Convert quantity to standard unit for cost calculation qty_in_std = _convert_unit(float(ri.quantity), display_unit, ing.standard_unit) cost = qty_in_std * eff_price if eff_price and ri.quantity else None ingredients.append({ "id": ri.id, "ingredient_id": ing.id, "ingredient_name": ing.name, "quantity": float(ri.quantity), "unit": display_unit, "standard_unit": ing.standard_unit, "compatible_units": _get_compatible_units(ing.standard_unit), "yield_percent": yld, "effective_price": round(eff_price, 6) if eff_price else None, "cost": round(cost, 4) if cost else None, "is_manual_price": is_manual_price if not (hasattr(ing, 'is_free') and ing.is_free) else False, "has_no_price": (eff_price is None) if not (hasattr(ing, 'is_free') and ing.is_free) else False, "notes": ri.notes, "sort_order": ri.sort_order, }) sub_recipes = [] for sr in sorted(recipe.sub_recipes, key=lambda x: x.sort_order): child = sr.child_recipe if not child: continue # Calculate child cost from scratch (not from snapshots, which may be stale/missing) child_cost_data = await _calc_recipe_cost(child.id, db) child_total = child_cost_data.get("total_cost_recent", 0) or 0 child_output_qty = _get_output_qty(child) child_cost_per_portion = child_total / child_output_qty if child_total and child_output_qty else None child_output_unit = _get_output_unit(child) needed_unit = sr.portions_needed_unit or child_output_unit # Convert to child output unit for costing needed_in_output_unit = _convert_unit(float(sr.portions_needed), needed_unit, child_output_unit) cost_contribution = None if child_cost_per_portion and sr.portions_needed: cost_contribution = needed_in_output_unit * child_cost_per_portion # Check if child recipe has any manual-priced or no-price ingredients child_has_manual = any(ci.get("is_manual_price") for ci in child_cost_data.get("ingredients", [])) child_has_no_price = any(ci.get("has_no_price") for ci in child_cost_data.get("ingredients", [])) # Also check nested sub-recipe child ingredients recursively def _check_subs(subs): m, n = False, False for s in (subs or []): for ci in s.get("child_ingredients", []): if ci.get("is_manual_price"): m = True if ci.get("has_no_price"): n = True sm, sn = _check_subs(s.get("child_sub_recipes", [])) m = m or sm n = n or sn return m, n sub_m, sub_n = _check_subs(child_cost_data.get("sub_recipes", [])) child_has_manual = child_has_manual or sub_m child_has_no_price = child_has_no_price or sub_n sub_recipes.append({ "id": sr.id, "child_recipe_id": child.id, "child_recipe_name": child.name, "child_recipe_type": child.recipe_type, "batch_portions": child.batch_portions, "batch_output_type": child.batch_output_type or "portions", "batch_yield_qty": float(child.batch_yield_qty) if child.batch_yield_qty else None, "batch_yield_unit": child.batch_yield_unit, "output_qty": _get_output_qty(child), "output_unit": child_output_unit, "portions_needed": float(sr.portions_needed), "portions_needed_unit": needed_unit, "compatible_units": _get_compatible_units(child_output_unit), "cost_per_portion": child_cost_per_portion, "cost_contribution": round(cost_contribution, 4) if cost_contribution else None, "has_manual_price_ingredients": child_has_manual, "has_no_price_ingredients": child_has_no_price, "notes": sr.notes, "sort_order": sr.sort_order, }) steps = [ { "id": s.id, "step_number": s.step_number, "title": s.title, "instruction": s.instruction, "image_path": s.image_path, "duration_minutes": s.duration_minutes, "notes": s.notes, } for s in sorted(recipe.steps, key=lambda x: x.step_number) ] images = [ { "id": img.id, "image_path": img.image_path, "caption": img.caption, "image_type": img.image_type, "sort_order": img.sort_order, } for img in sorted(recipe.images, key=lambda x: x.sort_order) ] return { "id": recipe.id, "name": recipe.name, "recipe_type": recipe.recipe_type, "menu_section_id": recipe.menu_section_id, "menu_section_name": recipe.menu_section.name if recipe.menu_section else None, "description": recipe.description, "batch_portions": recipe.batch_portions, "batch_output_type": recipe.batch_output_type or "portions", "batch_yield_qty": float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None, "batch_yield_unit": recipe.batch_yield_unit, "output_qty": _get_output_qty(recipe), "output_unit": _get_output_unit(recipe), "prep_time_minutes": recipe.prep_time_minutes, "cook_time_minutes": recipe.cook_time_minutes, "notes": recipe.notes, "is_archived": recipe.is_archived, "kds_menu_item_name": recipe.kds_menu_item_name, "sambapos_portion_name": recipe.sambapos_portion_name, "gross_sell_price": float(recipe.gross_sell_price) if recipe.gross_sell_price else None, "ingredients": ingredients, "sub_recipes": sub_recipes, "steps": steps, "images": images, "created_at": str(recipe.created_at) if recipe.created_at else "", "updated_at": str(recipe.updated_at) if recipe.updated_at else "", } @router.patch("/{recipe_id}") async def update_recipe( recipe_id: int, data: RecipeUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) ) recipe = result.scalar_one_or_none() if not recipe: raise HTTPException(404, "Recipe not found") changes = [] if data.name is not None and data.name != recipe.name: changes.append(f"Name changed from '{recipe.name}' to '{data.name}'") recipe.name = data.name.strip() if data.recipe_type is not None and data.recipe_type != recipe.recipe_type: changes.append(f"Type changed from '{recipe.recipe_type}' to '{data.recipe_type}'") recipe.recipe_type = data.recipe_type if data.menu_section_id is not None: recipe.menu_section_id = data.menu_section_id if data.description is not None: recipe.description = data.description if data.batch_portions is not None and data.batch_portions != recipe.batch_portions: changes.append(f"Batch portions changed from {recipe.batch_portions} to {data.batch_portions}") recipe.batch_portions = data.batch_portions if data.batch_output_type is not None and data.batch_output_type != recipe.batch_output_type: changes.append(f"Output type changed from '{recipe.batch_output_type}' to '{data.batch_output_type}'") recipe.batch_output_type = data.batch_output_type if data.batch_output_type == "bulk": recipe.batch_portions = 1 elif data.batch_output_type == "portions": recipe.batch_yield_qty = None recipe.batch_yield_unit = None if data.batch_yield_qty is not None: old_val = float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None if old_val != data.batch_yield_qty: changes.append(f"Yield qty changed from {old_val} to {data.batch_yield_qty}") recipe.batch_yield_qty = Decimal(str(data.batch_yield_qty)) if data.batch_yield_qty > 0 else None if data.batch_yield_unit is not None and data.batch_yield_unit != recipe.batch_yield_unit: changes.append(f"Yield unit changed from '{recipe.batch_yield_unit}' to '{data.batch_yield_unit}'") recipe.batch_yield_unit = data.batch_yield_unit if data.prep_time_minutes is not None: recipe.prep_time_minutes = data.prep_time_minutes if data.cook_time_minutes is not None: recipe.cook_time_minutes = data.cook_time_minutes if data.notes is not None: recipe.notes = data.notes if data.is_archived is not None: recipe.is_archived = data.is_archived if data.kds_menu_item_name is not None: recipe.kds_menu_item_name = data.kds_menu_item_name if data.sambapos_portion_name is not None: recipe.sambapos_portion_name = data.sambapos_portion_name if data.sambapos_portion_name else None if data.gross_sell_price is not None: recipe.gross_sell_price = data.gross_sell_price if data.gross_sell_price > 0 else None if changes: db.add(RecipeChangeLog( recipe_id=recipe_id, change_summary="; ".join(changes), user_id=user.id, )) await db.commit() if changes: await _snapshot_recipe_and_parents(recipe_id, db, f"recipe_updated: {'; '.join(changes)}") return {"ok": True} @router.delete("/{recipe_id}") async def archive_recipe( recipe_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) ) recipe = result.scalar_one_or_none() if not recipe: raise HTTPException(404, "Recipe not found") recipe.is_archived = True await db.commit() return {"ok": True} @router.post("/{recipe_id}/duplicate") async def duplicate_recipe( recipe_id: int, new_name: Optional[str] = Query(None), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Clone recipe with deep copy of ingredients, steps, images, flags. Sub-recipes are linked.""" result = await db.execute( select(Recipe) .options( selectinload(Recipe.ingredients), selectinload(Recipe.sub_recipes), selectinload(Recipe.steps), selectinload(Recipe.images), ) .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) ) original = result.scalar_one_or_none() if not original: raise HTTPException(404, "Recipe not found") name = new_name or f"{original.name} (Copy)" clone = Recipe( kitchen_id=user.kitchen_id, name=name, recipe_type=original.recipe_type, menu_section_id=original.menu_section_id, description=original.description, batch_portions=original.batch_portions, batch_output_type=original.batch_output_type, batch_yield_qty=original.batch_yield_qty, batch_yield_unit=original.batch_yield_unit, prep_time_minutes=original.prep_time_minutes, cook_time_minutes=original.cook_time_minutes, notes=original.notes, created_by=user.id, ) db.add(clone) await db.flush() # Copy ingredients for ri in original.ingredients: db.add(RecipeIngredient( recipe_id=clone.id, ingredient_id=ri.ingredient_id, quantity=ri.quantity, unit=ri.unit, notes=ri.notes, sort_order=ri.sort_order, )) # Link sub-recipes (not deep copy) for sr in original.sub_recipes: db.add(RecipeSubRecipe( parent_recipe_id=clone.id, child_recipe_id=sr.child_recipe_id, portions_needed=sr.portions_needed, portions_needed_unit=sr.portions_needed_unit, notes=sr.notes, sort_order=sr.sort_order, )) # Copy steps for step in original.steps: db.add(RecipeStep( recipe_id=clone.id, step_number=step.step_number, title=step.title, instruction=step.instruction, duration_minutes=step.duration_minutes, notes=step.notes, )) # Copy images (copy file references, images are shared) for img in original.images: db.add(RecipeImage( recipe_id=clone.id, image_path=img.image_path, caption=img.caption, image_type=img.image_type, sort_order=img.sort_order, uploaded_by=user.id, )) db.add(RecipeChangeLog( recipe_id=clone.id, change_summary=f"Duplicated from '{original.name}' (ID: {original.id})", user_id=user.id, )) await db.commit() return {"id": clone.id, "name": clone.name} # ── Recipe Ingredients ─────────────────────────────────────────────────────── @router.post("/{recipe_id}/ingredients") async def add_recipe_ingredient( recipe_id: int, data: IngredientAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): recipe = await _get_recipe(recipe_id, user.kitchen_id, db) ing = await db.execute( select(Ingredient).where(Ingredient.id == data.ingredient_id, Ingredient.kitchen_id == user.kitchen_id) ) ingredient = ing.scalar_one_or_none() if not ingredient: raise HTTPException(404, "Ingredient not found") # Store unit override if different from standard_unit unit_override = None display_unit = ingredient.standard_unit if data.unit and data.unit != ingredient.standard_unit: # Validate it's a compatible unit if data.unit not in _get_compatible_units(ingredient.standard_unit): raise HTTPException(400, f"Unit '{data.unit}' is not compatible with ingredient's standard unit '{ingredient.standard_unit}'") unit_override = data.unit display_unit = data.unit ri = RecipeIngredient( recipe_id=recipe_id, ingredient_id=data.ingredient_id, quantity=Decimal(str(data.quantity)), unit=unit_override, yield_percent=Decimal(str(data.yield_percent)), notes=data.notes, sort_order=data.sort_order, ) db.add(ri) db.add(RecipeChangeLog( recipe_id=recipe_id, change_summary=f"Added {ingredient.name} ({data.quantity}{display_unit})", user_id=user.id, )) await db.commit() await db.refresh(ri) await _snapshot_recipe_and_parents(recipe_id, db, f"ingredient_added: {ingredient.name}") return {"id": ri.id} @router.patch("/recipe-ingredients/{ri_id}") async def update_recipe_ingredient( ri_id: int, data: IngredientUpdateSchema, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): ri_result = await db.execute( select(RecipeIngredient) .options(selectinload(RecipeIngredient.ingredient)) .where(RecipeIngredient.id == ri_id) ) ri = ri_result.scalar_one_or_none() if not ri: raise HTTPException(404, "Recipe ingredient not found") changes = [] if data.unit is not None: new_unit = data.unit if data.unit != ri.ingredient.standard_unit else None if new_unit != ri.unit: old_display = ri.unit or ri.ingredient.standard_unit new_display = data.unit or ri.ingredient.standard_unit if old_display != new_display: changes.append(f"{ri.ingredient.name} unit changed from {old_display} to {new_display}") ri.unit = new_unit if data.quantity is not None and float(ri.quantity) != data.quantity: old_qty = float(ri.quantity) display_unit = ri.unit or ri.ingredient.standard_unit changes.append(f"{ri.ingredient.name} quantity changed from {old_qty} to {data.quantity}{display_unit}") ri.quantity = Decimal(str(data.quantity)) if data.yield_percent is not None and float(ri.yield_percent) != data.yield_percent: old_yld = float(ri.yield_percent) changes.append(f"{ri.ingredient.name} yield changed from {old_yld}% to {data.yield_percent}%") ri.yield_percent = Decimal(str(data.yield_percent)) if data.notes is not None: ri.notes = data.notes if data.sort_order is not None: ri.sort_order = data.sort_order if changes: db.add(RecipeChangeLog( recipe_id=ri.recipe_id, change_summary="; ".join(changes), user_id=user.id, )) await db.commit() if changes: await _snapshot_recipe_and_parents(ri.recipe_id, db, f"ingredient_updated: {'; '.join(changes)}") return {"ok": True} @router.delete("/recipe-ingredients/{ri_id}") async def remove_recipe_ingredient( ri_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): ri_result = await db.execute( select(RecipeIngredient) .options(selectinload(RecipeIngredient.ingredient)) .where(RecipeIngredient.id == ri_id) ) ri = ri_result.scalar_one_or_none() if not ri: raise HTTPException(404, "Recipe ingredient not found") recipe_id_for_snap = ri.recipe_id ing_name = ri.ingredient.name if ri.ingredient else 'unknown' db.add(RecipeChangeLog( recipe_id=recipe_id_for_snap, change_summary=f"Removed {ing_name}", user_id=user.id, )) await db.delete(ri) await db.commit() await _snapshot_recipe_and_parents(recipe_id_for_snap, db, f"ingredient_removed: {ing_name}") return {"ok": True} # ── Sub-recipes ────────────────────────────────────────────────────────────── @router.post("/{recipe_id}/sub-recipes") async def add_sub_recipe( recipe_id: int, data: SubRecipeAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): recipe = await _get_recipe(recipe_id, user.kitchen_id, db) if data.child_recipe_id == recipe_id: raise HTTPException(400, "Cannot add recipe as its own sub-recipe") # Circular dependency check (max 5 levels) cycle_check = await db.execute( text(""" WITH RECURSIVE ancestors AS ( SELECT parent_recipe_id, child_recipe_id, 1 AS depth FROM recipe_sub_recipes WHERE child_recipe_id = :parent_id UNION ALL SELECT rsr.parent_recipe_id, rsr.child_recipe_id, a.depth + 1 FROM recipe_sub_recipes rsr JOIN ancestors a ON rsr.child_recipe_id = a.parent_recipe_id WHERE a.depth < 5 ) SELECT 1 FROM ancestors WHERE parent_recipe_id = :child_id LIMIT 1 """), {"parent_id": recipe_id, "child_id": data.child_recipe_id}, ) if cycle_check.fetchone(): raise HTTPException(400, "Adding this sub-recipe would create a circular dependency") sr = RecipeSubRecipe( parent_recipe_id=recipe_id, child_recipe_id=data.child_recipe_id, portions_needed=Decimal(str(data.portions_needed)), portions_needed_unit=data.portions_needed_unit, notes=data.notes, sort_order=data.sort_order, ) db.add(sr) child = await db.execute(select(Recipe).where(Recipe.id == data.child_recipe_id)) child_recipe = child.scalar_one_or_none() unit_label = data.portions_needed_unit or _get_output_unit(child_recipe) if child_recipe else "portions" db.add(RecipeChangeLog( recipe_id=recipe_id, change_summary=f"Added sub-recipe '{child_recipe.name if child_recipe else '?'}' ({data.portions_needed} {unit_label})", user_id=user.id, )) await db.commit() await db.refresh(sr) await _snapshot_recipe_and_parents(recipe_id, db, f"sub_recipe_added: {child_recipe.name if child_recipe else '?'}") return {"id": sr.id} @router.patch("/recipe-sub-recipes/{sr_id}") async def update_sub_recipe( sr_id: int, data: SubRecipeUpdateSchema, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): sr_result = await db.execute( select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id) ) sr = sr_result.scalar_one_or_none() if not sr: raise HTTPException(404, "Sub-recipe not found") changed = False if data.portions_needed is not None: sr.portions_needed = Decimal(str(data.portions_needed)) changed = True if data.portions_needed_unit is not None: sr.portions_needed_unit = data.portions_needed_unit or None changed = True if data.notes is not None: sr.notes = data.notes if data.sort_order is not None: sr.sort_order = data.sort_order await db.commit() if changed: await _snapshot_recipe_and_parents(sr.parent_recipe_id, db, "sub_recipe_qty_updated") return {"ok": True} @router.delete("/recipe-sub-recipes/{sr_id}") async def remove_sub_recipe( sr_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): sr_result = await db.execute( select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id) ) sr = sr_result.scalar_one_or_none() if not sr: raise HTTPException(404, "Sub-recipe not found") parent_id = sr.parent_recipe_id await db.delete(sr) await db.commit() await _snapshot_recipe_and_parents(parent_id, db, "sub_recipe_removed") return {"ok": True} # ── Steps ──────────────────────────────────────────────────────────────────── @router.post("/{recipe_id}/steps") async def add_step( recipe_id: int, data: StepCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await _get_recipe(recipe_id, user.kitchen_id, db) step = RecipeStep( recipe_id=recipe_id, step_number=data.step_number, title=data.title, instruction=data.instruction, duration_minutes=data.duration_minutes, notes=data.notes, ) db.add(step) await db.commit() await db.refresh(step) return {"id": step.id} @router.patch("/recipe-steps/{step_id}") async def update_step( step_id: int, data: StepUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id)) step = result.scalar_one_or_none() if not step: raise HTTPException(404, "Step not found") if data.title is not None: step.title = data.title or None # empty string -> null if data.instruction is not None: step.instruction = data.instruction if data.step_number is not None: step.step_number = data.step_number if data.duration_minutes is not None: step.duration_minutes = data.duration_minutes if data.notes is not None: step.notes = data.notes await db.commit() return {"ok": True} @router.delete("/recipe-steps/{step_id}") async def delete_step( step_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id)) step = result.scalar_one_or_none() if not step: raise HTTPException(404, "Step not found") await db.delete(step) await db.commit() return {"ok": True} @router.patch("/{recipe_id}/ingredients/reorder") async def reorder_ingredients( recipe_id: int, data: IngredientReorder, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Batch reorder recipe ingredients.""" recipe = await _get_recipe(recipe_id, user.kitchen_id, db) for i, ri_id in enumerate(data.ingredient_ids): result = await db.execute( select(RecipeIngredient).where( RecipeIngredient.id == ri_id, RecipeIngredient.recipe_id == recipe_id, ) ) ri = result.scalar_one_or_none() if ri: ri.sort_order = i await db.commit() return {"ok": True} @router.patch("/{recipe_id}/sub-recipes/reorder") async def reorder_sub_recipes( recipe_id: int, data: SubRecipeReorder, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Batch reorder sub-recipes.""" recipe = await _get_recipe(recipe_id, user.kitchen_id, db) for i, sr_id in enumerate(data.sub_recipe_ids): result = await db.execute( select(RecipeSubRecipe).where( RecipeSubRecipe.id == sr_id, RecipeSubRecipe.parent_recipe_id == recipe_id, ) ) sr = result.scalar_one_or_none() if sr: sr.sort_order = i await db.commit() return {"ok": True} @router.patch("/{recipe_id}/steps/reorder") async def reorder_steps( recipe_id: int, data: StepReorder, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): for i, step_id in enumerate(data.step_ids): result = await db.execute( select(RecipeStep).where(RecipeStep.id == step_id, RecipeStep.recipe_id == recipe_id) ) step = result.scalar_one_or_none() if step: step.step_number = i + 1 await db.commit() return {"ok": True} # ── Images ─────────────────────────────────────────────────────────────────── @router.post("/{recipe_id}/images") async def upload_image( recipe_id: int, file: UploadFile = File(...), caption: Optional[str] = Query(None), image_type: str = Query("general"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): recipe = await _get_recipe(recipe_id, user.kitchen_id, db) # Create directory img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "recipes") os.makedirs(img_dir, exist_ok=True) ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg" filename = f"{uuid.uuid4()}{ext}" filepath = os.path.join(img_dir, filename) content = await file.read() with open(filepath, "wb") as f: f.write(content) img = RecipeImage( recipe_id=recipe_id, image_path=filepath, caption=caption, image_type=image_type, uploaded_by=user.id, ) db.add(img) await db.commit() await db.refresh(img) return {"id": img.id, "image_path": img.image_path} @router.get("/{recipe_id}/images/{image_id}") async def serve_image( recipe_id: int, image_id: int, token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): # img tags can't send Authorization header, so auth via query param if not token: raise HTTPException(401, "Not authenticated") user = await get_current_user_from_token(token, db) if not user: raise HTTPException(401, "Not authenticated") await _get_recipe(recipe_id, user.kitchen_id, db) result = await db.execute( select(RecipeImage).where(RecipeImage.id == image_id, RecipeImage.recipe_id == recipe_id) ) img = result.scalar_one_or_none() if not img or not os.path.exists(img.image_path): raise HTTPException(404, "Image not found") from fastapi.responses import FileResponse return FileResponse(img.image_path) @router.delete("/recipe-images/{image_id}") async def delete_image( image_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(RecipeImage).where(RecipeImage.id == image_id)) img = result.scalar_one_or_none() if not img: raise HTTPException(404, "Image not found") if os.path.exists(img.image_path): os.remove(img.image_path) await db.delete(img) await db.commit() return {"ok": True} # ── Costing ────────────────────────────────────────────────────────────────── def _scale_child_sub_recipes(sub_recipes: list, scale: float) -> list: """Recursively scale nested sub-recipe data for hierarchical display.""" result = [] for sr in sub_recipes: result.append({ "child_recipe_id": sr["child_recipe_id"], "child_recipe_name": sr["child_recipe_name"], "batch_output_type": sr.get("batch_output_type", "portions"), "output_qty": sr.get("output_qty", 1), "output_unit": sr.get("output_unit", "portion"), "portions_needed": round(sr["portions_needed"] * scale, 4), "cost_contribution": round(sr["cost_contribution"] * scale, 4) if sr.get("cost_contribution") else None, "child_ingredients": [ { "ingredient_id": ci["ingredient_id"], "ingredient_name": ci["ingredient_name"], "quantity": round(ci["quantity"] * scale, 4), "unit": ci["unit"], "yield_percent": ci.get("yield_percent", 100.0), "cost_recent": round(ci["cost_recent"] * scale, 4) if ci.get("cost_recent") else None, "cost_min": round(ci["cost_min"] * scale, 4) if ci.get("cost_min") else None, "cost_max": round(ci["cost_max"] * scale, 4) if ci.get("cost_max") else None, "is_manual_price": ci.get("is_manual_price", False), "has_no_price": ci.get("has_no_price", False), } for ci in sr.get("child_ingredients", []) ], "child_sub_recipes": _scale_child_sub_recipes(sr.get("child_sub_recipes", []), scale), }) return result async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional[float] = None) -> dict: """Calculate full cost breakdown for a recipe.""" recipe_result = await db.execute( select(Recipe) .options( selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources), selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe), ) .where(Recipe.id == recipe_id) ) recipe = recipe_result.scalar_one_or_none() if not recipe: return {} output_qty = _get_output_qty(recipe) output_unit = _get_output_unit(recipe) scale_factor = (scale_to / output_qty) if scale_to else 1.0 ingredient_costs = [] total_ing_cost = Decimal(0) total_ing_cost_min = Decimal(0) total_ing_cost_max = Decimal(0) for ri in recipe.ingredients: ing = ri.ingredient if not ing: continue display_unit = ri.unit or ing.standard_unit qty_display = float(ri.quantity) * scale_factor # Convert to standard unit for cost calculation qty_std = _convert_unit(qty_display, display_unit, ing.standard_unit) yld = float(ri.yield_percent) if ri.yield_percent else 100.0 # Get all source prices source_prices = [] for src in (ing.sources or []): if src.price_per_std_unit: source_prices.append({ "supplier_id": src.supplier_id, "price_per_std_unit": float(src.price_per_std_unit), "latest_invoice_date": str(src.latest_invoice_date) if src.latest_invoice_date else None, }) # Effective prices recent_price = None min_price = None max_price = None if source_prices: prices = [sp["price_per_std_unit"] for sp in source_prices] min_price = min(prices) max_price = max(prices) recent_price = source_prices[0]["price_per_std_unit"] # Find most recent by date dated = [(sp.get("latest_invoice_date", ""), sp["price_per_std_unit"]) for sp in source_prices] dated.sort(reverse=True) if dated: recent_price = dated[0][1] elif ing.manual_price: recent_price = min_price = max_price = float(ing.manual_price) ing_is_free = getattr(ing, 'is_free', False) is_manual_price = (not source_prices and ing.manual_price is not None) if not ing_is_free else False has_no_price = (recent_price is None) if not ing_is_free else False # Apply yield adjustment if recent_price and yld > 0: recent_eff = recent_price / (yld / 100) else: recent_eff = recent_price if min_price and yld > 0: min_eff = min_price / (yld / 100) else: min_eff = min_price if max_price and yld > 0: max_eff = max_price / (yld / 100) else: max_eff = max_price # Cost is calculated in standard units cost_recent = round(qty_std * recent_eff, 4) if recent_eff else None cost_min = round(qty_std * min_eff, 4) if min_eff else None cost_max = round(qty_std * max_eff, 4) if max_eff else None if cost_recent: total_ing_cost += Decimal(str(cost_recent)) if cost_min: total_ing_cost_min += Decimal(str(cost_min)) if cost_max: total_ing_cost_max += Decimal(str(cost_max)) ingredient_costs.append({ "ingredient_id": ing.id, "ingredient_name": ing.name, "quantity": qty_display, "unit": display_unit, "yield_percent": yld, "recent_price": round(recent_eff, 6) if recent_eff else None, "min_price": round(min_eff, 6) if min_eff else None, "max_price": round(max_eff, 6) if max_eff else None, "cost_recent": cost_recent, "cost_min": cost_min, "cost_max": cost_max, "sources": source_prices, "is_manual_price": is_manual_price, "has_no_price": has_no_price, }) # Sub-recipe costs sub_recipe_costs = [] total_sub_cost = Decimal(0) total_sub_cost_min = Decimal(0) total_sub_cost_max = Decimal(0) for sr in recipe.sub_recipes: child = sr.child_recipe if not child: continue child_cost_data = await _calc_recipe_cost(child.id, db) child_total = Decimal(str(child_cost_data.get("total_cost_recent", 0) or 0)) child_total_min = Decimal(str(child_cost_data.get("total_cost_min", 0) or 0)) child_total_max = Decimal(str(child_cost_data.get("total_cost_max", 0) or 0)) child_output_qty = _get_output_qty(child) child_output_unit = _get_output_unit(child) # Convert portions_needed to child output unit if different unit was used needed_unit = sr.portions_needed_unit or child_output_unit portions_needed_raw = float(sr.portions_needed) * scale_factor portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit) scale_ratio = portions_needed / child_output_qty if child_output_qty else 0 cost_contribution = float(child_total) * scale_ratio if child_total else None cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None cost_contribution_max = float(child_total_max) * scale_ratio if child_total_max else None if cost_contribution: total_sub_cost += Decimal(str(cost_contribution)) if cost_contribution_min: total_sub_cost_min += Decimal(str(cost_contribution_min)) if cost_contribution_max: total_sub_cost_max += Decimal(str(cost_contribution_max)) # Scale child ingredients by portions_needed / child_output_qty # Include both direct ingredients AND ingredients from the child's own sub-recipes child_scale = portions_needed / child_output_qty if child_output_qty else 1 child_ingredients = [] for ci in child_cost_data.get("ingredients", []): child_ingredients.append({ "ingredient_id": ci["ingredient_id"], "ingredient_name": ci["ingredient_name"], "quantity": round(ci["quantity"] * child_scale, 4), "unit": ci["unit"], "yield_percent": ci["yield_percent"], "cost_recent": round(ci["cost_recent"] * child_scale, 4) if ci.get("cost_recent") else None, "cost_min": round(ci["cost_min"] * child_scale, 4) if ci.get("cost_min") else None, "cost_max": round(ci["cost_max"] * child_scale, 4) if ci.get("cost_max") else None, "is_manual_price": ci.get("is_manual_price", False), "has_no_price": ci.get("has_no_price", False), }) # Build hierarchical child_sub_recipes from the child's own sub-recipes child_sub_recipes = _scale_child_sub_recipes( child_cost_data.get("sub_recipes", []), child_scale ) sub_recipe_costs.append({ "child_recipe_id": child.id, "child_recipe_name": child.name, "batch_portions": child.batch_portions, "batch_output_type": child.batch_output_type or "portions", "output_qty": child_output_qty, "output_unit": child_output_unit, "portions_needed": portions_needed, "cost_per_portion": float(child_total) / child_output_qty if child_total and child_output_qty else None, "cost_contribution": round(cost_contribution, 4) if cost_contribution else None, "child_ingredients": child_ingredients, "child_sub_recipes": child_sub_recipes, }) total_cost = float(total_ing_cost + total_sub_cost) total_cost_min = float(total_ing_cost_min + total_sub_cost_min) total_cost_max = float(total_ing_cost_max + total_sub_cost_max) effective_output = scale_to if scale_to else output_qty cost_per_portion = total_cost / effective_output if effective_output and total_cost else None # GP calculator for dishes (gross prices incl. 20% VAT) gp_comparison = None if recipe.recipe_type == "dish" and cost_per_portion: vat_rate = 1.20 gp_comparison = [ {"gp_target": pct, "suggested_price": round(cost_per_portion / (1 - pct / 100) * vat_rate, 2)} for pct in [60, 65, 70, 75, 80] ] return { "recipe_id": recipe.id, "batch_portions": recipe.batch_portions or 1, "batch_output_type": recipe.batch_output_type or "portions", "output_qty": effective_output, "output_unit": output_unit, "ingredients": ingredient_costs, "sub_recipes": sub_recipe_costs, "total_cost_recent": round(total_cost, 4) if total_cost else None, "total_cost_min": round(total_cost_min, 4) if total_cost_min else None, "total_cost_max": round(total_cost_max, 4) if total_cost_max else None, "cost_per_portion": round(cost_per_portion, 4) if cost_per_portion else None, "gp_comparison": gp_comparison, } @router.get("/{recipe_id}/costing") async def get_recipe_costing( recipe_id: int, scale_to: Optional[float] = Query(None), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): recipe = await _get_recipe(recipe_id, user.kitchen_id, db) return await _calc_recipe_cost(recipe_id, db, scale_to) @router.get("/{recipe_id}/cost-trend") async def get_cost_trend( recipe_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await _get_recipe(recipe_id, user.kitchen_id, db) result = await db.execute( select(RecipeCostSnapshot) .where(RecipeCostSnapshot.recipe_id == recipe_id) .order_by(RecipeCostSnapshot.snapshot_date) ) snapshots = result.scalars().all() # Get change log entries to show what caused cost changes log_result = await db.execute( select(RecipeChangeLog) .where(RecipeChangeLog.recipe_id == recipe_id) .order_by(RecipeChangeLog.created_at) ) logs = log_result.scalars().all() # Group changes by date for annotation changes_by_date: dict[str, list[str]] = {} for log in logs: if log.created_at: log_date = str(log.created_at.date()) if hasattr(log.created_at, 'date') else str(log.created_at)[:10] changes_by_date.setdefault(log_date, []).append(log.change_summary) return { "snapshots": [ { "id": s.id, "created_at": str(s.snapshot_date), "cost_per_portion": float(s.cost_per_portion), "total_cost": float(s.total_cost), "trigger": s.trigger_source or "", "changes": changes_by_date.get(str(s.snapshot_date), []), } for s in snapshots ], } async def snapshot_recipe_cost(recipe_id: int, db: AsyncSession, trigger_source: str = "manual_recalc"): """Calculate and store/upsert a cost snapshot for today.""" cost_data = await _calc_recipe_cost(recipe_id, db) total_cost = cost_data.get("total_cost_recent") cost_per_portion = cost_data.get("cost_per_portion") if total_cost is None or cost_per_portion is None: return today = date.today() existing = await db.execute( select(RecipeCostSnapshot).where( RecipeCostSnapshot.recipe_id == recipe_id, RecipeCostSnapshot.snapshot_date == today, ) ) snap = existing.scalar_one_or_none() if snap: snap.cost_per_portion = Decimal(str(cost_per_portion)) snap.total_cost = Decimal(str(total_cost)) snap.trigger_source = trigger_source else: db.add(RecipeCostSnapshot( recipe_id=recipe_id, cost_per_portion=Decimal(str(cost_per_portion)), total_cost=Decimal(str(total_cost)), snapshot_date=today, trigger_source=trigger_source, )) async def _snapshot_recipe_and_parents(recipe_id: int, db: AsyncSession, trigger_source: str = ""): """Snapshot a recipe and any parent recipes that use it as a sub-recipe. Also bumps updated_at so menu staleness detection picks up content changes.""" # Bump updated_at on the recipe itself (ingredient/sub-recipe changes don't trigger onupdate) recipe_result = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) recipe = recipe_result.scalar_one_or_none() if recipe: recipe.updated_at = datetime.utcnow() await snapshot_recipe_cost(recipe_id, db, trigger_source) parent_result = await db.execute( select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == recipe_id) ) for (parent_id,) in parent_result.fetchall(): # Bump parent updated_at too p_result = await db.execute(select(Recipe).where(Recipe.id == parent_id)) parent = p_result.scalar_one_or_none() if parent: parent.updated_at = datetime.utcnow() await snapshot_recipe_cost(parent_id, db, trigger_source) await db.commit() async def snapshot_recipes_using_ingredient( ingredient_id: int, db: AsyncSession, trigger_source: str = "", price_info: dict | None = None, invoice_id: int | None = None, ): """Find all recipes using this ingredient and snapshot their costs. If price_info is provided ({"name", "unit", "old_price", "new_price"}), a RecipeChangeLog entry is created for each affected recipe. """ # Direct usage ri_result = await db.execute( select(RecipeIngredient.recipe_id).where(RecipeIngredient.ingredient_id == ingredient_id) ) recipe_ids = set(r[0] for r in ri_result.fetchall()) # Also find recipes that use sub-recipes containing this ingredient (1 level) for rid in list(recipe_ids): parent_result = await db.execute( select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == rid) ) for (parent_id,) in parent_result.fetchall(): recipe_ids.add(parent_id) # Build change log message if price info available change_msg = None if price_info: name = price_info["name"] unit = price_info.get("unit", "") old_p = price_info.get("old_price") new_p = price_info["new_price"] unit_label = f"/{unit}" if unit else "" # Only log if price actually changed if old_p is not None and abs(new_p - old_p) > 0.000001: change_msg = f"{name} price changed: £{old_p:.4f}{unit_label} → £{new_p:.4f}{unit_label}" elif old_p is None: change_msg = f"{name} price set: £{new_p:.4f}{unit_label}" for rid in recipe_ids: # Bump updated_at so menu staleness detection picks up ingredient flag changes r_result = await db.execute(select(Recipe).where(Recipe.id == rid)) r = r_result.scalar_one_or_none() if r: r.updated_at = datetime.utcnow() await snapshot_recipe_cost(rid, db, trigger_source) if change_msg: db.add(RecipeChangeLog( recipe_id=rid, change_summary=change_msg, user_id=None, source_invoice_id=invoice_id, )) if recipe_ids: await db.commit() # ── Recipe Change Log ──────────────────────────────────────────────────────── @router.get("/{recipe_id}/change-log") async def get_change_log( recipe_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): await _get_recipe(recipe_id, user.kitchen_id, db) result = await db.execute( select(RecipeChangeLog) .options(selectinload(RecipeChangeLog.user)) .where(RecipeChangeLog.recipe_id == recipe_id) .order_by(RecipeChangeLog.created_at.desc()) ) logs = result.scalars().all() return [ { "id": l.id, "change_summary": l.change_summary, "username": l.user.name if l.user else "", "created_at": str(l.created_at) if l.created_at else "", } for l in logs ] @router.post("/cleanup-false-price-changes") async def cleanup_false_price_changes( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Remove recipe change log entries where old price equals new price (false changes).""" if not user.is_admin: raise HTTPException(status_code=403, detail="Admin only") import re # Get all "price changed" log entries for this kitchen's recipes result = await db.execute( select(RecipeChangeLog) .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id) .where( Recipe.kitchen_id == user.kitchen_id, RecipeChangeLog.change_summary.like("%price changed%"), ) ) logs = result.scalars().all() # Pattern: "Ingredient name price changed: £0.0086/g → £0.0086/g" pattern = re.compile(r"price changed: £([\d.]+)(/\w+)? → £([\d.]+)(/\w+)?") deleted = 0 for log in logs: match = pattern.search(log.change_summary) if match: old_price = float(match.group(1)) new_price = float(match.group(3)) if abs(old_price - new_price) < 0.000001: await db.delete(log) deleted += 1 await db.commit() return {"message": f"Removed {deleted} false price change entries", "deleted": deleted} @router.post("/backfill-invoice-references") async def backfill_invoice_references( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Backfill missing source_invoice_id on recipe change log entries by matching to cost snapshots whose trigger_source contains 'invoice #'.""" if not user.is_admin: raise HTTPException(status_code=403, detail="Admin only") import re # Get change log entries missing source_invoice_id for this kitchen result = await db.execute( select(RecipeChangeLog) .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id) .where( Recipe.kitchen_id == user.kitchen_id, RecipeChangeLog.source_invoice_id == None, (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), ) ) logs = result.scalars().all() if not logs: return {"message": "No entries missing invoice references", "updated": 0} # For each log, find a cost snapshot for same recipe created within 5 seconds invoice_re = re.compile(r"invoice #(\d+)") updated = 0 for log in logs: if not log.created_at: continue # Look for a cost snapshot close in time with an invoice trigger_source snap_result = await db.execute( select(RecipeCostSnapshot.trigger_source).where( RecipeCostSnapshot.recipe_id == log.recipe_id, RecipeCostSnapshot.created_at.between( log.created_at - timedelta(seconds=5), log.created_at + timedelta(seconds=5), ), RecipeCostSnapshot.trigger_source.like("%invoice #%"), ).limit(1) ) row = snap_result.scalar_one_or_none() if row: m = invoice_re.search(row) if m: log.source_invoice_id = int(m.group(1)) updated += 1 await db.commit() return {"message": f"Linked {updated} of {len(logs)} entries to their triggering invoice", "updated": updated, "total": len(logs)} # ── Print / Recipe Card HTML ──────────────────────────────────────────────── @router.get("/{recipe_id}/print") async def print_recipe( recipe_id: int, format: str = Query("full"), # "full" | "kitchen" token: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), ): # window.open() can't send Authorization header, so auth via query param if not token: raise HTTPException(status_code=401, detail="Not authenticated") user = await get_current_user_from_token(token, db) if not user: raise HTTPException(status_code=401, detail="Not authenticated") recipe = await _get_recipe(recipe_id, user.kitchen_id, db) full_data = await get_recipe(recipe_id, user, db) cost_data = await _calc_recipe_cost(recipe_id, db) # Get flags from api.food_flags import compute_recipe_flags flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db) html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token) return HTMLResponse(content=html) def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str: """Generate print-optimised HTML for a recipe.""" esc = html_escape name = esc(recipe_data.get("name", "")) recipe_type = recipe_data.get("recipe_type", "dish") batch = recipe_data.get("batch_portions", 1) batch_output_type = recipe_data.get("batch_output_type", "portions") output_qty = recipe_data.get("output_qty", batch) output_unit = recipe_data.get("output_unit", "portion") description = esc(recipe_data.get("description", "") or "") prep_time = recipe_data.get("prep_time_minutes") cook_time = recipe_data.get("cook_time_minutes") # Flag badges flag_html = "" for f in flags: if f.is_active: color = "#dc3545" if f.propagation_type == "contains" else "#28a745" badge_style = f"display:inline-block;padding:2px 8px;margin:2px;border-radius:12px;background:{color};color:white;font-size:12px;" if f.excludable_on_request: badge_style += "border:2px dashed white;" flag_html += f'{esc(f.flag_code or f.flag_name)}' # Ingredients table ing_rows = "" for ing in recipe_data.get("ingredients", []): cost_str = f"£{ing['cost']:.2f}" if ing.get("cost") else "-" ing_rows += f""" {esc(ing['ingredient_name'])} {ing['quantity']:g}{esc(ing['unit'])} {cost_str} """ # Sub-recipes — match cost_data child_ingredients by child_recipe_id cost_sub_recipes = {sr["child_recipe_id"]: sr for sr in cost_data.get("sub_recipes", [])} sub_rows = "" for sr in recipe_data.get("sub_recipes", []): cost_sr = cost_sub_recipes.get(sr["child_recipe_id"], {}) cost_str = f"£{sr['cost_contribution']:.2f}" if sr.get("cost_contribution") else "-" sr_unit = sr.get("output_unit", "portion") sr_unit_label = f"{sr['portions_needed']:g} {sr_unit}{'s' if sr['portions_needed'] != 1 and sr_unit == 'portion' else ''}" sub_rows += f""" ▸ {esc(sr['child_recipe_name'])} ({sr_unit_label}) {cost_str} """ for ci in cost_sr.get("child_ingredients", []): ci_cost = f"£{ci['cost_recent']:.2f}" if ci.get("cost_recent") else "-" sub_rows += f""" ↳ {esc(ci['ingredient_name'])} {ci['quantity']:g}{esc(ci['unit'])} {ci_cost} """ # Steps steps_html = "" for step in recipe_data.get("steps", []): dur = f" ({step['duration_minutes']} min)" if step.get("duration_minutes") else "" title = esc(step['title']) if step.get('title') else None instr = esc(step['instruction']) if title: steps_html += f"
  • {title}{dur}
    {instr}
  • " else: steps_html += f"
  • {instr}{f' {dur}' if dur else ''}
  • " # Cost summary cost_per_portion = cost_data.get("cost_per_portion") total_cost = cost_data.get("total_cost_recent") cost_summary = "" if format == "full" and cost_per_portion: cost_unit_label = f"Cost per {output_unit}" if output_unit != "portion" else "Cost per portion" cost_summary = f"""
    {cost_unit_label}: £{cost_per_portion:.4f} | Total cost: £{total_cost:.2f}
    """ # Images (plating photos for kitchen card) images_html = "" if format == "kitchen": plating_images = [img for img in recipe_data.get("images", []) if img.get("image_type") == "plating"] if not plating_images: plating_images = recipe_data.get("images", [])[:1] for img in plating_images: img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}" images_html += f'' time_info = "" if prep_time or cook_time: parts = [] if prep_time: parts.append(f"Prep: {prep_time} min") if cook_time: parts.append(f"Cook: {cook_time} min") time_info = f"

    {' | '.join(parts)}

    " kitchen_font_size = "font-size:16px;" if format == "kitchen" else "" return f""" {name}

    {name}

    {recipe_type.upper()} {f'Yield: {output_qty:g}{output_unit}' if recipe_type == 'component' and batch_output_type == 'bulk' else (f'Batch: {batch} portions' if recipe_type == 'component' else '')}

    {time_info} {f'

    {description}

    ' if description else ''}
    {flag_html}
    {images_html}

    Ingredients

    {ing_rows}{sub_rows}
    Ingredient Quantity Cost
    {cost_summary} {'

    Method

      ' + steps_html + '
    ' if steps_html else ''} {f'

    Printed {date.today().strftime("%d/%m/%Y")}

    '} """ # ── Helpers ────────────────────────────────────────────────────────────────── # Unit conversion factors to a common base (g for weight, ml for volume) _UNIT_TO_BASE = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} # Which units are compatible (same measurement type) _COMPATIBLE_UNITS = { "g": ("g", "kg"), "kg": ("g", "kg"), "ml": ("ml", "ltr"), "ltr": ("ml", "ltr"), "portion": ("portion",), "each": ("each",), } def _convert_unit(value: float, from_unit: str, to_unit: str) -> float: """Convert a value between compatible units. Returns original value if incompatible.""" if from_unit == to_unit: return value from_base = _UNIT_TO_BASE.get(from_unit) to_base = _UNIT_TO_BASE.get(to_unit) if from_base is None or to_base is None: return value # Check compatibility if to_unit not in _COMPATIBLE_UNITS.get(from_unit, ()): return value return value * from_base / to_base def _get_compatible_units(unit: str) -> list[str]: """Get list of compatible units for a given output unit.""" return list(_COMPATIBLE_UNITS.get(unit, (unit,))) def _get_output_qty(recipe) -> float: """Unified output quantity: bulk uses yield_qty, portioned uses batch_portions.""" if recipe.batch_output_type == "bulk" and recipe.batch_yield_qty: return float(recipe.batch_yield_qty) return recipe.batch_portions or 1 def _get_output_unit(recipe) -> str: """Unified output unit label: bulk uses yield_unit, portioned uses 'portion'.""" if recipe.batch_output_type == "bulk" and recipe.batch_yield_unit: return recipe.batch_yield_unit return "portion" async def _get_recipe(recipe_id: int, kitchen_id: int, db: AsyncSession) -> Recipe: result = await db.execute( select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == kitchen_id) ) recipe = result.scalar_one_or_none() if not recipe: raise HTTPException(404, "Recipe not found") return recipe