docs(guide): cash-stats per-terminal grand total (dashboard#15)
CD - Develop (guide → bcos.dev) / build-and-deploy (push) Successful in 1m39s
CD - Develop (guide → bcos.dev) / build-and-deploy (push) Successful in 1m39s
Documents the Total shown under each ATM name on Cash Stats, across all five tabs. The load-bearing facts, which are easy to get wrong: - The total is derived client-side in CashStatus.svelte. No endpoint, DTO, or column backs it, and none should be added. - The asterisk means the total is a floor, not a balance: mix/retract notes (no denomination) and cassettes with no reported count are excluded. An empty retract cassette deliberately does not flag the row. - An ATM with no reported inventory shows a dash, never $0, which would read as an empty machine. - The pills must keep wrapping: real fleets run 5+ cassettes, which under the old nowrap layout overflowed the table and crushed the ATM column. Also notes that the bcos.dev simulator emits no mix/retract slots, so the partial-total path never fires there and must not be mistaken for dead code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,16 @@ Key point: this is the **executor→Kafka→record-store** pattern applied acros
|
||||
|
||||
The frontend (`CashStatus.svelte`) does the rest client-side: groups events by ATM, computes empty/low/ok (`LOW_BILL_THRESHOLD = 200` bills), sorts empty→low→ok→config→no-data, and merges in ATMs from `/api/atms` that returned no events at all as "No inventory reported."
|
||||
|
||||
## Per-terminal grand total (#15)
|
||||
|
||||
The **Total** shown under each ATM name is **derived in the component**, not served. The backend deliberately returns raw per-slot rows and aggregates nothing; adding a `totalValue` to `JournalEventDTO` would push a presentation concern into incident's domain for no gain, since the page already holds every slot it needs.
|
||||
|
||||
- `totalValue` = Σ `denomination × billCount` over slots with a denomination and a real count (`eventSource !== 'CASSETTE_CONFIG'`).
|
||||
- `totalPartial` → renders `*`. True when the machine holds cash that cannot be valued: a `CASSETTE_CONFIG` slot (no count reported) or a MIX/RETRACT slot (null denomination) with notes in it. An **empty** retract slot does not trip it — otherwise almost every row would carry the marker and it would stop meaning anything.
|
||||
- `noData` / `configOnly` rows render a **dash**, not `$0`. This is a correctness point, not cosmetics: `$0` on an unreported ATM reads as "this machine is empty", which is exactly the wrong operational signal.
|
||||
|
||||
**Layout constraint:** the total lives in the `.col-atm` cell rather than its own column, and the cassette pills wrap (`flex-wrap: wrap`, `.col-atm` pinned to 200px). Real fleets run 5+ cassettes (4 dispensing + mix/retract); with the former `nowrap` + 190px-min pills, those rows overflowed the table horizontally and crushed the ATM column to a few characters. Keep the pills wrapping.
|
||||
|
||||
## DB tables
|
||||
|
||||
| Table | Service / DB | Access |
|
||||
|
||||
@@ -38,8 +38,18 @@ Terse agent reference for `dashboard.cash-stats`. Read view over cassette bill c
|
||||
- Scope: `InstitutionContext.resolveScope()` → `CUSTOMER`/`MSP_ADMIN` scoped to JWT institution keys; others unrestricted.
|
||||
- Journal admin: `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`.
|
||||
|
||||
## Per-terminal grand total (#15)
|
||||
- Rendered **under the ATM name** as `Total $X` — there is **no Total column**; it lives in the `.col-atm` cell.
|
||||
- **Derived 100% client-side** in `CashStatus.svelte` from data the page already loads. No endpoint, DTO, or column backs it — do not go looking for one, and do not add one.
|
||||
- `totalValue` = Σ `denomination × billCount` over slots with `denomination > 0` **and** `eventSource !== 'CASSETTE_CONFIG'`.
|
||||
- `totalPartial` (renders a `*` + tooltip) = any slot that is `CASSETTE_CONFIG`, **or** a MIX/RETRACT slot (`denomination === 0`) with `billCount > 0` — i.e. the machine holds cash the total cannot value. An **empty** retract slot deliberately does **not** flag the row (otherwise nearly every row would carry the marker).
|
||||
- `noData` / `configOnly` rows render a **dash**, never `$0` — an unreported ATM must not read as an empty one.
|
||||
- Slot pills **wrap** (`.slots-row { flex-wrap: wrap }`); `.col-atm` is pinned to 200px. Both are load-bearing: with `nowrap` + the 190px-min pill, a 5+ cassette machine overflows the table and crushes the ATM column. Do not restore `nowrap`.
|
||||
|
||||
## Gotchas
|
||||
- `event_source='CASSETTE_CONFIG'` = **synthetic config-only** slot (no real count) — UI shows "(no data)". Real data = `event_source='JOURNAL'` (from journal) or agent ingest.
|
||||
- The grand total is a **floor, not a balance**, whenever the `*` shows — MIX/RETRACT notes and unreported cassettes are excluded by construction.
|
||||
- The bcos.dev simulator emits **no MIX/RETRACT slots and no partially-reported ATMs**, so the `*` path never fires there. Exercise it with fabricated data; do not conclude from bcos.dev that it is dead code.
|
||||
- Journal write **deletes then inserts** all `CASSETTE_INVENTORY` rows for an ATM per update; has a freshness guard (skips if existing `event_time` newer).
|
||||
- Read query uses `MAX(id)` per `(atm_id, COALESCE(cassette_position,''))` as recency proxy — assumes monotonic ids.
|
||||
- Currency hardcoded `USD`; type from STARTUP is `RECYCLER`/`MIX`/`RETRACT`. MIX/RETRACT have null denomination.
|
||||
@@ -49,6 +59,8 @@ Terse agent reference for `dashboard.cash-stats`. Read view over cassette bill c
|
||||
|
||||
## Do NOT
|
||||
- Do NOT add a cassette/cash endpoint to `hiveops-dashboard` — it has no backend; use incident.
|
||||
- Do NOT add a backend/DTO field for the grand total — it is derived in the component; the backend deliberately returns raw per-slot rows.
|
||||
- Do NOT include MIX/RETRACT slots in the total (null denomination → they would silently contribute `0` and understate nothing visibly); exclude them and flag with `*`.
|
||||
- Do NOT write `CASSETTE_INVENTORY` rows from anywhere but journal's `CassetteInventoryService` (single writer owns the delete+insert).
|
||||
- Do NOT expect Kafka backlog replay — consumer is `auto.offset.reset=latest`; use `sync-cassette-inventory` to backfill.
|
||||
- Do NOT assume "last reported" = now; it's the STARTUP transaction time.
|
||||
|
||||
@@ -31,6 +31,8 @@ The page makes exactly two calls on load and every 60 s:
|
||||
3. **One ATM stuck stale / never updates** → its STARTUP journals aren't parsing, or the ATM's `atm_id` string doesn't resolve to a numeric id in incident's `atms` table (`resolveNumericId` returns null → row skipped, logged as a warning).
|
||||
4. **Counts look wrong for a recycler** → the journal parser reads the `[DENOMINATION]` table (or falls back to `RCY USD` lines) out of the STARTUP record; a firmware format it doesn't match yields missing/zero slots.
|
||||
5. **Cassettes show but say "(no data)"** → those are **config-only** synthetic slots. The ATM has a cassette *configuration* (agent-synced or a manually applied template) but has reported no actual bill count yet. Not an error.
|
||||
6. **"The Total under the ATM name is lower than what's in the machine"** → look for the **asterisk** next to it. The total sums only cassettes that reported a count *and* have a denomination; a **mix/retract cassette** holding notes, or a cassette with no reported count, is excluded and flags the row with `*`. With an asterisk the figure is a **floor**, not a balance. This is by design, not a miscalculation.
|
||||
7. **"Total shows a dash"** → the ATM reported no cash inventory (or config-only cassettes). A dash is deliberate: an unreported machine must not read as `$0`, which would look like an empty ATM.
|
||||
|
||||
## Admin actions (backend, not on the page)
|
||||
|
||||
@@ -47,6 +49,8 @@ The page makes exactly two calls on load and every 60 s:
|
||||
| Newer count not showing | journal skips write because existing `event_time` is newer | Expected guard; a genuinely newer STARTUP will overwrite |
|
||||
| Customer sees subset of ATMs | institution scoping from JWT | Working as intended |
|
||||
| 401/403 on load | expired/invalid JWT, or user has none of the allowed roles | Re-auth; check role |
|
||||
| Total looks too low, has a `*` | mix/retract notes or an unreported cassette excluded from the sum | Working as intended — the `*` says the total is a floor |
|
||||
| Total shows `—` | no cash inventory reported for that ATM | Same root cause as "All No Data" above; not a total bug |
|
||||
|
||||
## Data freshness
|
||||
|
||||
|
||||
@@ -10,13 +10,25 @@ A fleet-wide view of how much cash is in each ATM's cassettes, based on the late
|
||||
|
||||
## 👀 What you're looking at
|
||||
|
||||
Each row is one ATM, showing its name, the time it last reported, and a strip of coloured pills — one per cassette.
|
||||
Each row is one ATM, showing its name, the time it last reported, the **total cash in that machine**, and a strip of coloured pills — one per cassette.
|
||||
|
||||
- **[position]** — the cassette slot on the machine.
|
||||
- **$denomination** — the bill value in that cassette (e.g. $20).
|
||||
- **(count)** — how many bills are currently loaded.
|
||||
- **$total** — the cash value in that cassette (denomination × bill count).
|
||||
|
||||
## 💵 Total cash in the terminal
|
||||
|
||||
Under each ATM's name, **Total** gives you the whole machine at a glance — no need to add the cassettes up yourself.
|
||||
|
||||
- It adds up every cassette that reported a bill count and has a denomination.
|
||||
- An **asterisk (\*)** means the total is **incomplete** — the machine holds cash it cannot put a value on. Hover the asterisk to see why. Two things cause it:
|
||||
- a **mix or retract cassette** holding notes: these have no single denomination, so their value is unknown;
|
||||
- a cassette that **never reported a count** (its pill reads "(no data)").
|
||||
- A **dash (—)** means the ATM reported no cash inventory at all, so there is nothing to total. It does not mean the machine is empty.
|
||||
|
||||
> When you see an asterisk, treat the total as a **floor** — the machine holds at least that much.
|
||||
|
||||
Pill and row colours tell you the status at a glance:
|
||||
|
||||
- 🟢 **OK** — plenty of bills.
|
||||
@@ -38,3 +50,5 @@ Rows are automatically sorted so the machines needing attention — empty first,
|
||||
|
||||
- Times show when each ATM last sent an inventory report — a stale time can mean the machine hasn't checked in recently.
|
||||
- Cassettes marked "no data" are set up on the machine but haven't reported an actual bill count yet.
|
||||
- The **Total** is the cash the machine last *reported*, not a live balance — it moves only when the ATM sends a fresh inventory report.
|
||||
- Machines with more cassettes than fit on one line simply wrap onto a second line; nothing is hidden.
|
||||
|
||||
@@ -25,12 +25,17 @@ Test devices are the simulators: **C1-ATM-001…**, **C1-TCR-001…**, **C1-SRV-
|
||||
|---|---------|-----------|
|
||||
| 1 | As `bcos_a`, open **Cash Stats** | Page loads with a table (**ATM** / **Cassettes** columns) and four cards at the top: **Empty**, **Low**, **OK**, **No Data** |
|
||||
| 2 | Look at any ATM row with cassette data | The Cassettes cell shows coloured pills, each with `[position]`, `$denomination`, a `(count)`, and a `$total` — red = empty, amber = low, green = OK |
|
||||
| 2a | Look under the ATM name on that same row | A **Total** line shows the machine's whole cash figure, and it **equals the sum of the `$total` values on that row's pills** — add them up and check |
|
||||
| 2b | Find an ATM whose row has a **"(no data)"** pill next to real ones | Its **Total** carries an **asterisk (\*)**; hovering the asterisk explains that cassettes with no reported count are excluded. The total counts only the pills that show a value |
|
||||
| 2c | Find an ATM with a **mix `[E]`** or **retract `[R]`** cassette holding notes (production fleets; the bcos.dev simulator has none) | The row's **Total** carries an **asterisk** — those cassettes have no denomination, so their notes cannot be valued and are left out of the total |
|
||||
| 2d | Find an ATM with **5 or more cassettes** | The pills **wrap onto a second line**; the ATM name stays on one line and the Total stays visible. Nothing runs off the right edge and the table does **not** scroll sideways |
|
||||
| 3 | Check the row order | Rows needing attention sort to the top: **Empty** ATMs first, then **Low**, with No-Data / config-only rows at the bottom |
|
||||
| 4 | Click the **Empty** card | The list filters to only ATMs with an empty cassette (or shows "No ATMs match the current filter" if none); the card looks selected |
|
||||
| 5 | Click the **Empty** card again (or the **Low**/**OK**/**No Data** cards) | Clicking the active card clears the filter and shows all ATMs again; other cards switch the filter to that status |
|
||||
| 6 | Click the **↻ Refresh** button | The button spins briefly and the list reloads — no error banner appears |
|
||||
| 7 | Look for any server device (e.g. `C1-SRV-001`) in the list | Servers are **not** listed — Cash Stats shows ATMs/TCRs only, never SVR/SRV devices |
|
||||
| 8 | Find an ATM that hasn't reported cash | Its row reads **"No inventory reported"** (or pills say **(no data)** for config-only cassettes) — this is expected, not an error |
|
||||
| 8a | Look at the **Total** on that same no-inventory row | It shows a **dash (—)**, never **$0** — an unreported machine must not read as an empty one |
|
||||
| 9 | Log in as `customer_a`, open **Cash Stats** | You see **only C1** ATMs — no C2 or C3 rows; the card counts reflect just C1 |
|
||||
| 10 | Log in as `msp_a`, open **Cash Stats** | You see **C1 and C2** ATMs but **not C3** |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user