fix(machine): align dispense error handling with legacy brain.js
dispenseCash now always resolves with a DispenseCashResult (per-bill
dispensed/rejected counts, overall success flag, optional error) instead
of throwing. dispenseError is a 30s timed state that auto-returns to
idle, matching brain.js _timedState('outOfCash'). The dead-end retry
loop (which the UI never exposed) is removed.
The Vue dispenseError screen now shows partial dispense info, the
transaction ID as a QR code, and a 30s countdown.
Closes #30
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
143ae9ff0b
commit
e51f462876
8 changed files with 303 additions and 24 deletions
|
|
@ -126,14 +126,22 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
for (const { denomination, count } of amounts) {
|
||||
const idx = cassetteDenominations.indexOf(denomination)
|
||||
if (idx === -1) {
|
||||
throw new Error(`No cassette loaded with denomination: ${denomination}`)
|
||||
return {
|
||||
bills: amounts.map((a) => ({
|
||||
denomination: a.denomination,
|
||||
dispensed: 0,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispensed: false,
|
||||
error: `No cassette loaded with denomination: ${denomination}`,
|
||||
}
|
||||
}
|
||||
notes[idx] = count
|
||||
}
|
||||
|
||||
const result = await dispenser.dispense(notes)
|
||||
|
||||
// Update inventory
|
||||
// Update inventory based on what was ACTUALLY dispensed
|
||||
for (let i = 0; i < result.value.length; i++) {
|
||||
const denom = cassetteDenominations[i]
|
||||
if (denom !== undefined && inventory[denom] !== undefined) {
|
||||
|
|
@ -141,13 +149,27 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
|||
}
|
||||
}
|
||||
|
||||
// Build per-denomination result
|
||||
const bills = amounts.map((a, i) => ({
|
||||
denomination: a.denomination,
|
||||
dispensed: result.value[i]?.dispensed ?? 0,
|
||||
rejected: result.value[i]?.rejected ?? 0,
|
||||
}))
|
||||
|
||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
return { bills, dispensed: false, error: result.error.message }
|
||||
}
|
||||
|
||||
// Wait for customer to take the bills
|
||||
await dispenser.waitForBillsRemoved()
|
||||
console.log('[HAL] Bills removed by customer')
|
||||
// Wait for customer to take bills (only if bills were dispensed)
|
||||
if (totalDispensed > 0) {
|
||||
await dispenser.waitForBillsRemoved()
|
||||
console.log('[HAL] Bills removed by customer')
|
||||
}
|
||||
|
||||
return { bills, dispensed: totalRequested === totalDispensed }
|
||||
},
|
||||
|
||||
getInventory: async () => {
|
||||
|
|
|
|||
|
|
@ -1171,7 +1171,7 @@ function createATMServices(
|
|||
/**
|
||||
* Dispense cash (mock for development)
|
||||
*/
|
||||
dispenseCash: async (amounts: { denomination: number; count: number }[]): Promise<void> => {
|
||||
dispenseCash: async (amounts) => {
|
||||
console.log('[ATM Service] Dispensing cash:', amounts)
|
||||
|
||||
// In production, this would interface with the Rust HAL
|
||||
|
|
@ -1179,6 +1179,14 @@ function createATMServices(
|
|||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
console.log('[ATM Service] Cash dispensed successfully')
|
||||
return {
|
||||
bills: amounts.map((a) => ({
|
||||
denomination: a.denomination,
|
||||
dispensed: a.count,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispensed: true,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ const mockServices: ATMServices = {
|
|||
dispenseCash: async (amounts) => {
|
||||
console.log('[Mock] Dispensing cash:', amounts)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
return {
|
||||
bills: amounts.map((a) => ({
|
||||
denomination: a.denomination,
|
||||
dispensed: a.count,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispensed: true,
|
||||
}
|
||||
},
|
||||
|
||||
// noffer cash-out services
|
||||
|
|
@ -787,7 +795,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const halAtmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
||||
dispenseCash: async (amounts) => {
|
||||
console.log('[ATM] Dispensing via IPC:', amounts)
|
||||
await api.halDispense(amounts)
|
||||
return await api.halDispense(amounts)
|
||||
},
|
||||
getInventory: async () => {
|
||||
// Priority: DB inventory > HAL hardware inventory > empty
|
||||
|
|
|
|||
|
|
@ -58,6 +58,26 @@ watch(
|
|||
const nestedState = computed(() => atmStore.nestedState)
|
||||
const context = computed(() => atmStore.context)
|
||||
|
||||
// Dispense error 30s countdown
|
||||
const dispenseErrorCountdown = ref(30)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch(nestedState, (newState, oldState) => {
|
||||
if (newState === 'dispenseError' && oldState !== 'dispenseError') {
|
||||
dispenseErrorCountdown.value = 30
|
||||
countdownTimer = setInterval(() => {
|
||||
dispenseErrorCountdown.value--
|
||||
if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} else if (oldState === 'dispenseError' && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// Available denominations from inventory
|
||||
const availableDenominations = computed(() => {
|
||||
if (!context.value?.inventory) return []
|
||||
|
|
@ -425,9 +445,69 @@ function formatFiat(cents: number): string {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispense Error (timed, 30s → idle) -->
|
||||
<div
|
||||
v-else-if="nestedState === 'dispenseError'"
|
||||
key="dispenseError"
|
||||
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
|
||||
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
||||
>
|
||||
<!-- Left side — error details -->
|
||||
<div class="flex flex-col items-center gap-3 lg:gap-5">
|
||||
<div class="text-5xl lg:text-[8vh]">⚠️</div>
|
||||
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3>
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">
|
||||
{{ context?.error || 'Cash could not be dispensed' }}
|
||||
</p>
|
||||
|
||||
<!-- Partial dispense info -->
|
||||
<div
|
||||
v-if="context?.dispenseResult?.bills?.length"
|
||||
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="bill in context.dispenseResult.bills"
|
||||
:key="bill.denomination"
|
||||
class="flex justify-between text-base lg:text-2xl"
|
||||
>
|
||||
<span class="text-muted-foreground"
|
||||
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span
|
||||
>
|
||||
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
|
||||
{{ bill.dispensed }} dispensed
|
||||
<span v-if="bill.rejected > 0" class="text-destructive">
|
||||
({{ bill.rejected }} rejected)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm lg:text-lg text-muted-foreground">
|
||||
Please contact support with the transaction ID below.
|
||||
</p>
|
||||
|
||||
<!-- Countdown -->
|
||||
<p class="text-sm lg:text-base text-muted-foreground">
|
||||
Returning to start in {{ dispenseErrorCountdown }}s
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
|
||||
</div>
|
||||
|
||||
<!-- Right side — txid QR -->
|
||||
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
|
||||
<QRCode :value="context.txid" :size="280" />
|
||||
<p
|
||||
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
|
||||
>
|
||||
{{ context.txid }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error (centered, destructive accent) -->
|
||||
<div
|
||||
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
|
||||
v-else-if="nestedState === 'error'"
|
||||
key="error"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-4 lg:gap-6 p-4 lg:p-8"
|
||||
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue