FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
55 KiB
Recipe, Ingredient & Food Flag System — Implementation Plan
Context
The kitchen-invoice-flash app currently tracks invoices, line items, and suppliers with a basic portioning feature (scales icon → cost breakdown modal). This plan introduces a full Recipe & Ingredient Management System that:
- Creates a canonical ingredient library with yield tracking, duplicate detection, and multi-supplier price comparison
- Builds a hierarchical recipe system with sub-recipes, batch portions, scaling, cost trending, and printable recipe cards
- Replaces hardcoded allergens with a configurable food flag system — categories with different propagation logic ("contains" for allergens, "suitable_for" for dietary)
- Adds flag tracking cascading from line items → ingredients → recipes → plated dishes, with audit trails for overrides
- Introduces event/function ordering — select recipes × quantities to generate aggregated shopping lists and purchase orders
- Provides an internal API (API key auth) for in-house apps (e.g., menu display plugin) and KDS recipe linking for kitchen display integration
The existing portioning inline expansion (scales icon) becomes an ingredient-first mapping modal dialog — pack/unit fields remain but now feed into ingredient unit conversion rather than standalone portioning.
Phase 1: Database Schema & Backend Models
New Tables
ingredient_categories — Configurable ingredient groupings
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(100) NOT NULL -- "Dairy", "Meat", "Produce", etc.
sort_order INT DEFAULT 0
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(kitchen_id, name)
Pre-seeded: Dairy, Meat, Seafood, Produce, Dry Goods, Oils & Fats, Herbs & Spices, Bakery, Beverages, Condiments, Other
ingredients — Canonical ingredient library
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(255) NOT NULL -- "Butter", "Minced Beef 80/20", "Plain Flour"
category_id INT FK → ingredient_categories(id) ON DELETE SET NULL
standard_unit VARCHAR(20) NOT NULL -- "g", "kg", "ml", "ltr", "each"
yield_percent NUMERIC(5,2) DEFAULT 100.00 -- usable % after trim/peel/waste (e.g., 85 for carrots, 65 for whole chicken)
manual_price NUMERIC(12,6) -- placeholder price/std_unit for unmapped ingredients
notes TEXT
is_archived BOOL DEFAULT false
created_by INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
UNIQUE(kitchen_id, name)
ingredient_sources — Maps supplier products → ingredients (with unit conversion)
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
ingredient_id INT FK → ingredients(id) ON DELETE CASCADE
supplier_id INT FK → suppliers(id) NOT NULL
product_code VARCHAR(100) -- matches line_items.product_code (NULL for no-SKU suppliers)
description_pattern VARCHAR(255) -- normalised substring match against line_item descriptions (used when product_code is NULL)
-- Pack/conversion data (persisted like product_definitions)
pack_quantity INT -- e.g., 10 (10 blocks of butter)
unit_size NUMERIC(10,3) -- e.g., 250 (250g each)
unit_size_type VARCHAR(10) -- "g", "kg", "ml", "ltr", "oz", "cl", "each"
-- Price tracking (auto-updated from most recent matched line item)
latest_unit_price NUMERIC(10,2)
latest_invoice_id INT FK → invoices(id) ON DELETE SET NULL
latest_invoice_date DATE
price_per_std_unit NUMERIC(12,6) -- auto-calc: latest_unit_price / total_in_standard_unit
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
-- Dual unique constraints for SKU and non-SKU suppliers
UNIQUE(kitchen_id, ingredient_id, supplier_id, product_code) -- for items WITH product_code
-- Partial unique index for no-SKU items (product_code IS NULL)
CREATE UNIQUE INDEX uix_ingredient_source_desc
ON ingredient_sources(kitchen_id, ingredient_id, supplier_id, description_pattern)
WHERE product_code IS NULL;
Matching priority (same as existing product_definitions pattern):
- Try
supplier_id + product_codeexact match first - Fall back to
supplier_id + description_patternnormalised contains-match (for no-SKU suppliers) - Longer patterns match before shorter ones (more specific wins)
Validation rule: When product_code is NULL, description_pattern is required (and vice versa — at least one must be set).
food_flag_categories — Configurable flag category types (Allergy, Dietary, etc.)
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(100) NOT NULL -- "Allergy", "Dietary", "Religious", etc.
propagation_type VARCHAR(20) NOT NULL -- "contains" (any-match, union) | "suitable_for" (all-must-match, intersection)
sort_order INT DEFAULT 0
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(kitchen_id, name)
Pre-seeded:
- "Allergy" (propagation: "contains") — if ANY child ingredient has it, recipe has it
- "Dietary" (propagation: "suitable_for") — only applies if ALL children qualify
food_flags — Individual flags within categories
id SERIAL PK
category_id INT FK → food_flag_categories(id) ON DELETE CASCADE
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(100) NOT NULL -- "Gluten", "Milk", "Vegetarian", "Vegan", etc.
code VARCHAR(10) -- short code: "Gl", "Mi", "V", "Ve" (for badges)
icon VARCHAR(10) -- optional emoji/symbol
sort_order INT DEFAULT 0
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(kitchen_id, name)
Pre-seeded Allergy flags: Celery, Gluten, Crustaceans, Eggs, Fish, Lupin, Milk, Molluscs, Mustard, Tree Nuts, Peanuts, Sesame, Soya, Sulphites Pre-seeded Dietary flags: Vegetarian, Vegan, Pescatarian, Gluten-Free (dietary, not allergy)
ingredient_flags — Canonical flag assignments on ingredients (latching)
id SERIAL PK
ingredient_id INT FK → ingredients(id) ON DELETE CASCADE
food_flag_id INT FK → food_flags(id) ON DELETE CASCADE
flagged_by INT FK → users(id)
source VARCHAR(20) DEFAULT 'manual' -- "manual" | "latched" (auto-set from line_item_flag)
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(ingredient_id, food_flag_id)
Latching behavior: When a line item is flagged AND that line item is mapped to an ingredient (via ingredient_id), the system auto-creates an ingredient_flag with source='latched'. Flags latch on permanently — they never auto-remove. Only manual deletion by a user can remove an ingredient flag.
This table is the canonical source of truth for ingredient-level flags. Recipe flag propagation reads from here, not from line_item_flags.
line_item_flags — Flags on supplier line items (data entry mechanism)
id SERIAL PK
line_item_id INT FK → line_items(id) ON DELETE CASCADE
food_flag_id INT FK → food_flags(id) ON DELETE CASCADE
flagged_by INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(line_item_id, food_flag_id)
Line item flags serve as a data-entry point. When set, they trigger latching to the mapped ingredient (if line_item.ingredient_id is set). The ingredient_flags table holds the persistent truth.
menu_sections — Groupings for recipes (both plated and component)
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(100) NOT NULL -- Plated: "Starters", "Mains", "Desserts". Component: "Sauces", "Bases", "Preparations"
sort_order INT DEFAULT 0
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(kitchen_id, name)
Sections work for both recipe types. The recipe list page filters sections by selected type (component/plated). No separate section_type needed — a section like "Sauces" naturally only has components assigned to it.
recipes — Component and plated recipes
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(255) NOT NULL
recipe_type VARCHAR(20) NOT NULL -- "component" | "plated"
menu_section_id INT FK → menu_sections(id) ON DELETE SET NULL -- optional grouping for either type
description TEXT
batch_portions INT NOT NULL DEFAULT 1 -- components only: how many portions this batch makes (plated always 1)
prep_time_minutes INT
cook_time_minutes INT
notes TEXT
is_archived BOOL DEFAULT false
kds_menu_item_name VARCHAR(255) -- Phase 7: matches KDS/SambaPOS menu item name for linking
created_by INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
UNIQUE(kitchen_id, name)
recipe_ingredients — Ingredients used in a recipe
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
ingredient_id INT FK → ingredients(id) ON DELETE RESTRICT
quantity NUMERIC(10,3) NOT NULL -- in ingredient's standard_unit
notes TEXT -- "finely diced", "room temperature"
sort_order INT DEFAULT 0
recipe_sub_recipes — Sub-recipes used in a recipe (max 5 levels deep)
id SERIAL PK
parent_recipe_id INT FK → recipes(id) ON DELETE CASCADE
child_recipe_id INT FK → recipes(id) ON DELETE RESTRICT
portions_needed NUMERIC(10,3) NOT NULL -- how many portions of the child batch we use
notes TEXT
sort_order INT DEFAULT 0
CHECK(parent_recipe_id != child_recipe_id)
recipe_steps — Cooking instructions
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
step_number INT NOT NULL
instruction TEXT NOT NULL
image_path VARCHAR(500) -- optional step photo (local Docker volume)
duration_minutes INT
notes TEXT
recipe_images — General recipe/plating photos
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
image_path VARCHAR(500) NOT NULL -- stored at /app/data/{kitchen_id}/recipes/{uuid}.{ext}
caption TEXT
image_type VARCHAR(20) DEFAULT 'general' -- "general" | "plating" | "method"
sort_order INT DEFAULT 0
uploaded_by INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
recipe_flags — Flag state on recipes (manual additions + override state)
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
food_flag_id INT FK → food_flags(id) ON DELETE CASCADE
source_type VARCHAR(20) NOT NULL -- "auto" | "manual"
is_active BOOL DEFAULT true -- false = overridden/deactivated
excludable_on_request BOOL DEFAULT false -- plated only: can prepare without on request
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
UNIQUE(recipe_id, food_flag_id)
recipe_flag_overrides — Audit log for flag changes (mandatory notes)
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
food_flag_id INT FK → food_flags(id) ON DELETE CASCADE
action VARCHAR(20) NOT NULL -- "deactivated" | "reactivated" | "set_excludable" | "unset_excludable"
note TEXT NOT NULL -- mandatory reason
user_id INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
recipe_change_log — Recipe change history
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
change_summary TEXT NOT NULL -- "Butter quantity changed from 200g to 250g; Added Oregano 5g"
user_id INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
recipe_cost_snapshots — Cost trending over time
id SERIAL PK
recipe_id INT FK → recipes(id) ON DELETE CASCADE
cost_per_portion NUMERIC(12,6) NOT NULL
total_cost NUMERIC(12,6) NOT NULL
snapshot_date DATE NOT NULL
trigger_source VARCHAR(100) -- "ingredient_price_update: Butter" or "manual_recalc"
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(recipe_id, snapshot_date) -- one snapshot per recipe per day (upsert on conflict)
Upsert behavior: If a snapshot already exists for today, update it with the latest cost values. Multiple ingredient price changes on the same day result in one snapshot reflecting the final state.
event_orders — Function/event ordering (select recipes × quantities → generate shopping list)
id SERIAL PK
kitchen_id INT FK → kitchens(id) NOT NULL
name VARCHAR(255) NOT NULL -- "Wedding Reception 15th March", "Staff Party"
event_date DATE
notes TEXT
status VARCHAR(20) DEFAULT 'DRAFT' -- DRAFT | FINALISED | ORDERED
created_by INT FK → users(id)
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
event_order_items — Recipes and quantities for an event
id SERIAL PK
event_order_id INT FK → event_orders(id) ON DELETE CASCADE
recipe_id INT FK → recipes(id) ON DELETE RESTRICT
quantity INT NOT NULL -- how many servings/batches of this recipe
notes TEXT
sort_order INT DEFAULT 0
Both plated and component recipes can be added to an event order. Components show batch_portions for context (e.g., "Burger Sauce — batch of 20 portions, qty: 3 batches = 60 portions").
Existing Table Modifications
line_items — Add ingredient_id column
ALTER TABLE line_items ADD COLUMN ingredient_id INT REFERENCES ingredients(id) ON DELETE SET NULL;
CREATE INDEX idx_line_items_ingredient ON line_items(ingredient_id);
Direct link from line item to its mapped ingredient. Set when a user maps a line item to an ingredient via the ingredient mapping modal. Nullable — only populated for mapped items.
Benefits:
- Direct relationship for queries ("show all line items for Butter")
- Enables latching: when a line_item_flag is set, check
ingredient_idand auto-create ingredient_flag - Scales icon tooltip can show "→ Butter" without a lookup query
kitchen_settings — Add API key fields
ALTER TABLE kitchen_settings ADD COLUMN api_key VARCHAR(100);
ALTER TABLE kitchen_settings ADD COLUMN api_key_enabled BOOL DEFAULT false;
Used by external in-house apps (e.g., menu display plugin) to authenticate against the internal API endpoints.
Database Extensions
pg_trgm — Trigram similarity for duplicate detection
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Used for fuzzy ingredient name matching during creation. Provides similarity() function:
SELECT name, similarity(name, 'Butter') AS sim
FROM ingredients
WHERE kitchen_id = :kid AND similarity(name, 'Butter') > 0.3
ORDER BY sim DESC LIMIT 5;
Returns similar names like "Unsalted Butter" (0.47), "Salted Butter" (0.47) as warnings before creating a potential duplicate.
Unit Conversion Constants
Standard units: g, kg, ml, ltr, each. Conversion factors in code:
UNIT_CONVERSIONS = {
"g": {"g": 1, "kg": 0.001},
"kg": {"g": 1000, "kg": 1},
"oz": {"g": 28.3495, "kg": 0.0283495},
"lb": {"g": 453.592, "kg": 0.453592},
"ml": {"ml": 1, "ltr": 0.001},
"cl": {"ml": 10, "ltr": 0.01},
"ltr": {"ml": 1000, "ltr": 1},
"each": {"each": 1},
}
Key Calculation Logic
Ingredient cost from source:
total_in_std_unit = pack_quantity × unit_size × conversion_factor(unit_size_type → standard_unit)
price_per_std_unit = latest_unit_price / total_in_std_unit
Yield-adjusted effective price (used in recipes):
raw_price = most recent source price_per_std_unit (or manual_price if unmapped)
effective_price = raw_price / (yield_percent / 100)
-- e.g., carrots at £1/kg with 85% yield → £1.18/kg usable
-- whole chicken at £3/kg with 65% yield → £4.62/kg usable
Recipe cost:
ingredient_cost = SUM(recipe_ingredient.quantity × ingredient.effective_price)
sub_recipe_cost = SUM((sub.portions_needed / child.batch_portions) × child.total_cost)
total_cost = ingredient_cost + sub_recipe_cost
cost_per_portion = total_cost / batch_portions
Cost range on recipes:
min_cost = use cheapest source for each ingredient
max_cost = use most expensive source for each ingredient
recent_cost = use most recent purchase for each ingredient (default)
GP calculator on plated recipes:
At target GP%: suggested_price = cost_per_portion / (1 - target_gp)
Show comparison at 60%, 65%, 70% GP targets
Food flag propagation (computed on-read via ingredient_flags):
For each food_flag_category:
if propagation_type == "contains":
Collect flags from ALL recipe_ingredients → ingredient → ingredient_flags
Union with flags from ALL sub-recipes (recursive)
→ Recipe has flag if ANY ingredient has it
if propagation_type == "suitable_for":
For each flag in category:
Check ALL recipe_ingredients → ingredient → ingredient_flags has this flag
AND ALL sub-recipes have this flag (recursive)
→ Recipe has flag only if ALL ingredients have it
→ Ingredients with NO flags assessed for this category count as "unknown" (not a match)
Merge with manual additions (source_type="manual" in recipe_flags)
Apply overrides (is_active=false entries from recipe_flags with audit log)
Flag latching flow:
1. User flags a line_item with "Contains: Milk" via Review.tsx flag button
2. System checks line_item.ingredient_id — if set (e.g., ingredient "Butter"):
a. Auto-create ingredient_flag(ingredient=Butter, flag=Milk, source='latched') if not exists
b. ingredient_flag persists permanently regardless of future line item changes
3. All recipes using "Butter" now auto-inherit "Contains: Milk" via propagation
Files to Create
backend/models/ingredient.py— Ingredient, IngredientCategory, IngredientSource, IngredientFlagbackend/models/recipe.py— Recipe, MenuSection, RecipeIngredient, RecipeSubRecipe, RecipeStep, RecipeImage, RecipeChangeLog, RecipeCostSnapshotbackend/models/food_flag.py— FoodFlagCategory, FoodFlag, LineItemFlag, RecipeFlag, RecipeFlagOverridebackend/models/event_order.py— EventOrder, EventOrderItembackend/api/ingredients.py— Ingredient CRUD + source mapping + auto-price hookbackend/api/recipes.py— Recipe CRUD + costing + sub-recipe cycle check + scaling + recipe card HTML + menu section CRUD + cost snapshot calculationbackend/api/food_flags.py— Flag management + ingredient flagging + line item flagging + latching logic + recipe flag propagation + overridesbackend/api/event_orders.py— Event ordering + aggregated shopping list generationbackend/api/external.py— Internal API endpoints with API key auth for in-house appsbackend/migrations/add_recipe_system.py— All new tables + pre-seeded data + pg_trgm extensionfrontend/src/components/Ingredients.tsx— Ingredient library pagefrontend/src/components/RecipeList.tsx— Recipe list pagefrontend/src/components/RecipeEditor.tsx— Recipe builder/editor pagefrontend/src/components/RecipeFlagMatrix.tsx— Flag breakdown matrix (ingredients × flags)frontend/src/components/FoodFlagBadges.tsx— Reusable flag badges componentfrontend/src/components/EventOrders.tsx— Event ordering pagefrontend/src/components/EventOrderEditor.tsx— Event order builder
Files to Modify
backend/models/__init__.py— Register new models (IngredientFlag added)backend/models/line_item.py— Addingredient_idFK columnbackend/main.py— Register new routers (ingredients, recipes, food_flags, event_orders, external)frontend/src/App.tsx— Add routes + new "Recipes" dropdown in top header nav (matching existing Invoices/Bookings/Reports dropdown pattern) containing: Recipes, Ingredients, Event Ordersfrontend/src/components/Review.tsx— Replace inline scales expansion with ingredient mapping modal dialog + add flag buttonfrontend/src/components/Dashboard.tsx— Add unmapped ingredients widgetbackend/api/invoices.py— Auto-price update hook when line items saved + flag latching trigger + cost snapshot triggerfrontend/src/pages/Settings.tsx— Add Food Flag Categories/Flags management section + API Key management section
Phase 2: Foundation — Schema, Models, Ingredient Library
2a: Migration & Models
- Create migration
add_recipe_system.pywith all tables above - Enable
pg_trgmextension - Add
ingredient_idcolumn toline_itemstable - Add
api_key+api_key_enabledcolumns tokitchen_settingstable - Pre-seed
ingredient_categorieswith defaults - Pre-seed
food_flag_categories(Allergy/contains, Dietary/suitable_for) andfood_flags(EU 14 + dietary defaults). Pre-seeded categories and flags are user-editable and deletable — no special protection needed (kitchen can customise to their needs) - Create all SQLAlchemy models
- Register in
__init__.pyandmain.py
2b: Ingredient Backend
Endpoints:
GET /api/ingredient-categories— List categories (for dropdowns)POST /api/ingredient-categories— Create categoryPATCH /api/ingredient-categories/{id}— Rename/reorderDELETE /api/ingredient-categories/{id}— Delete (sets ingredients to null category)GET /api/ingredients— List all (with source count, flag summary, effective price)GET /api/ingredients?unmapped=true— Filter to ingredients with no sourcesPOST /api/ingredients— Create (name, category_id, standard_unit, yield_percent, optional manual_price). Duplicate detection: uses pg_trgmsimilarity()to fuzzy-match name against existing ingredients, returns warnings if similar names found (threshold > 0.3)PATCH /api/ingredients/{id}— Update (including yield_percent)DELETE /api/ingredients/{id}— Soft-archive (is_archived=true)GET /api/ingredients/{id}/sources— List all supplier sources with pricesPOST /api/ingredients/{id}/sources— Map a supplier product (requires product_code OR description_pattern)PATCH /api/ingredient-sources/{id}— Update pack/conversion dataDELETE /api/ingredient-sources/{id}— Remove mappingGET /api/ingredients/{id}/flags— List ingredient's flagsPUT /api/ingredients/{id}/flags— Set/update ingredient flags (manual)GET /api/ingredients/suggest?description={text}— Suggest existing ingredient matches for a line item description (uses pg_trgm similarity). Used by the ingredient mapping modal to auto-populate the ingredient dropdown
Auto-price hook (in invoices.py line item save/update):
- When a line item is saved/updated, get supplier_id via
line_item → invoice → supplier_id(line_items don't have direct supplier_id) - Match priority: Try
supplier_id + product_codeexact match against ingredient_sources first. If no product_code on the line item (or no match), trysupplier_id + description_patternnormalised contains-match (lowercase, collapse whitespace, check if pattern is contained in description). Longer patterns match before shorter ones (more specific wins) - If match found: update latest_unit_price, latest_invoice_id, latest_invoice_date, recalculate price_per_std_unit
- Also set
line_item.ingredient_idto the matched ingredient (if not already set)
Flag latching hook (logic in food_flags.py, called from line item flag save endpoints):
- When a
line_item_flagis created/updated ANDline_item.ingredient_idis set:- Auto-create
ingredient_flagfor that ingredient + flag if not already present (source='latched') - Ingredient flags are permanent — latching only adds, never removes
- Auto-create
2c: Ingredient Frontend — /ingredients page
- Searchable/filterable table of all ingredients
- Columns: name, category, standard unit, yield %, sources count, flags (FoodFlagBadges), effective price/unit (yield-adjusted)
- Expandable row: all sources with supplier name, product code/description pattern, pack info, price/std unit, last invoice date
- "Create Ingredient" modal with duplicate detection (on name blur, API call checks pg_trgm similarity, shows warning with similar existing names)
- Filter toggle: "Show unmapped only" (ingredients without any sources)
- Category management: "+Add" button in category filter dropdown (opens inline input)
2d: Ingredient Mapping Modal (replaces inline scales expansion in Review.tsx)
- Keep scales icon with existing colour scheme: red (no data), amber (partial/parsed), green (fully mapped to ingredient)
- Tooltip updates to show ingredient name + conversion info when mapped (e.g., "→ Butter (250g × 10 = 2.5kg @ £4.20/kg)")
- Clicking scales icon opens a modal dialog (replaces the current inline expandable row — the extra fields need more space):
- Auto-populate: Parse line item description to suggest existing ingredient match (via pg_trgm similarity search). Pre-fill pack fields from line item's existing pack_quantity/unit_size/unit_size_type. Load product_definition if exists
- Ingredient mapping section: Searchable dropdown of ingredients. If no match exists, "Create new ingredient" opens inline mini-form (name, category, standard unit) within the modal. Shows current mapping if already mapped
- Pack fields: pack_quantity, unit_size, unit_size_type (editable, auto-filled from OCR/product_definition)
- Conversion display: Shows calculated total_in_standard_unit and price_per_std_unit based on current pack fields + ingredient's standard unit
- Save: Creates/updates the ingredient_source mapping. Sets
line_item.ingredient_id. Checkbox to "Update saved definition" (existing product_definition behavior preserved)
- Existing
portions_per_unit/cost_per_portionfields remain on line_item model for backward compatibility but are de-emphasised in the UI (shown in a collapsible "Legacy Portioning" section within the modal)
Phase 3: Recipe Builder
3a: Recipe Backend
Menu sections:
GET /api/menu-sections— List all sections (for dropdowns, filtered by recipe type in frontend)POST /api/menu-sections— Create section (name)PATCH /api/menu-sections/{id}— Rename/reorderDELETE /api/menu-sections/{id}— Delete (sets recipes to null section)
Recipe endpoints:
GET /api/recipes— List all (filterable by type, menu section, search by name, flag include/exclude, ingredient contains)GET /api/recipes/{id}— Full recipe with ingredients, sub-recipes, steps, images, flags, costingPOST /api/recipes— CreatePATCH /api/recipes/{id}— Update metadata (logs change to recipe_change_log)DELETE /api/recipes/{id}— Soft-archivePOST /api/recipes/{id}/duplicate— Clone recipe with rename prompt. Deep copies: ingredients, steps, images, flags, notes. Sub-recipe references are linked (not deep-copied) — the duplicate shares the same component recipes. User prompted to enter new name (pre-filled with "Original Name (Copy)")
Recipe ingredients:
POST /api/recipes/{id}/ingredients— Add ingredient (logs change)PATCH /api/recipe-ingredients/{id}— Update quantity/notes (logs old→new)DELETE /api/recipe-ingredients/{id}— Remove (logs removal)
Recipe sub-recipes:
POST /api/recipes/{id}/sub-recipes— Add (with circular dependency check via recursive CTE, max 5 levels)PATCH /api/recipe-sub-recipes/{id}— Update portions_neededDELETE /api/recipe-sub-recipes/{id}— Remove
Circular dependency check:
WITH RECURSIVE ancestors AS (
SELECT parent_recipe_id, child_recipe_id, 1 AS depth
FROM recipe_sub_recipes WHERE child_recipe_id = :new_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 = :new_child_id LIMIT 1;
-- If returns a row → would create cycle → reject
Recipe steps:
POST /api/recipes/{id}/steps— Add stepPATCH /api/recipe-steps/{id}— UpdateDELETE /api/recipe-steps/{id}— RemovePATCH /api/recipes/{id}/steps/reorder— Bulk reorder
Recipe images:
POST /api/recipes/{id}/images— Upload (multipart, stored at/app/data/{kitchen_id}/recipes/{uuid}.{ext})GET /api/recipes/{recipe_id}/images/{image_id}— Serve image (authenticated, same pattern as invoice image endpoint)DELETE /api/recipe-images/{id}— Remove file + DB record
Costing:
GET /api/recipes/{id}/costing— Full cost breakdown:- Per ingredient: quantity, unit, yield %, effective price (yield-adjusted), all source prices, min/max
- Per sub-recipe: name, batch_portions, portions_needed, cost per portion, cost contribution
- Totals: recent cost, min cost, max cost, cost per portion
- GP calculator: suggested prices at 60%, 65%, 70% GP targets (plated only)
GET /api/recipes/{id}/costing?scale_to=50— Same but with quantities scaled to target portionsGET /api/recipes/{id}/cost-trend— Cost snapshot history for trend chart- Cost snapshot trigger: Snapshot calculation function lives in
recipes.py. Called by the auto-price hook ininvoices.pywhen an ingredient source price updates — recalculates and snapshots all recipes that use that ingredient. Uses upsert (INSERT ... ON CONFLICT UPDATE) — if today's snapshot exists, update it; otherwise create new
Recipe card (HTML + browser print):
GET /api/recipes/{id}/print?format=full&token={jwt}— Full recipe HTML page (all details, images, costs, flags). Print-optimised with@media printCSS, same pattern as PO preview (_build_po_html()in purchase_orders.py)GET /api/recipes/{id}/print?format=kitchen&token={jwt}— Kitchen card HTML (large font, ingredients, steps, plating photo, flags)- User clicks "Print Recipe" → opens in new tab → browser print dialog (includes "Save as PDF" option)
3b: Recipe Frontend
/recipes page (RecipeList.tsx):
- Card/list view toggle
- Basic filters (always visible): type (component/plated), search by name, menu section dropdown
- Expandable filter panel ("Show Filters" toggle reveals):
- "Contains ingredient" searchable multi-select (find recipes using specific ingredients)
- Flag filters: every flag shown with three-state toggle — neutral (no filter) / must include / must exclude
- Flag filters grouped by category (Allergy section, Dietary section, etc.)
- Cost range filter (min/max cost per portion)
- All filters apply live with debounce on text fields — no "Apply" button needed
- Each card: name, type badge, menu section, batch portions (if component), cost/portion, flag badges
- Quick actions: edit, duplicate, archive
- Stats bar: total recipes, component count, plated count, unmapped ingredients count (links to /ingredients?unmapped=true)
- Menu section management: "+Add Section" (e.g., Starters, Mains, Desserts, Sauces, Bases)
/recipes/:id page (RecipeEditor.tsx):
- Header: Name, type (component/plated), menu section (dropdown, for either type), description, batch_portions (component only), prep/cook time
- Ingredients section:
- Table: ingredient name, quantity, unit, cost (recent/min/max), flag indicators
- "Add ingredient" searchable dropdown from library — or "Create new" inline modal (name, category, standard_unit, optional manual_price, optional first line item search+map)
- Drag-to-reorder via sort_order
- Sub-recipes section:
- Table: recipe name, type, batch size, portions needed, cost contribution
- "Add sub-recipe" dropdown (excludes self + descendants, filtered by recipe search)
- Shows "uses X of Y portions" with cost math inline
- Steps section:
- Ordered list with step number, instruction textarea, optional image upload, optional duration
- Drag-to-reorder, add/remove
- Images section:
- Grid gallery with upload, caption, type tag (method/plating/general)
- Plated recipes show plating photos prominently
- Flags section: Flag summary badges + notification block + expandable flag matrix (see Phase 4)
- Cost summary panel (sticky bottom bar):
- Recent cost | Min cost | Max cost | Cost per portion
- GP comparison table (plated only): suggested sell price at 60%, 65%, 70%
- Expandable ingredient-by-ingredient breakdown with source options
- Scaling calculator (in cost summary panel):
- Input: "Scale to X portions" → recalculates all ingredient quantities and sub-recipe portions for display
- Frontend-only calculation, no schema change — just multiplies quantities by (target_portions / batch_portions)
- Useful for event prep or varying batch sizes
- Cost trend chart (expandable in cost summary):
- Line chart showing cost_per_portion over time (from recipe_cost_snapshots)
- Highlights when/why cost changed (trigger_source label on hover)
- Print recipe button:
- Opens new tab with print-optimised HTML page from backend
- Dropdown: "Full Recipe" or "Kitchen Card"
- Browser print dialog (includes "Save as PDF")
- Includes flag badges and yield-adjusted costs
- Change history (expandable section at bottom):
- Scrollable log: timestamp, user, change summary
- Most recent first
Phase 4: Food Flag System
4a: Flag Management Backend
Settings endpoints:
GET /api/food-flag-categories— List categories with their flagsPOST /api/food-flag-categories— Create category (name, propagation_type)PATCH /api/food-flag-categories/{id}— Update name/propagation_type/sort_orderDELETE /api/food-flag-categories/{id}— Delete (cascade deletes flags)POST /api/food-flags— Create flag within categoryPATCH /api/food-flags/{id}— Update name/code/icon/sort_orderDELETE /api/food-flags/{id}— Delete flag
Ingredient flags:
GET /api/ingredients/{id}/flags— Get all flags for an ingredientPUT /api/ingredients/{id}/flags— Set flags (full replacement: send array of food_flag_ids, source='manual')- Flags are the canonical source for recipe propagation
Line item flags (data entry + latching):
GET /api/line-items/{id}/flags— Get flags for a line itemPUT /api/line-items/{id}/flags— Set flags (full replacement: send array of food_flag_ids)- Latching trigger: For each flag being set, if
line_item.ingredient_idis not null, auto-createingredient_flag(source='latched') if not already present
- Latching trigger: For each flag being set, if
- Icon button on line item row in Review.tsx
Recipe flag propagation logic (computed on-read from ingredient_flags):
For each food_flag_category:
if propagation_type == "contains":
For each recipe_ingredient → get ingredient → get ingredient_flags
Union with flags from ALL sub-recipes (recursive, same logic)
→ Recipe has flag if ANY ingredient has it
if propagation_type == "suitable_for":
For each flag in category:
Check ALL recipe_ingredients → ingredient → ingredient_flags has this flag
AND ALL sub-recipes have this flag (recursive)
→ Recipe has flag only if ALL ingredients have it
→ Ingredients with NO ingredient_flags for this category = "unassessed" (treated as unknown, NOT a match)
Merge with manual additions (source_type="manual" in recipe_flags)
Apply overrides (is_active=false entries from recipe_flags with audit log)
Recipe flag endpoints:
GET /api/recipes/{id}/flags— Full flag state with source tracing per flag + unassessed ingredient listPOST /api/recipes/{id}/flags/{flag_id}/deactivate— Override off (requiresnote, creates audit log)POST /api/recipes/{id}/flags/{flag_id}/reactivate— Undo override (creates audit log)PATCH /api/recipes/{id}/flags/{flag_id}— Toggle excludable_on_request (plated only, requiresnote)POST /api/recipes/{id}/flags/manual— Manually add a flag not auto-detectedGET /api/recipes/{id}/flags/audit-log— Override historyGET /api/recipes/{id}/flags/matrix— Full ingredient × flag matrix data for the flag breakdown table
4b: Flag Frontend
Line item flag button (Review.tsx):
- New icon button alongside scales icon (shield/warning icon)
- Opens modal with flags grouped by category (Allergy section, Dietary section, etc.)
- Checkboxes for each flag
- Icon color: grey = no flags, amber = has allergy flags, green = has dietary flags, both = combined indicator
- When saving: triggers latching to mapped ingredient (if ingredient_id set)
Flag management in Settings page:
- Section for "Food Flag Categories"
- Each category: name, propagation type display ("Contains" / "Suitable For"), expandable flag list
- "+Add Category" button
- Within each category: "+Add Flag" with name, code, icon fields
- Reorder via drag or arrows
Recipe flag notification block (in RecipeEditor.tsx, above flag badges):
- Appears when ingredients have incomplete flag coverage:
⚠️ 3 ingredients are missing allergen details — Lettuce, Mustard, Salt
- Additional line when manual recipe-level flags exist AND there are still unassessed ingredients:
ℹ️ Recipe-level flags have been manually set (may not reflect all ingredients)
- Links each ingredient name to the ingredient's flag editing interface
- Dismisses when all ingredients have been assessed
Recipe flag summary badges (in RecipeEditor.tsx):
- Compact FoodFlagBadges row showing the computed recipe-level flags
- Below the notification block (if present)
- Same badges as on recipe list cards
Recipe flag matrix (RecipeFlagMatrix.tsx — expandable section in RecipeEditor.tsx):
- Table layout: ingredients down the left, food flags as columns
- Columns grouped by category (Allergy columns, then Dietary columns, etc.)
- Direct recipe ingredients shown as regular rows
- Sub-recipe ingredients grouped under a bold header row with the component name:
| Ingredient | Crust. | Eggs | Milk | Gluten | ... | Veg | Vegan | |---------------------|--------|------|------|--------|-----|------|-------| | Brioche Bun | | | | 🔴✓ | | 🟢✓ | 🔴✗ | | Lettuce | ❓ | ❓ | ❓ | ❓ | | ❓ | ❓ | | ▸ Burger Patty | | | | | | | | | ↳ Beef Mince | | | | | | 🔴✗ | 🔴✗ | | ↳ Breadcrumbs | | | | 🔴✓ | | 🟢✓ | 🟢✓ | | ↳ Egg | | 🔴✓ | | | | 🟢✓ | 🔴✗ | | ▸ Burger Sauce | | | | | | | | | ↳ Mayonnaise | | 🔴✓ | | | | 🟢✓ | 🔴✗ | | ↳ Mustard | ❓ | ❓ | ❓ | ❓ | | ❓ | ❓ | | ══ Recipe Total ══ | | 🔴✓ | | 🔴✓ | | 🔴✗ | 🔴✗ | - Colour logic per flag category:
- "Contains" flags (allergens): 🔴 red tick = contains (bad), empty = doesn't contain (good)
- "Suitable for" flags (dietary): 🟢 green tick = qualifies (good), 🔴 red cross = doesn't qualify (bad)
- ❓ Amber question mark = ingredient has NOT been assessed for ANY flags in this category (missing data)
- Recipe total row uses propagation logic:
- Allergens: union (any red tick in column → recipe total is red tick)
- Dietary: intersection (any red cross OR any amber ❓ in column → recipe total is red cross or ❓)
- Overrides shown with strikethrough + hover tooltip showing mandatory note
- "Excludable on request" flags shown with dashed border
- Click on any ingredient row to navigate to that ingredient's flag editor
FoodFlagBadges.tsx (reusable component):
- Compact row of colored badges using flag codes (Gl, Mi, Eg, V, Ve, etc.)
- Color by category (red for allergens, green for dietary, blue for other)
- Tooltip: full name + source trace
- "Excludable" flags: dashed border or different opacity
- Used in: recipe list cards, ingredient rows, line item rows, recipe editor
Phase 5: Dashboard Integration & Internal API
5a: Dashboard Widgets
- Main dashboard (Dashboard.tsx): Small card showing:
- Unmapped ingredients count (links to
/ingredients?unmapped=true) - Recipes without complete costing count
- Links to
/recipesoverview
- Unmapped ingredients count (links to
5b: Recipe Overview Stats (on /recipes page)
- Total recipes / components / plated
- Unmapped ingredients count
- Recipes with incomplete flag coverage
- Recently updated recipes
5c: Internal API for In-House Apps
Authentication: API key in request header (not JWT). Kitchen identified from API key lookup.
X-API-Key: {kitchen_settings.api_key}
Endpoints (prefix /api/external/):
GET /api/external/recipes/plated— List non-archived plated recipes- Query params:
include_ingredients=flat(consolidated ingredient list) |nested(shows sub-recipe ingredient breakdown) |noneinclude_costs=true|false(whether to include cost data — default false)exclude_flags=1,5,7(filter out recipes containing specific flags by ID)
- Returns: id, name, description, menu_section, images, flags (with excludable markers), ingredients (if requested)
- Query params:
GET /api/external/recipes/{id}— Single plated recipe with same query param optionsGET /api/external/food-flags— List all flag categories and flags (for external app to understand flag IDs)
Use case: Menu display app queries plated recipes → selects corresponding recipe for a menu item → reads flags to calculate and display allergen/dietary information.
Settings page: "API Access" section under Settings
- Generate / regenerate API key button
- Toggle API key enabled/disabled
- Copy key to clipboard
- Show when key was last used (optional future enhancement)
Phase 6: Event/Function Ordering
6a: Backend
Endpoints:
GET /api/event-orders— List all event orders (filterable by status, date range)POST /api/event-orders— Create event order (name, event_date, notes)PATCH /api/event-orders/{id}— Update metadata/statusDELETE /api/event-orders/{id}— Delete (DRAFT only)POST /api/event-orders/{id}/items— Add recipe × quantity (both plated and component recipes)PATCH /api/event-order-items/{id}— Update quantityDELETE /api/event-order-items/{id}— RemoveGET /api/event-orders/{id}/shopping-list— Aggregated ingredient shopping list:- Walks all selected recipes (including sub-recipes) × quantities
- Aggregates total quantity needed per ingredient (in standard units, yield-adjusted)
- Groups by ingredient category
- For each ingredient: shows total needed, available sources with pack sizes, suggested packs to order (rounded up)
- Can group by supplier for generating per-supplier order lists
POST /api/event-orders/{id}/generate-po— Optional: auto-generate purchase orders from shopping list (links to existing PO system)
6b: Frontend
/event-orders page (EventOrders.tsx):
- List of event orders with name, date, status, recipe count, estimated total cost
- Create new event order
/event-orders/:id page (EventOrderEditor.tsx):
- Header: Event name, date, status, notes
- Recipe selection:
- Searchable dropdown of all recipes (plated and component, with menu section grouping)
- For component recipes: shows batch_portions for context (e.g., "Burger Sauce — batch of 20 portions")
- Quantity input per recipe (servings for plated, batches for component)
- Shows: recipe name, type badge, cost/portion, quantity, subtotal
- Running total at bottom
- Shopping list view (toggle/tab):
- Aggregated ingredients grouped by category
- Each row: ingredient name, total quantity needed (standard unit), yield-adjusted quantity
- Expandable: which recipes need this ingredient and how much each
- Source info: supplier(s), pack size, suggested packs to order, cost per pack, subtotal
- Group-by-supplier view: generates per-supplier order lists
- "Generate Purchase Orders" button → creates POs in existing system per supplier
- Cost summary: Total ingredient cost, cost per head, GP comparison
Phase 7: KDS Recipe Link
7a: Schema
Already included in main recipes table schema (Phase 1) as kds_menu_item_name VARCHAR(255). No separate migration needed.
7b: Backend (added to existing backend/api/kds.py)
GET /api/kds/recipe-link/{menu_item_name}— Look up linked recipe for a KDS order item- Display recipe summary (plating photo, key steps, flag badges) in a KDS-friendly format
7c: Frontend (KDS page enhancement)
- When a KDS order item has a linked recipe: show small recipe icon
- Tap to view: plating photo, ingredient list, key steps, flag badges
- Useful for new staff or complex dishes
- Lightweight overlay that doesn't disrupt KDS workflow
Implementation Order
| Phase | Scope | Key Deliverables |
|---|---|---|
| 2a | Migration + models | All DB tables, pg_trgm extension, line_item.ingredient_id, pre-seeded data, SQLAlchemy models |
| 2b | Ingredient backend | CRUD endpoints, source mapping (product_code + description_pattern), auto-price hook, flag latching, duplicate detection |
| 2c | Ingredient frontend | /ingredients page, category management, yield %, duplicate warnings, flag display |
| 2d | Ingredient mapping modal | Modal dialog in Review.tsx replacing inline expansion, ingredient_id linking |
| 3a | Recipe backend | CRUD, costing, cycle check, change logging, cost snapshots (upsert), menu sections, print HTML |
| 3b | Recipe frontend | /recipes list + /recipes/:id editor with scaling, cost trend chart, print button |
| 4a | Flag management backend | Flag categories/flags CRUD, ingredient flagging, line item flagging + latching, recipe propagation via ingredient_flags |
| 4b | Flag frontend | Line item flag button, settings management, recipe flag notification block + matrix + badges |
| 5a-c | Dashboard + internal API | Dashboard widgets, recipe stats, API key auth, external endpoints for in-house apps |
| 6a-b | Event ordering backend + frontend | Event orders (plated + component), aggregated shopping list, PO generation |
| 7a-c | KDS recipe link | Link plated recipes to KDS menu items, recipe overlay on KDS |
Verification Plan
- Phase 2a: Run migration → verify all tables + pre-seeded data via
psql. Verify pg_trgm extension active. Verify line_items.ingredient_id column exists - Phase 2b: Create ingredients via API, map line items as sources → verify unit conversion + yield-adjusted price. Test duplicate detection via pg_trgm on similar names. Process a new invoice → verify auto-price update (supplier_id resolved via invoice join). Test description_pattern matching for no-SKU suppliers. Verify ingredient_id set on matched line items
- Phase 2c-d: Create ingredient from
/ingredientspage with yield %. Open ingredient mapping modal on a line item → verify auto-populate from description. Map to ingredient → verify source created with correct conversion and line_item.ingredient_id set - Phase 3: Create "Burger Patty" component in "Preparations" section (batch: 4 portions). Create "Beef Burger" plated in "Mains" section using 1 portion of Burger Patty + bun → verify cost = (1/4 × patty total) + bun cost. Test scaling calculator at different portion counts. Verify cost trend chart after ingredient price changes (upsert for same-day updates). Print recipe card via HTML preview
- Phase 4: Flag ingredient "Butter" with "Contains: Milk". Flag a line item → verify latching creates ingredient_flag. Open recipe flag matrix → verify "Contains: Milk" propagates from Butter via any-match. Verify "suitable_for" propagates via all-must-match. Verify amber ❓ shows for unassessed ingredients. Test override with mandatory note + audit log. Test excludable_on_request on plated. Verify notification block shows missing flag count
- Phase 5: Dashboard widget shows unmapped count. Generate API key in Settings. Use API key to query
/api/external/recipes/plated→ verify returns recipes with flags. Testinclude_ingredientsandinclude_costsquery params - Phase 6: Create event order for "Wedding Reception", add 50× Beef Burger (plated) + 3× Burger Sauce (component, 20-portion batch) → verify aggregated shopping list totals ingredients correctly across recipes. Test suggested packs calculation. Generate POs per supplier
- Phase 7: Link "Beef Burger" recipe to KDS menu item. Verify recipe overlay appears on KDS when that item is ordered
Key Design Decisions
- Standard units: g, kg, ml, ltr, each — chefs choose the appropriate standard per ingredient (saffron in g, beef in kg)
- Yield percentage on ingredients adjusts effective cost for waste/trim (e.g., 85% yield carrots, 65% whole chicken)
- Most recent purchase price used as default recipe cost (not pinned suppliers) — auto-updates as new invoices are processed. Min/max show the range across all sources
- ingredient_sources coexist with product_definitions — existing portioning works unchanged. Ingredient mapping is additive
- Dual matching: product_code first, then description_pattern — supports both SKU-based and description-based suppliers. Same priority pattern as existing product_definitions
- ingredient_id FK on line_items — direct link from line item to ingredient, simplifies queries and enables flag latching
- ingredient_flags as canonical flag source — flags live on ingredients, not just line items. Line item flags are a data-entry mechanism that auto-latches to ingredients. Recipe propagation reads from ingredient_flags
- Flag latching — when a line item is flagged, the system auto-creates a permanent ingredient_flag. Flags only accumulate, never auto-remove. Manual removal by user only
- Food flags computed on-read — always fresh from ingredient_flags, no cache invalidation needed.
recipe_flagstable only stores manual additions + override state - Propagation type per category — "contains" (allergens, any-match) vs "suitable_for" (dietary, all-must-match) enables correct semantics for both flag types
- Unassessed ingredients shown as amber ❓ — clearly distinguishes "not yet assessed" from "assessed as clean", prevents false negatives in dietary flags
- Flag matrix with grouped sub-recipe ingredients — full ingredient × flag breakdown with component grouping headers, colour-coded by flag type
- Notification block for incomplete flag coverage — warns when ingredients are missing allergen details, notes when manual recipe flags are a stopgap
- Duplicate ingredient detection — pg_trgm trigram similarity (PostgreSQL extension) for fuzzy name matching, threshold > 0.3
- Max 5 levels sub-recipe nesting — enforced via recursive CTE depth check
- Batch portions on components only — plated recipes always represent 1 serving
- Menu sections for both recipe types — Starters/Mains/Desserts for plated, Sauces/Bases/Preparations for components. Shared table, filtered by recipe type in UI
- Recipe scaling calculator — frontend-only, multiplies quantities by target/batch ratio for display
- Cost trend snapshots with upsert — daily snapshots triggered by ingredient price changes. Multiple updates on same day upsert to latest values
- Recipe cards via HTML + browser print — follows existing PO preview pattern (
_build_po_html()). Two formats: full detail and kitchen card. No new PDF library needed - Recipe image serving — authenticated endpoint
GET /api/recipes/{id}/images/{image_id}, same pattern as invoice image endpoints. Stored at/app/data/{kitchen_id}/recipes/{uuid}.{ext}on existing Docker volume - Event ordering supports both recipe types — plated (servings) and component (batches) can be added to event orders
- Internal API with API key auth —
/api/external/prefix, X-API-Key header, for in-house apps (e.g., menu display plugin querying recipes for allergen calculation). Not publicly unauthenticated - API key management in Settings — generate/regenerate, enable/disable toggle, per-kitchen
- KDS recipe link — matches plated recipes to KDS menu items for quick recipe/plating reference
- Change history as summary strings — single log entry per save with old→new field values
- Recipe images on local Docker volume at
/app/data/{kitchen_id}/recipes/— backed up via existing Nextcloud - Any user can create/edit recipes — uses existing auth, no new roles needed
- Inline ingredient creation from both mapping modal and recipe editor — with duplicate detection and ability to search+map a line item source or set manual placeholder price
- Recipe duplication copies top-level content (ingredients, steps, images, flags) but links sub-recipes (not deep-copied)
- Flag filters offer both include AND exclude for every flag — three-state toggle (neutral/include/exclude)
- Live filtering on recipe list with debounce — no "Apply" button
- New "Recipes" dropdown in top header nav — matches existing Invoices/Bookings/Reports dropdown pattern, separate from invoice navigation
- Ingredient mapping modal replaces inline expansion — more space for ingredient search, pack fields, and conversion display
- Scales icon colours preserved — red/amber/green as before, tooltip shows ingredient name when mapped
- Supplier_id resolved via invoice join — line_items don't have direct supplier_id, the auto-price hook joins through invoice.supplier_id