kitchen/backend/api/recipes.py
jtricerolph ba075276b1 Audit pass: cookie auth migration, route guards, GP% clamp, CSV export, OCR transaction safety, N+1 fix, and cleanup
- Migrated all 435 frontend fetch calls from Authorization Bearer header to credentials: 'include' (cookie auth)
- Removed ?token= from all file/image URLs (browser history exposure)
- Added ProtectedRoute wrapper to all capability-gated routes in App.tsx
- OCR background task: added transaction boundary, improved error handling and status rollback
- DuplicateDetector: wrapped in non-fatal try/except so crashes don't abort invoice processing
- File upload: commit DB row before writing to disk to prevent orphaned files
- GP% clamped to 100% in GPReport (credit notes can inflate above 100%)
- Added CSV export to GPReport (suppliers, daily data, allowances breakdown)
- Backend file-serving endpoints: cookie auth with ?token= fallback for backward compatibility
- DATA_DIR: moved from hardcoded /app/data to os.getenv in invoices.py and recipes.py
- N+1 fix in list_recipes: batch-loads latest cost snapshot in 1 query (was N)
- Zero-yield sub-recipe: logs warning instead of silently zeroing cost contribution
- Budget spend rate input: rejects negative values
- GPReport allowances toggle: persisted to localStorage across page loads
- DB pool_size/max_overflow: configurable via DB_POOL_SIZE/DB_MAX_OVERFLOW env vars
- Fixed SyntaxWarning from \\d in invoices.py docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 10:04:20 +00:00

2347 lines
94 KiB
Python

"""
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, Request
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 = os.getenv("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()
# Batch-load latest cost snapshot per recipe (1 query vs N)
recipe_ids = [r.id for r in recipes]
snap_map: dict[int, RecipeCostSnapshot] = {}
if recipe_ids:
latest_subq = (
select(
RecipeCostSnapshot.recipe_id,
func.max(RecipeCostSnapshot.snapshot_date).label("max_date"),
)
.where(RecipeCostSnapshot.recipe_id.in_(recipe_ids))
.group_by(RecipeCostSnapshot.recipe_id)
.subquery()
)
snap_rows = await db.execute(
select(RecipeCostSnapshot).join(
latest_subq,
and_(
RecipeCostSnapshot.recipe_id == latest_subq.c.recipe_id,
RecipeCostSnapshot.snapshot_date == latest_subq.c.max_date,
),
)
)
snap_map = {s.recipe_id: s for s in snap_rows.scalars().all()}
items = []
for r in recipes:
snap = snap_map.get(r.id)
# 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,
request: Request,
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
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)
if not child_output_qty:
logger.warning(f"Recipe {child.id} ({child.name!r}) has zero yield qty — cost contribution zeroed in parent recipe")
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 #<id>'."""
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,
request: Request,
format: str = Query("full"), # "full" | "kitchen"
token: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
user = None
if token:
user = await get_current_user_from_token(token, db)
if not user:
try:
user = await get_current_user(request)
except HTTPException:
pass
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)
return HTMLResponse(content=html)
def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0) -> 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'<span style="{badge_style}">{esc(f.flag_code or f.flag_name)}</span>'
# 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"""<tr>
<td style="padding:6px 10px;border-bottom:1px solid #eee;">{esc(ing['ingredient_name'])}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;text-align:right;">{ing['quantity']:g}{esc(ing['unit'])}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;text-align:right;">{cost_str}</td>
</tr>"""
# 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"""<tr style="background:#f0f0f0;">
<td style="padding:6px 10px;border-bottom:1px solid #ddd;font-weight:600;">▸ {esc(sr['child_recipe_name'])} ({sr_unit_label})</td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;"></td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;font-weight:600;">{cost_str}</td>
</tr>"""
for ci in cost_sr.get("child_ingredients", []):
ci_cost = f"£{ci['cost_recent']:.2f}" if ci.get("cost_recent") else "-"
sub_rows += f"""<tr style="background:#fafafa;">
<td style="padding:4px 10px 4px 30px;border-bottom:1px solid #f0f0f0;color:#666;">↳ {esc(ci['ingredient_name'])}</td>
<td style="padding:4px 10px;border-bottom:1px solid #f0f0f0;text-align:right;color:#666;">{ci['quantity']:g}{esc(ci['unit'])}</td>
<td style="padding:4px 10px;border-bottom:1px solid #f0f0f0;text-align:right;color:#666;">{ci_cost}</td>
</tr>"""
# 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"<li style='margin-bottom:8px;'><strong>{title}</strong>{dur}<br/><span style='color:#555;'>{instr}</span></li>"
else:
steps_html += f"<li style='margin-bottom:8px;'>{instr}{f' <em>{dur}</em>' if dur else ''}</li>"
# 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"""
<div style="margin-top:20px;padding:12px;background:#f0f0f0;border-radius:6px;">
<strong>{cost_unit_label}:</strong> £{cost_per_portion:.4f} |
<strong>Total cost:</strong> £{total_cost:.2f}
</div>"""
# 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"/kitchen/api/recipes/{recipe_id}/images/{img['id']}"
images_html += f'<img src="{esc(img_url)}" style="max-width:300px;border-radius:8px;margin:10px 0;" />'
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"<p style='color:#666;'>{' | '.join(parts)}</p>"
kitchen_font_size = "font-size:16px;" if format == "kitchen" else ""
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{name}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width:800px; margin:0 auto; padding:20px; {kitchen_font_size} }}
@media print {{
body {{ padding: 0; }}
.no-print {{ display: none; }}
}}
h1 {{ margin-bottom: 4px; }}
table {{ width: 100%; border-collapse: collapse; margin: 10px 0; }}
th {{ text-align: left; padding: 8px 10px; border-bottom: 2px solid #ccc; background: #f5f5f5; }}
</style>
</head>
<body>
<h1>{name}</h1>
<p style="color:#666;">
<span style="background:#e0e0e0;padding:2px 8px;border-radius:4px;font-size:13px;">{recipe_type.upper()}</span>
{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 '')}
</p>
{time_info}
{f'<p>{description}</p>' if description else ''}
<div style="margin:10px 0;">{flag_html}</div>
{images_html}
<h2>Ingredients</h2>
<table>
<thead><tr>
<th>Ingredient</th>
<th style="text-align:right;">Quantity</th>
<th style="text-align:right;">Cost</th>
</tr></thead>
<tbody>{ing_rows}{sub_rows}</tbody>
</table>
{cost_summary}
{'<h2>Method</h2><ol>' + steps_html + '</ol>' if steps_html else ''}
{f'<p style="color:#999;font-size:12px;margin-top:30px;">Printed {date.today().strftime("%d/%m/%Y")}</p>'}
</body>
</html>"""
# ── 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