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:
@@ -102,6 +102,22 @@ export const orderItems = pgTable('order_items', {
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
// A frozen copy of a Menu Item's Recipe as it existed when a given Order
|
||||
// Item was placed. A later edit to the live Recipe (menuItemSteps) never
|
||||
// reshapes an Order Item already in flight — see
|
||||
// docs/adr/0005-recipe-snapshot-per-order-item.md.
|
||||
export const orderItemSteps = pgTable('order_item_steps', {
|
||||
id: serial().primaryKey(),
|
||||
orderItemId: integer('order_item_id')
|
||||
.notNull()
|
||||
.references(() => orderItems.id),
|
||||
sequence: integer().notNull(),
|
||||
machineTagId: integer('machine_tag_id')
|
||||
.notNull()
|
||||
.references(() => machineTags.id),
|
||||
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
|
||||
})
|
||||
|
||||
export const preparationStepStatusValues = [
|
||||
'queued',
|
||||
'in_progress',
|
||||
|
||||
+54
-43
@@ -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]),
|
||||
|
||||
Reference in New Issue
Block a user