feat(cassettes): record operations from the dashboard instead of counts

The cassettes tab no longer has editable count fields, because there is
no longer an endpoint that would accept them. The bays render read-only
from the machine's own report, and a Record-operation dialog captures
what the operator did: a refill in notes added, an empty, a recount, a
denomination change.

This removes the failure the tab used to invite. A form loaded before a
dispense held a count that was already wrong, and publishing it
overwrote the dispense with no error on either side. Recording a delta
instead means a dispense that happened while the dialog was open is kept
rather than discarded, and a recount is now an explicit act — what an
operator opening a bay and counting actually does — rather than being
indistinguishable from a stale form.

A recent-operations list shows each one as Applied or Pending from
acked_at, which is the machine echoing the id back. Pending needs no
retry button: every publish carries the recent window, so an operation
that missed its own publish keeps being re-offered until it lands, and
saying so in the panel is more useful than a button that would do
nothing new.

The counts-uncertain banner tells the operator when the machine cannot
vouch for its own numbers and asks for the recount that clears it. The
machine row is re-read on every cassette refresh, since that flag is set
by the consumer while the dialog is open.
This commit is contained in:
Padreug 2026-09-23 12:50:08 +02:00
commit 79a4f83293
2 changed files with 275 additions and 148 deletions

View file

@ -212,15 +212,14 @@ window.app = Vue.createApp({
loading: false,
machine: null,
settlements: [],
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
// submitCassettePublish methods + the cassettes panel in
// templates/spirekeeper/index.html.
// Cassettes sub-tab state (v2, bitspire ADR-004) — see
// openCassetteOpDialog / submitCassetteOp + the cassettes panel in
// templates/spirekeeper/index.html. Read-only by design: the
// operator records operations, the machine owns the counts.
activeTab: 'settlements',
cassetteEdits: [], // editable working copy of cassette_configs rows
cassettesPristine: [], // last-known-clean snapshot for revert
cassettes: [], // machine-reported rows, not editable
cassetteOps: [], // recent operations, newest first
cassettesLoading: false,
cassettesPublishing: false,
cassettesDirty: false,
cassettesError: null
},
cassettesTable: {
@ -228,14 +227,27 @@ window.app = Vue.createApp({
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
{name: 'count', label: 'Count', field: 'count', align: 'right'},
{name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
{name: 'state_at', label: 'Machine reported', field: 'state_at', align: 'left'},
{name: 'actions', label: '', field: 'position', align: 'right'}
],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
},
cassettePublishConfirm: {
show: false
cassetteOpDialog: {
show: false,
saving: false,
error: null,
position: null,
op_type: 'refill',
bills: null,
count: null,
denomination: null
},
cassetteOpTypeOptions: [
{label: 'Refill — notes added', value: 'refill'},
{label: 'Empty — bay emptied', value: 'empty'},
{label: 'Recount — notes counted', value: 'recount'},
{label: 'Set denomination', value: 'set_denomination'}
],
partialDispenseDialog: {
show: false,
saving: false,
@ -286,6 +298,28 @@ window.app = Vue.createApp({
},
computed: {
cassetteBayOptions() {
return this.machineDetail.cassettes.map(row => ({
label: `Bay ${row.position} — ${row.denomination} ${
(this.machineDetail.machine || {}).fiat_code || ''
} ×${row.count}`,
value: row.position
}))
},
cassetteOpIsComplete() {
// Mirrors CreateCassetteOpData's root validator: exactly one value
// field, decided by the type. Enforced here only to keep the button
// honest — the server rejects a malformed op regardless.
const d = this.cassetteOpDialog
if (!d.position) return false
if (d.op_type === 'empty') return true
if (d.op_type === 'refill') return Number(d.bills) > 0
if (d.op_type === 'recount') return d.count !== null && Number(d.count) >= 0
if (d.op_type === 'set_denomination') return Number(d.denomination) > 0
return false
},
superAnyFee() {
// Banner styling key — true when either directional super fee is
// non-zero, so the banner reads as "active platform fee" instead
@ -943,9 +977,8 @@ window.app = Vue.createApp({
async viewMachine(machine) {
this.machineDetail.machine = machine
this.machineDetail.settlements = []
this.machineDetail.cassetteEdits = []
this.machineDetail.cassettesPristine = []
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettes = []
this.machineDetail.cassetteOps = []
this.machineDetail.cassettesError = null
this.machineDetail.activeTab = 'settlements'
this.machineDetail.show = true
@ -972,21 +1005,25 @@ window.app = Vue.createApp({
},
// -----------------------------------------------------------------
// Cassette inventory (#29 v1)
// Cassette inventory + operations (v2, bitspire ADR-004)
// -----------------------------------------------------------------
async loadMachineCassettes() {
if (!this.machineDetail.machine) return
this.machineDetail.cassettesLoading = true
this.machineDetail.cassettesError = null
const base = `${MACHINES_PATH}/${this.machineDetail.machine.id}`
try {
const {data} = await LNbits.api.request(
'GET',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes`
)
const rows = (data || []).map(row => ({...row, _dirty: false}))
this.machineDetail.cassetteEdits = rows
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows))
this.machineDetail.cassettesDirty = false
// The machine row is re-read too: counts_uncertain_since lives on it
// and is set by the consumer as state events land, so the row the
// machines table handed us goes stale while this dialog is open.
const [machine, bays, ops] = await Promise.all([
LNbits.api.request('GET', base),
LNbits.api.request('GET', `${base}/cassettes`),
LNbits.api.request('GET', `${base}/cassettes/ops`)
])
if (machine.data) this.machineDetail.machine = machine.data
this.machineDetail.cassettes = bays.data || []
this.machineDetail.cassetteOps = ops.data || []
} catch (e) {
this._notifyError(e, 'Failed to load cassettes')
} finally {
@ -994,73 +1031,91 @@ window.app = Vue.createApp({
}
},
markCassetteDirty(row) {
// Find pristine match by position (the row identity) and compare;
// flip _dirty + overall dirty flag accordingly. Editable fields
// are denomination + count; position is the immutable row key.
const pristine = this.machineDetail.cassettesPristine.find(
p => p.position === row.position
cassetteOpIcon(opType) {
return (
{
refill: 'add_circle_outline',
empty: 'remove_circle_outline',
recount: 'fact_check',
set_denomination: 'sell'
}[opType] || 'help_outline'
)
row._dirty =
!pristine ||
Number(row.denomination) !== Number(pristine.denomination) ||
Number(row.count) !== Number(pristine.count)
this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty)
},
revertCassetteEdits() {
this.machineDetail.cassetteEdits = JSON.parse(
JSON.stringify(this.machineDetail.cassettesPristine)
)
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettesError = null
},
openCassettePublishConfirm() {
if (!this.machineDetail.cassettesDirty) return
this.machineDetail.cassettesError = null
this.cassettePublishConfirm.show = true
},
async submitCassettePublish() {
// Build the PublishCassettesPayload shape (v1.1, position-keyed):
// { positions: { "<pos>": { denomination, count }, ... } }
// The API enforces the position set matches what's stored —
// since we only edit existing rows, this should always pass.
const positions = {}
for (const row of this.machineDetail.cassetteEdits) {
positions[String(row.position)] = {
denomination: Number(row.denomination),
count: Number(row.count)
}
cassetteOpSummary(op) {
const fiat = (this.machineDetail.machine || {}).fiat_code || ''
const bay = `Bay ${op.position}`
if (op.op_type === 'refill') return `${bay} — added ${op.bills} notes`
if (op.op_type === 'empty') return `${bay} — emptied`
if (op.op_type === 'recount') return `${bay} — recounted to ${op.count}`
if (op.op_type === 'set_denomination') {
return `${bay} — denomination set to ${op.denomination} ${fiat}`.trim()
}
const payload = {positions}
this.machineDetail.cassettesPublishing = true
return `${bay} — ${op.op_type}`
},
openCassetteOpDialog(position) {
const bays = this.machineDetail.cassettes
if (!bays.length) return
Object.assign(this.cassetteOpDialog, {
show: true,
saving: false,
error: null,
position: position || bays[0].position,
op_type: 'refill',
bills: null,
count: null,
denomination: null
})
},
resetCassetteOpValue() {
// Each type carries exactly one value field and the server rejects an
// op that carries a foreign one. Clearing on every type switch means a
// number typed under the previous type can't ride along invisibly.
Object.assign(this.cassetteOpDialog, {
bills: null,
count: null,
denomination: null,
error: null
})
},
async submitCassetteOp() {
const d = this.cassetteOpDialog
if (!this.cassetteOpIsComplete) return
const payload = {position: Number(d.position), op_type: d.op_type}
if (d.op_type === 'refill') payload.bills = Number(d.bills)
if (d.op_type === 'recount') payload.count = Number(d.count)
if (d.op_type === 'set_denomination') {
payload.denomination = Number(d.denomination)
}
d.saving = true
d.error = null
try {
const {data} = await LNbits.api.request(
await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`,
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/ops`,
null,
payload
)
const fresh = (data || []).map(r => ({...r, _dirty: false}))
this.machineDetail.cassetteEdits = fresh
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh))
this.machineDetail.cassettesDirty = false
this.cassettePublishConfirm.show = false
d.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Cassette config published to ATM'
message: 'Operation recorded and published to the ATM'
})
await this.loadMachineCassettes()
} catch (e) {
const detail =
(e && e.response && e.response.data && e.response.data.detail) ||
'Publish failed'
this.machineDetail.cassettesError = detail
this._notifyError(e, 'Publish failed')
'Could not record the operation'
// A 503 means the op IS recorded and will ride out with the next
// publish, so reload either way — the list should show it pending.
d.error = detail
this._notifyError(e, 'Operation failed')
await this.loadMachineCassettes()
} finally {
this.machineDetail.cassettesPublishing = false
d.saving = false
}
},