Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)

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>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

694
backend/api/event_orders.py Normal file
View file

@ -0,0 +1,694 @@
"""
Event Order API create event orders, add recipes × quantities,
generate aggregated shopping lists, and optionally create purchase orders.
"""
import logging
from datetime import date
from decimal import Decimal
from typing import Optional
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from database import get_db
from models.user import User
from models.event_order import EventOrder, EventOrderItem
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe
from models.ingredient import Ingredient, IngredientSource, IngredientCategory
from models.supplier import Supplier
from auth import get_current_user, require_cap
from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic schemas ─────────────────────────────────────────────────────────
class EventOrderCreate(BaseModel):
name: str
event_date: Optional[date] = None
notes: Optional[str] = None
class EventOrderUpdate(BaseModel):
name: Optional[str] = None
event_date: Optional[date] = None
notes: Optional[str] = None
status: Optional[str] = None
class EventOrderItemAdd(BaseModel):
recipe_id: int
quantity: int
notes: Optional[str] = None
sort_order: int = 0
class EventOrderItemUpdate(BaseModel):
quantity: Optional[int] = None
notes: Optional[str] = None
sort_order: Optional[int] = None
class BulkItemEntry(BaseModel):
recipe_id: int
quantity: int
notes: Optional[str] = None
class EventOrderBulkAdd(BaseModel):
items: list[BulkItemEntry]
class EventOrderResponse(BaseModel):
id: int
name: str
event_date: Optional[str] = None
notes: Optional[str] = None
status: str
item_count: int = 0
estimated_cost: Optional[float] = None
created_at: str = ""
updated_at: str = ""
class EventOrderItemResponse(BaseModel):
id: int
recipe_id: int
recipe_name: str = ""
recipe_type: str = ""
batch_portions: int = 1
quantity: int
cost_per_portion: Optional[float] = None
subtotal: Optional[float] = None
notes: Optional[str] = None
sort_order: int = 0
# ── Event Order CRUD ─────────────────────────────────────────────────────────
@router.get("")
async def list_event_orders(
status: Optional[str] = Query(None),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(EventOrder)
.options(selectinload(EventOrder.items))
.where(EventOrder.kitchen_id == user.kitchen_id)
)
if status:
query = query.where(EventOrder.status == status)
result = await db.execute(query.order_by(EventOrder.event_date.desc().nullslast(), EventOrder.created_at.desc()))
orders = result.scalars().all()
responses = []
for o in orders:
responses.append(EventOrderResponse(
id=o.id,
name=o.name,
event_date=str(o.event_date) if o.event_date else None,
notes=o.notes,
status=o.status,
item_count=len(o.items) if o.items else 0,
created_at=str(o.created_at) if o.created_at else "",
updated_at=str(o.updated_at) if o.updated_at else "",
))
return responses
@router.post("")
async def create_event_order(
data: EventOrderCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
order = EventOrder(
kitchen_id=user.kitchen_id,
name=data.name,
event_date=data.event_date,
notes=data.notes,
created_by=user.id,
)
db.add(order)
await db.commit()
await db.refresh(order)
return {"id": order.id, "name": order.name}
@router.get("/{order_id}")
async def get_event_order(
order_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(EventOrder)
.options(selectinload(EventOrder.items).selectinload(EventOrderItem.recipe))
.where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, "Event order not found")
items = []
for item in sorted(order.items, key=lambda x: x.sort_order):
recipe = item.recipe
# Get cost per portion from latest snapshot
from models.recipe import RecipeCostSnapshot
snap_result = await db.execute(
select(RecipeCostSnapshot)
.where(RecipeCostSnapshot.recipe_id == item.recipe_id)
.order_by(RecipeCostSnapshot.snapshot_date.desc())
.limit(1)
)
snap = snap_result.scalar_one_or_none()
cpp = float(snap.cost_per_portion) if snap else None
subtotal = cpp * item.quantity if cpp else None
items.append(EventOrderItemResponse(
id=item.id,
recipe_id=item.recipe_id,
recipe_name=recipe.name if recipe else "",
recipe_type=recipe.recipe_type if recipe else "",
batch_portions=recipe.batch_portions if recipe else 1,
quantity=item.quantity,
cost_per_portion=cpp,
subtotal=round(subtotal, 2) if subtotal else None,
notes=item.notes,
sort_order=item.sort_order,
))
return {
"id": order.id,
"name": order.name,
"event_date": str(order.event_date) if order.event_date else None,
"notes": order.notes,
"status": order.status,
"items": [i.model_dump() for i in items],
"created_at": str(order.created_at) if order.created_at else "",
"updated_at": str(order.updated_at) if order.updated_at else "",
}
@router.patch("/{order_id}")
async def update_event_order(
order_id: int,
data: EventOrderUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, "Event order not found")
if data.name is not None:
order.name = data.name
if data.event_date is not None:
order.event_date = data.event_date
if data.notes is not None:
order.notes = data.notes
if data.status is not None:
order.status = data.status
await db.commit()
return {"ok": True}
@router.delete("/{order_id}")
async def delete_event_order(
order_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, "Event order not found")
if order.status != "DRAFT":
raise HTTPException(400, "Only DRAFT orders can be deleted")
await db.delete(order)
await db.commit()
return {"ok": True}
# ── Event Order Items ────────────────────────────────────────────────────────
@router.post("/{order_id}/items")
async def add_item(
order_id: int,
data: EventOrderItemAdd,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
order = await _get_order(order_id, user.kitchen_id, db)
recipe = await db.execute(
select(Recipe).where(Recipe.id == data.recipe_id, Recipe.kitchen_id == user.kitchen_id)
)
if not recipe.scalar_one_or_none():
raise HTTPException(404, "Recipe not found")
item = EventOrderItem(
event_order_id=order_id,
recipe_id=data.recipe_id,
quantity=data.quantity,
notes=data.notes,
sort_order=data.sort_order,
)
db.add(item)
await db.commit()
await db.refresh(item)
return {"id": item.id}
@router.post("/{order_id}/items/bulk")
async def add_items_bulk(
order_id: int,
data: EventOrderBulkAdd,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Add multiple recipes to an event order at once (e.g. from a menu)."""
order = await _get_order(order_id, user.kitchen_id, db)
# Get existing recipe_ids on this order to detect duplicates
existing_result = await db.execute(
select(EventOrderItem.recipe_id).where(EventOrderItem.event_order_id == order_id)
)
existing_ids = {r[0] for r in existing_result.all()}
# Get max sort_order
max_sort_result = await db.execute(
select(func.coalesce(func.max(EventOrderItem.sort_order), -1))
.where(EventOrderItem.event_order_id == order_id)
)
next_sort = (max_sort_result.scalar() or 0) + 1
# Validate all recipe_ids belong to this kitchen
recipe_ids = [entry.recipe_id for entry in data.items]
valid_result = await db.execute(
select(Recipe.id).where(Recipe.id.in_(recipe_ids), Recipe.kitchen_id == user.kitchen_id)
)
valid_ids = {r[0] for r in valid_result.all()}
added = 0
skipped = 0
for entry in data.items:
if entry.recipe_id not in valid_ids:
skipped += 1
continue
if entry.recipe_id in existing_ids:
skipped += 1
continue
item = EventOrderItem(
event_order_id=order_id,
recipe_id=entry.recipe_id,
quantity=entry.quantity,
notes=entry.notes,
sort_order=next_sort,
)
db.add(item)
existing_ids.add(entry.recipe_id)
next_sort += 1
added += 1
await db.commit()
return {"added": added, "skipped": skipped}
@router.patch("/items/{item_id}")
async def update_item(
item_id: int,
data: EventOrderItemUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Item not found")
if data.quantity is not None:
item.quantity = data.quantity
if data.notes is not None:
item.notes = data.notes
if data.sort_order is not None:
item.sort_order = data.sort_order
await db.commit()
return {"ok": True}
@router.delete("/items/{item_id}")
async def delete_item(
item_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Item not found")
await db.delete(item)
await db.commit()
return {"ok": True}
# ── Shopping List ────────────────────────────────────────────────────────────
async def _collect_ingredients_for_recipe(
recipe_id: int,
multiplier: float,
db: AsyncSession,
depth: int = 0,
) -> dict[int, float]:
"""Recursively collect ingredient quantities for a recipe × multiplier.
Returns {ingredient_id: total_quantity_in_standard_unit}."""
if depth > 5:
return {}
result: dict[int, float] = defaultdict(float)
# Direct ingredients (yield-adjusted: divide by yield to get required purchase qty)
ri_result = await db.execute(
select(RecipeIngredient)
.options(selectinload(RecipeIngredient.ingredient))
.where(RecipeIngredient.recipe_id == recipe_id)
)
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
for ri in ri_result.scalars().all():
yld = float(ri.yield_percent) if ri.yield_percent else 100.0
raw_qty = float(ri.quantity) * multiplier
# Convert from display unit to standard unit if needed
if ri.unit and ri.ingredient and ri.unit != ri.ingredient.standard_unit:
from_base = _bases.get(ri.unit)
to_base = _bases.get(ri.ingredient.standard_unit)
if from_base and to_base:
raw_qty = raw_qty * from_base / to_base
adjusted_qty = raw_qty / (yld / 100) if yld > 0 else raw_qty
result[ri.ingredient_id] += adjusted_qty
# Sub-recipe ingredients
sr_result = await db.execute(
select(RecipeSubRecipe).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
)
for sr in sr_result.scalars().all():
child_result = await db.execute(
select(Recipe).where(Recipe.id == sr.child_recipe_id)
)
child_recipe = child_result.scalar_one_or_none()
if not child_recipe:
continue
# Use unified output qty (handles both portioned and bulk)
child_output_qty = float(child_recipe.batch_yield_qty) if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_qty else (child_recipe.batch_portions or 1)
child_output_unit = child_recipe.batch_yield_unit if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_unit else "portion"
# Convert portions_needed to child output unit if different unit was used
needed = float(sr.portions_needed)
needed_unit = sr.portions_needed_unit or child_output_unit
if needed_unit != child_output_unit:
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
if needed_unit in _bases and child_output_unit in _bases:
needed = needed * _bases[needed_unit] / _bases[child_output_unit]
child_multiplier = multiplier * (needed / child_output_qty)
child_ings = await _collect_ingredients_for_recipe(sr.child_recipe_id, child_multiplier, db, depth + 1)
for ing_id, qty in child_ings.items():
result[ing_id] += qty
return dict(result)
@router.get("/{order_id}/shopping-list")
async def get_shopping_list(
order_id: int,
group_by_supplier: bool = Query(False),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Aggregated ingredient shopping list across all event order items."""
order = await _get_order(order_id, user.kitchen_id, db)
# Get all items
items_result = await db.execute(
select(EventOrderItem)
.options(selectinload(EventOrderItem.recipe))
.where(EventOrderItem.event_order_id == order_id)
)
items = items_result.scalars().all()
# Aggregate ingredient quantities across all recipes
total_ingredients: dict[int, float] = defaultdict(float)
recipe_breakdown: dict[int, list] = defaultdict(list) # ingredient_id -> [{recipe, qty}]
for item in items:
recipe = item.recipe
if not recipe:
continue
# For dish: multiplier = quantity (servings) / output_qty
# For component: multiplier = quantity (batches)
if recipe.recipe_type == "component":
multiplier = float(item.quantity) # each item.quantity = number of batches
else:
# Dishes are always portioned with batch_portions=1
multiplier = float(item.quantity) / (recipe.batch_portions or 1)
ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
for ing_id, qty in ings.items():
total_ingredients[ing_id] += qty
recipe_breakdown[ing_id].append({
"recipe_name": recipe.name,
"quantity": round(qty, 3),
})
if not total_ingredients:
return {"items": [], "by_supplier": {}}
# Load ingredient details and sources
ing_ids = list(total_ingredients.keys())
ing_result = await db.execute(
select(Ingredient)
.options(
selectinload(Ingredient.category),
selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
)
.where(Ingredient.id.in_(ing_ids))
)
ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
shopping_items = []
by_supplier: dict[str, list] = defaultdict(list)
for ing_id, total_qty in sorted(total_ingredients.items(), key=lambda x: x[0]):
ing = ingredients.get(ing_id)
if not ing:
continue
# Quantity already yield-adjusted during collection
adjusted_qty = total_qty
# Source info
sources = []
for src in (ing.sources or []):
pack_total = None
suggested_packs = None
cost_per_pack = None
if src.pack_quantity and src.unit_size and src.unit_size_type:
pack_in_std = convert_to_standard(
Decimal(str(src.pack_quantity)) * src.unit_size,
src.unit_size_type,
ing.standard_unit,
)
if pack_in_std and float(pack_in_std) > 0:
pack_total = float(pack_in_std)
suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
if src.latest_unit_price:
cost_per_pack = float(src.latest_unit_price)
source_info = {
"supplier_id": src.supplier_id,
"supplier_name": src.supplier.name if src.supplier else "",
"product_code": src.product_code,
"pack_description": f"{src.pack_quantity}×{src.unit_size}{src.unit_size_type}" if src.pack_quantity and src.unit_size else None,
"pack_total_std_unit": pack_total,
"suggested_packs": suggested_packs,
"cost_per_pack": cost_per_pack,
"subtotal": round(cost_per_pack * suggested_packs, 2) if cost_per_pack and suggested_packs else None,
}
sources.append(source_info)
if group_by_supplier:
supplier_name = src.supplier.name if src.supplier else "Unknown"
by_supplier[supplier_name].append({
"ingredient_name": ing.name,
"quantity_needed": round(adjusted_qty, 3),
"unit": ing.standard_unit,
**source_info,
})
item_data = {
"ingredient_id": ing.id,
"ingredient_name": ing.name,
"category": ing.category.name if ing.category else "Other",
"total_quantity": round(total_qty, 3),
"adjusted_quantity": round(adjusted_qty, 3),
"unit": ing.standard_unit,
"sources": sources,
"recipe_breakdown": recipe_breakdown.get(ing_id, []),
}
shopping_items.append(item_data)
# Sort by category
shopping_items.sort(key=lambda x: (x["category"], x["ingredient_name"]))
return {"items": shopping_items, "by_supplier": dict(by_supplier) if group_by_supplier else {}}
# ── Generate Purchase Orders ─────────────────────────────────────────────────
@router.post("/{order_id}/generate-po")
async def generate_purchase_orders(
order_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate purchase orders from shopping list, grouped by supplier."""
from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
order = await _get_order(order_id, user.kitchen_id, db)
# Get shopping list grouped by supplier
items_result = await db.execute(
select(EventOrderItem)
.options(selectinload(EventOrderItem.recipe))
.where(EventOrderItem.event_order_id == order_id)
)
items = items_result.scalars().all()
# Aggregate ingredient quantities
total_ingredients: dict[int, float] = defaultdict(float)
for item in items:
recipe = item.recipe
if not recipe:
continue
if recipe.recipe_type == "component":
multiplier = float(item.quantity)
else:
multiplier = float(item.quantity) / (recipe.batch_portions or 1)
ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
for ing_id, qty in ings.items():
total_ingredients[ing_id] += qty
if not total_ingredients:
raise HTTPException(400, "No ingredients to order")
# Load ingredients with sources
from sqlalchemy.orm import selectinload as si
ing_result = await db.execute(
select(Ingredient)
.options(
selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
)
.where(Ingredient.id.in_(list(total_ingredients.keys())))
)
ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
# Group by supplier: {supplier_id: [{ingredient, qty, source}]}
supplier_lines: dict[int, list] = defaultdict(list)
unmapped = []
for ing_id, total_qty in total_ingredients.items():
ing = ingredients.get(ing_id)
if not ing:
continue
# Quantity already yield-adjusted during collection
adjusted_qty = total_qty
# Pick the most recent source (by latest_invoice_date)
best_source = None
for src in (ing.sources or []):
if best_source is None or (src.latest_invoice_date and (
not best_source.latest_invoice_date or src.latest_invoice_date > best_source.latest_invoice_date
)):
best_source = src
if best_source and best_source.supplier_id:
pack_total = None
suggested_packs = 1
if best_source.pack_quantity and best_source.unit_size and best_source.unit_size_type:
pack_in_std = convert_to_standard(
Decimal(str(best_source.pack_quantity)) * best_source.unit_size,
best_source.unit_size_type,
ing.standard_unit,
)
if pack_in_std and float(pack_in_std) > 0:
pack_total = float(pack_in_std)
suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
supplier_lines[best_source.supplier_id].append({
"ingredient": ing,
"source": best_source,
"quantity": max(suggested_packs, 1),
"unit_price": float(best_source.latest_unit_price) if best_source.latest_unit_price else 0,
})
else:
unmapped.append(ing.name)
# Create one PO per supplier
created_pos = []
for supplier_id, lines in supplier_lines.items():
total = sum(l["quantity"] * l["unit_price"] for l in lines)
po = PurchaseOrder(
kitchen_id=user.kitchen_id,
supplier_id=supplier_id,
order_date=order.event_date or date.today(),
order_type="itemised",
status="DRAFT",
total_amount=Decimal(str(round(total, 2))),
notes=f"Auto-generated from event order: {order.name}",
created_by=user.id,
)
db.add(po)
await db.flush()
for idx, line in enumerate(lines):
line_total = round(line["quantity"] * line["unit_price"], 2)
po_line = PurchaseOrderLineItem(
purchase_order_id=po.id,
kitchen_id=user.kitchen_id,
product_code=line["source"].product_code,
description=line["ingredient"].name,
unit=line["ingredient"].standard_unit,
unit_price=Decimal(str(line["unit_price"])),
quantity=Decimal(str(line["quantity"])),
total=Decimal(str(line_total)),
line_number=idx + 1,
source="event_order",
)
db.add(po_line)
created_pos.append({"id": po.id, "supplier_id": supplier_id})
await db.commit()
return {
"created": len(created_pos),
"purchase_orders": created_pos,
"unmapped_ingredients": unmapped,
}
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _get_order(order_id: int, kitchen_id: int, db: AsyncSession) -> EventOrder:
result = await db.execute(
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == kitchen_id)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, "Event order not found")
return order