feat(ops): atm-reconcile — check cassette ledgers against recorded history
The cassettes table is a running total, so it can be re-derived: an absolute truth point (a recount, or an empty) plus the refills and dispenses since. A derived count that disagrees with the stored one is evidence of something the ledger never saw. Reconciliation deliberately refuses to start from a refill. A refill is a delta, and applying deltas on top of a wrong number just carries the error forward — which is how sintra's 20-EUR bay ran 10 notes high for weeks while its 50-EUR bay, zeroed by an `empty` before refilling, reconciled exactly. A bay with no baseline is reported as unreconcilable rather than silently assumed good. Also surfaces the two things that make a count untrustworthy: the counts-uncertain flag, and any transaction still sitting in dispense_error / partial. The SQL uses scalar subqueries rather than joins on purpose — joining transaction_bills to cassettes fans out across bays, and a LEFT JOIN whose rows are all excluded by the baseline cutoff collapses to NULL and poisons the arithmetic downstream (the first draft read "expected: blank" for exactly that reason). Exits non-zero on any gap or missing baseline so it can be run as a check after a test session. Refs #122
This commit is contained in:
parent
695a8bf98d
commit
ee28275bf3
2 changed files with 173 additions and 0 deletions
172
deploy/nixos/atm-reconcile.sh
Executable file
172
deploy/nixos/atm-reconcile.sh
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env bash
|
||||
# atm-reconcile — Check each cassette's ledger count against recorded history
|
||||
#
|
||||
# The cassettes table is a running total maintained by the machine: operator
|
||||
# ops add to it (refill) or set it (recount / empty), and dispenses subtract
|
||||
# from it. That means the count can be re-derived, and a derived value that
|
||||
# disagrees with the stored one is evidence of something the ledger never saw.
|
||||
#
|
||||
# Reconciliation runs forward from each bay's last ABSOLUTE truth point — a
|
||||
# `recount` (someone opened the bay and counted it) or an `empty` (set to
|
||||
# zero). Refills are deltas and cannot serve as a baseline: a refill applied
|
||||
# on top of a wrong number just carries the error forward, which is exactly
|
||||
# how a bad count survives for weeks.
|
||||
#
|
||||
# expected = base + refills_since_base - dispensed_since_base
|
||||
# gap = ledger - expected
|
||||
#
|
||||
# A non-zero gap means either notes moved without an op recording it, or a
|
||||
# dispense moved notes the dispenser's counters did not report. The second is
|
||||
# real: a note that leaves the bay and jams in the transport completes neither
|
||||
# the `dispensed` nor the `rejected` counter, so the bay silently reads one
|
||||
# high while the transaction row says nothing was dispensed (aiolabs/bitspire#122).
|
||||
#
|
||||
# A bay with NO baseline cannot be reconciled at all — its starting number
|
||||
# came from somewhere unrecorded. Publish a recount for it; that is the only
|
||||
# op that establishes ground truth (and the only one that clears the
|
||||
# counts-uncertain flag).
|
||||
#
|
||||
# Usage:
|
||||
# atm-reconcile # reconcile every bay
|
||||
# atm-reconcile --csv # machine-readable
|
||||
# atm-reconcile --quiet # exit status only, no output
|
||||
#
|
||||
# Exit status:
|
||||
# 0 every bay reconciles
|
||||
# 1 at least one bay has a non-zero gap or no baseline
|
||||
# 2 database missing / unreadable
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB="${ATM_STATE_DB:-/var/lib/bitspire/state.db}"
|
||||
|
||||
FORMAT="-column -header"
|
||||
QUIET=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--csv) FORMAT="-csv -header"; shift ;;
|
||||
--quiet) QUIET=true; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -r "$DB" ]; then
|
||||
echo "ERROR: state database not readable at $DB" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Per-bay baseline, then the deltas since it. Written as scalar subqueries
|
||||
# rather than joins on purpose: joining transaction_bills to cassettes fans
|
||||
# out across bays, and a LEFT JOIN whose rows are all filtered out by the
|
||||
# baseline cutoff collapses to NULL and poisons the arithmetic downstream.
|
||||
RECONCILE_SQL="
|
||||
WITH bay AS (
|
||||
SELECT
|
||||
c.position AS position,
|
||||
c.denomination AS denomination,
|
||||
c.count AS ledger,
|
||||
(SELECT o.op_at FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
|
||||
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
|
||||
FROM cassettes c
|
||||
),
|
||||
delta AS (
|
||||
SELECT
|
||||
bay.*,
|
||||
(SELECT COALESCE(SUM(o.bills), 0) FROM cassette_ops o
|
||||
WHERE o.position = bay.position AND o.op_type = 'refill'
|
||||
AND o.op_at > COALESCE(bay.base_at, -1)) AS added,
|
||||
(SELECT COALESCE(SUM(tb.count), 0)
|
||||
FROM transaction_bills tb
|
||||
JOIN transactions t ON t.txid = tb.txid
|
||||
WHERE tb.denomination = bay.denomination
|
||||
AND t.type IN ('cash_out','manual_dispense')
|
||||
AND t.created_at / 1000 > COALESCE(bay.base_at, -1)) AS dispensed
|
||||
FROM bay
|
||||
)
|
||||
SELECT
|
||||
position AS 'Bay',
|
||||
denomination AS 'Denom',
|
||||
CASE WHEN base_at IS NULL THEN '(none)' ELSE datetime(base_at,'unixepoch') END AS 'Baseline',
|
||||
CASE WHEN base_at IS NULL THEN NULL ELSE base_count END AS 'Base',
|
||||
added AS 'Refilled',
|
||||
dispensed AS 'Dispensed',
|
||||
ledger AS 'Ledger',
|
||||
CASE WHEN base_at IS NULL THEN NULL
|
||||
ELSE base_count + added - dispensed END AS 'Expected',
|
||||
CASE WHEN base_at IS NULL THEN 'NO BASELINE'
|
||||
ELSE printf('%+d', ledger - (base_count + added - dispensed)) END AS 'Gap'
|
||||
FROM delta
|
||||
ORDER BY position;
|
||||
"
|
||||
|
||||
# Bays sharing a denomination break per-bay attribution: transaction_bills
|
||||
# records what denomination went out, never which bay it came from, so the
|
||||
# dispensed figure lands on every matching bay.
|
||||
AMBIGUOUS=$(sqlite3 "$DB" \
|
||||
"SELECT group_concat(denomination) FROM (
|
||||
SELECT denomination FROM cassettes GROUP BY denomination HAVING COUNT(*) > 1);")
|
||||
|
||||
PROBLEMS=$(sqlite3 "$DB" "
|
||||
WITH bay AS (
|
||||
SELECT c.position AS position, c.denomination AS denomination, c.count AS ledger,
|
||||
(SELECT o.op_at FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at,
|
||||
(SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o
|
||||
WHERE o.position = c.position AND o.op_type IN ('recount','empty')
|
||||
ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count
|
||||
FROM cassettes c
|
||||
)
|
||||
SELECT COUNT(*) FROM bay WHERE base_at IS NULL OR ledger <> (
|
||||
base_count
|
||||
+ (SELECT COALESCE(SUM(o.bills),0) FROM cassette_ops o
|
||||
WHERE o.position = bay.position AND o.op_type = 'refill' AND o.op_at > bay.base_at)
|
||||
- (SELECT COALESCE(SUM(tb.count),0) FROM transaction_bills tb
|
||||
JOIN transactions t ON t.txid = tb.txid
|
||||
WHERE tb.denomination = bay.denomination
|
||||
AND t.type IN ('cash_out','manual_dispense')
|
||||
AND t.created_at / 1000 > bay.base_at));")
|
||||
|
||||
if ! $QUIET; then
|
||||
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
|
||||
sqlite3 $FORMAT "$DB" "$RECONCILE_SQL"
|
||||
echo
|
||||
|
||||
UNCERTAIN=$(sqlite3 "$DB" \
|
||||
"SELECT COALESCE(NULLIF(value,''),'') FROM meta WHERE key = 'countsUncertainSince';")
|
||||
if [ -n "$UNCERTAIN" ]; then
|
||||
echo "Counts flagged UNVERIFIED since $(date -u -d "@$UNCERTAIN" '+%F %T UTC' 2>/dev/null || echo "$UNCERTAIN")"
|
||||
echo " A dispense ended without a trustworthy report. Only a recount clears this."
|
||||
else
|
||||
echo "Counts not flagged unverified."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Unresolved dispense failures ==="
|
||||
# shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags
|
||||
sqlite3 $FORMAT "$DB" "
|
||||
SELECT txid AS 'TX ID', status AS 'Status',
|
||||
printf('%.2f', fiat_cents / 100.0) AS 'Fiat', currency AS 'Cur',
|
||||
COALESCE(error,'') AS 'Error',
|
||||
datetime(created_at / 1000,'unixepoch') AS 'Time (UTC)'
|
||||
FROM transactions
|
||||
WHERE status IN ('dispense_error','partial')
|
||||
ORDER BY created_at DESC;"
|
||||
|
||||
if [ -n "$AMBIGUOUS" ]; then
|
||||
echo
|
||||
echo "WARNING: denomination(s) $AMBIGUOUS are loaded in more than one bay."
|
||||
echo " Dispenses are recorded by denomination, not by bay, so the Dispensed"
|
||||
echo " column double-counts across those bays. Reconcile them as a group."
|
||||
fi
|
||||
fi
|
||||
|
||||
[ "${PROBLEMS:-0}" -eq 0 ] || exit 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue