diff --git a/backend/src/main/resources/content/dashboard/cash-stats/architect.md b/backend/src/main/resources/content/dashboard/cash-stats/architect.md index aca6871..6497600 100644 --- a/backend/src/main/resources/content/dashboard/cash-stats/architect.md +++ b/backend/src/main/resources/content/dashboard/cash-stats/architect.md @@ -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 | diff --git a/backend/src/main/resources/content/dashboard/cash-stats/claude.md b/backend/src/main/resources/content/dashboard/cash-stats/claude.md index a31dc4f..2642914 100644 --- a/backend/src/main/resources/content/dashboard/cash-stats/claude.md +++ b/backend/src/main/resources/content/dashboard/cash-stats/claude.md @@ -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. diff --git a/backend/src/main/resources/content/dashboard/cash-stats/internal.md b/backend/src/main/resources/content/dashboard/cash-stats/internal.md index a252b63..6a317a2 100644 --- a/backend/src/main/resources/content/dashboard/cash-stats/internal.md +++ b/backend/src/main/resources/content/dashboard/cash-stats/internal.md @@ -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 diff --git a/backend/src/main/resources/content/dashboard/cash-stats/overview.md b/backend/src/main/resources/content/dashboard/cash-stats/overview.md index a5c2202..4d3d569 100644 --- a/backend/src/main/resources/content/dashboard/cash-stats/overview.md +++ b/backend/src/main/resources/content/dashboard/cash-stats/overview.md @@ -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. diff --git a/backend/src/main/resources/content/dashboard/cash-stats/testing.md b/backend/src/main/resources/content/dashboard/cash-stats/testing.md index 70a0e09..f7756af 100644 --- a/backend/src/main/resources/content/dashboard/cash-stats/testing.md +++ b/backend/src/main/resources/content/dashboard/cash-stats/testing.md @@ -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** |