Freeze an Order Item's Recipe at order time

Price was already snapshotted per Order Item so later menu changes
don't rewrite history, but the Recipe (Step list) wasn't: advancing an
Order Item looked up its next Step from the live menuItemSteps table,
so an operator editing a Menu Item's Recipe mid-cook could silently
reshape an Order already in flight (different Machine Tag, duration,
or Step order than what the customer paid for).

Adds order_item_steps as a per-Order-Item copy of the Recipe, taken at
placement time; placeOrder, advanceOrderItemStep, and getOrderStatus
now all read from that snapshot instead of the live menu. Recorded as
docs/adr/0005 and added Recipe as a formal CONTEXT.md term.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 19:55:05 +02:00
co-authored by Claude Sonnet 5
parent d291312139
commit 9c3ad51686
8 changed files with 732 additions and 43 deletions
+54 -43
View File
@@ -170,31 +170,48 @@ export const assignQueuedStepsForTag = async (
}
}
const createFirstStepForOrderItem = async (
// Copies the Menu Item's live Recipe into orderItemSteps for this Order
// Item, then creates its first Preparation Step instance from that copy —
// so a later edit to the live Recipe never reshapes an Order Item already
// in flight (see docs/adr/0005-recipe-snapshot-per-order-item.md).
const initializeOrderItemRecipe = async (
db: DbOrTx,
orderItemId: number,
menuItemId: number,
) => {
const firstStep = firstRow(
await db
.select()
.from(schema.menuItemSteps)
.where(
and(
eq(schema.menuItemSteps.menuItemId, menuItemId),
eq(schema.menuItemSteps.sequence, 1),
),
),
)
const liveSteps = await db
.select()
.from(schema.menuItemSteps)
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
.orderBy(asc(schema.menuItemSteps.sequence))
// A menu item with no Preparation Steps at all is a data integrity bug
// (see spec: every menu item's Recipe has at least one Step), not a valid
// "nothing to do" case — surfacing it loudly here beats leaving the Order
// Item permanently stuck with no Step and no way to progress or cancel.
if (!firstStep) {
if (liveSteps.length === 0) {
throw new Error(`Menu item ${menuItemId} has no Preparation Steps defined`)
}
const recipeSteps = await db
.insert(schema.orderItemSteps)
.values(
liveSteps.map((step) => ({
orderItemId,
sequence: step.sequence,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
.returning()
const firstStep = recipeSteps.find((step) => step.sequence === 1)
if (!firstStep) {
throw new Error(
`Menu item ${menuItemId} has no Preparation Step with sequence 1`,
)
}
const [instance] = await db
.insert(schema.preparationStepInstances)
.values({
@@ -304,7 +321,7 @@ export const placeOrder = async (
})
.returning()
const step = await createFirstStepForOrderItem(
const step = await initializeOrderItemRecipe(
tx,
orderItem.id,
menuItem.id,
@@ -461,22 +478,14 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
.set({ status: 'done', doneAt: new Date() })
.where(eq(schema.preparationStepInstances.id, currentStep.id))
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
const nextStepDefinition = firstRow(
await tx
.select()
.from(schema.menuItemSteps)
.from(schema.orderItemSteps)
.where(
and(
eq(schema.menuItemSteps.menuItemId, orderItem.menuItemId),
eq(schema.menuItemSteps.sequence, currentStep.sequence + 1),
eq(schema.orderItemSteps.orderItemId, orderItemId),
eq(schema.orderItemSteps.sequence, currentStep.sequence + 1),
),
),
)
@@ -579,21 +588,25 @@ export const getOrderStatus = async (
).map((row) => [row.id, row]),
)
const recipeRows = await db
.select({
menuItemId: schema.menuItemSteps.menuItemId,
sequence: schema.menuItemSteps.sequence,
machineTag: schema.machineTags.name,
machineTagId: schema.menuItemSteps.machineTagId,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
})
.from(schema.menuItemSteps)
.innerJoin(
schema.machineTags,
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
)
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds))
.orderBy(asc(schema.menuItemSteps.sequence))
const recipeRows =
orderItemIds.length === 0
? []
: await db
.select({
orderItemId: schema.orderItemSteps.orderItemId,
sequence: schema.orderItemSteps.sequence,
machineTag: schema.machineTags.name,
machineTagId: schema.orderItemSteps.machineTagId,
estimatedDurationSeconds:
schema.orderItemSteps.estimatedDurationSeconds,
})
.from(schema.orderItemSteps)
.innerJoin(
schema.machineTags,
eq(schema.orderItemSteps.machineTagId, schema.machineTags.id),
)
.where(inArray(schema.orderItemSteps.orderItemId, orderItemIds))
.orderBy(asc(schema.orderItemSteps.sequence))
const allInstances =
orderItemIds.length === 0
@@ -652,9 +665,7 @@ export const getOrderStatus = async (
const itemViews: Array<OrderItemStatusView> = items.map((item) => {
const menuItem = menuItemsById.get(item.menuItemId)
const recipe = recipeRows.filter(
(step) => step.menuItemId === item.menuItemId,
)
const recipe = recipeRows.filter((step) => step.orderItemId === item.id)
const instances = instancesByOrderItem.get(item.id) ?? []
const instanceBySequence = new Map(
instances.map((row) => [row.sequence, row]),