fix(machine): decrement cassettes by position, not denomination, on cash-out
recordTransaction() updated cassette counts with WHERE denomination = ?, but the v9 migration made position the PK precisely so duplicate denominations across bays are legal (and the HAL dispense path already returns authoritative per-position results). On any machine with two bays of the same denomination, a single dispense drained every matching bay row — silently corrupting inventory, the operator cassette-state publish, and out-of-money gating. - cassettes branch: decrement by c.position - mock-only fallback (no per-bay results): drain matching bays greedily in position order, mirroring the dispenser's own fill order - regression tests with a duplicate-denomination layout (3 of 5 fail against the old code) Found during the dev-branch architecture review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
86b35b5415
commit
79e1823cc5
2 changed files with 195 additions and 6 deletions
|
|
@ -982,8 +982,11 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
const insertCassetteBill = db.prepare(
|
||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
const updateCassette = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
||||
const updateCassetteByPosition = db.prepare(
|
||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
|
||||
)
|
||||
const selectBaysByDenom = db.prepare(
|
||||
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
|
||||
)
|
||||
const updateCashboxStmt = db.prepare(
|
||||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
||||
|
|
@ -1024,17 +1027,33 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
}
|
||||
|
||||
if (t.type === 'cash_out') {
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested)
|
||||
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||||
// Position is the addressable unit (v9): duplicate denominations
|
||||
// across bays are legal, so a denomination-keyed UPDATE would
|
||||
// decrement every matching bay.
|
||||
if (t.cassettes) {
|
||||
for (const c of t.cassettes) {
|
||||
if (c.dispensed > 0) {
|
||||
updateCassette.run(-c.dispensed, c.denomination)
|
||||
updateCassetteByPosition.run(-c.dispensed, c.position)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use bill counts (backward compat for mocks without cassette data)
|
||||
// Fallback: per-denomination bill counts (mocks without per-bay
|
||||
// results). Drain matching bays greedily in position order —
|
||||
// the dispenser's own fill order.
|
||||
for (const bill of t.bills) {
|
||||
updateCassette.run(-bill.count, bill.denomination)
|
||||
let remaining = bill.count
|
||||
const bays = selectBaysByDenom.all(bill.denomination) as {
|
||||
position: number
|
||||
count: number
|
||||
}[]
|
||||
for (const bay of bays) {
|
||||
if (remaining <= 0) break
|
||||
const take = Math.min(remaining, bay.count)
|
||||
if (take <= 0) continue
|
||||
updateCassetteByPosition.run(-take, bay.position)
|
||||
remaining -= take
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue