137 lines
8.0 KiB
Markdown
137 lines
8.0 KiB
Markdown
## KoolTap Server — Hold the Stock (Source of Truth) for Several Tablets
|
|
|
|
Until now every tablet owned the stock: it counted down its own sales, reported its whole catalog with its own
|
|
`stock` on every sync, and applied the dashboard's stock adjustments itself. With two tablets in one venue each one
|
|
only knows its own sales, so they report different stock and the dashboard flips between them, and a delivery
|
|
registered on the dashboard is applied once per tablet.
|
|
|
|
From now on the **server holds the stock per product per venue**. It derives it from the tab events it already
|
|
receives (`line_added` / `line_removed` carry `productId` and `quantity`), counts made on a tablet (new `stock_set`
|
|
event) and the dashboard's adjustments. Tablets keep selling offline: they keep counting locally, and on every sync
|
|
they replace their stock with the server's and replay their unsent events on top, exactly like the tabs
|
|
(`server-tabs-source-of-truth.md`). Stock may go negative; a sale is never blocked.
|
|
|
|
The app side is done (KoolTap app, `lib/database/stock_queries.dart` `rebaseStock`,
|
|
`lib/services/remote_sync_service.dart`). Until the server sends `stock`, the app behaves exactly as before.
|
|
|
|
This builds on the tabs change: events are applied once per id, in the order of section "Order within one sync".
|
|
|
|
### 1. Stock state
|
|
|
|
Per `(venueId, productId)`, on the existing `Product` row:
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `stock` | `int` or `null`. `null` = not tracked: sales don't change it. May be negative. |
|
|
| `countedAt` | **new**, timestamp or `null`: when the stock was last set (a count). Line events that happened before it are already in that count. |
|
|
|
|
Migration: keep the current `stock` values (the last value a tablet reported) as the starting point, `countedAt` =
|
|
`null`. With several tablets that value may be off; a stock count on the dashboard fixes it.
|
|
|
|
### 2. Events that change stock
|
|
|
|
Apply these in the **same transaction** as the tab state, once per event id. An event that is a duplicate (already
|
|
applied) changes nothing, also not the stock.
|
|
|
|
| Event | Rule |
|
|
|---|---|
|
|
| `line_added` | `stock -= quantity`, if the product exists, `stock` is not `null`, and `countedAt` is `null` or `occurredAt > countedAt`. |
|
|
| `line_removed` | `stock += quantity`, same conditions. Applies the event's own `quantity` even when the tab state could remove fewer units (the tablet did the same). |
|
|
| `tab_deleted`, `settled`, `tab_opened`, `tab_renamed` | No stock change. A deleted tab's lines arrive as `line_removed` first; a settlement doesn't change stock, the drinks were counted down when added. |
|
|
| `stock_set` | **new**, see below. |
|
|
|
|
- An event without `productId` (older app versions) or for a product the server doesn't know changes no stock.
|
|
- `line_removed` on a line the tab state can't find still changes the stock: the tablet counted the drink back.
|
|
- Use the event's `occurredAt` as stored (after the existing clamp for clocks running ahead).
|
|
|
|
**`stock_set`** — a count or tracking change made on a tablet (inventory screen, a new product's starting stock).
|
|
It is not about a tab: it has **no `tabId` and no `customerName`**.
|
|
|
|
```json
|
|
{ "id": "…", "type": "stock_set", "productId": "12", "quantity": 30, "occurredAt": "2026-10-24T15:03:40.000Z" }
|
|
```
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `productId` | string | max 100 chars |
|
|
| `quantity` | int ≥ 0, or `null` | `null` = stop tracking this product |
|
|
|
|
Rule: if `countedAt` is `null` or `occurredAt >= countedAt`: `stock = quantity`, `countedAt = occurredAt`. Otherwise
|
|
ignore it (a newer count already exists, e.g. a dashboard count made while this tablet was offline). Unknown product →
|
|
acknowledge and ignore. Acknowledge it like any other event; it doesn't appear in the tab state or in `settlements`.
|
|
|
|
The tablet sends `stock_set` when a product is created (unless untracked), when the default products are seeded, and
|
|
when its stock or tracking is changed by hand.
|
|
|
|
### 3. Catalog: managed on the dashboard
|
|
|
|
- The dashboard adds, changes and archives products (archived = deleted for the tablets, kept for tabs and history).
|
|
A product added with a starting count is tracked from that moment.
|
|
- The tablet's `products` report is only used on the first sync of the venue, to take over its catalog and stock once.
|
|
After that it is ignored, also its `stock`.
|
|
- The sync response sends `catalog`: every product of the venue,
|
|
`[{ id, name, category, priceInCents, lowStockThreshold, archived }]`. The tablet adds and updates its products from
|
|
it, and hides archived ones and ones the catalog doesn't list (such as the default products of a newly paired
|
|
tablet). Leaving the key out means "the tablet manages its own catalog".
|
|
- A paired tablet can't add, rename, reprice or delete products; it still counts stock (`stock_set`) and keeps its own
|
|
product pictures until pictures are managed on the dashboard.
|
|
- A `stock_set` for a product the venue doesn't have is acknowledged and ignored.
|
|
|
|
### 4. Dashboard stock adjustments
|
|
|
|
The server applies them itself, **immediately**, also when every tablet is offline:
|
|
|
|
| `type` | Rule |
|
|
|---|---|
|
|
| `add` | `stock += quantity` (signed). Refuse when `stock` is `null` (`stock_not_tracked`). |
|
|
| `set` | `stock = quantity`, `countedAt = now`. On an untracked product this starts tracking it. |
|
|
|
|
Store it as `applied` with `stockAfter` right away. **Stop sending `stockAdjustments`** to tablets (send `[]` or leave
|
|
the key out): a tablet that receives `stock` ignores them anyway, so a delivery is never applied once per tablet.
|
|
Keep accepting `stockAdjustmentResults` from older app versions for adjustments that were already pending.
|
|
|
|
A `set` uses the server's clock, line events the tablet's. A few seconds of clock difference only matter for sales
|
|
rung up in the seconds around a count.
|
|
|
|
### 5. Sync response: `stock`
|
|
|
|
```json
|
|
{
|
|
"stock": [
|
|
{ "productId": "12", "stock": 46, "countedAt": "2026-10-24T15:03:40.000Z" },
|
|
{ "productId": "13", "stock": null, "countedAt": null }
|
|
]
|
|
}
|
|
```
|
|
|
|
- **Every** product of the venue, **after** applying the events acknowledged in this same response (and their
|
|
`stock_set` events). Events you reject must not change the stock: the tablet drops them.
|
|
- `stock`: int (may be negative) or `null` when not tracked. `countedAt`: ISO 8601 UTC or `null`.
|
|
- The tablet replaces the stock of every listed product and replays its unsent `line_added`, `line_removed` and
|
|
`stock_set` events on top with the same rules as section 2 (using the `countedAt` sent here). A product left out
|
|
keeps the tablet's own stock.
|
|
- Send `stock` on every successful sync. Leaving the key out means "this server doesn't own the stock" (old
|
|
behaviour: the tablet applies `stockAdjustments` itself).
|
|
|
|
### Order within one sync
|
|
|
|
1. Upsert the reported `products` (no `stock`, no removals).
|
|
2. Apply the received events (once per id), oldest first: tab state and stock in one transaction; acknowledge/reject.
|
|
3. Record `stockAdjustmentResults` from older tablets (unchanged).
|
|
4. Build `tabs`, `settlements` and `stock` from the resulting state.
|
|
5. Notify the dashboard (`stock` topic) when any stock changed.
|
|
|
|
### Testing
|
|
|
|
- Tablets A and B both sell 2 Cola (stock 48): both tablets and the dashboard show 44.
|
|
- Tablet A offline sells 3 Cola, B sells 1: B and the dashboard show 47 until A syncs, then everyone shows 44.
|
|
- Register a delivery of +24 on the dashboard with two tablets online: stock goes up by 24 once, on the dashboard at
|
|
once and on both tablets after their next sync.
|
|
- Tablet A offline sells 2 Cola at 14:00; the dashboard counts 30 at 15:00; A syncs at 16:00: stock stays 30.
|
|
- Tablet A offline sells 2 Cola at 15:30 after the 15:00 count of 30: stock 28 after A syncs.
|
|
- Tablet A sets Cola to 40 on its inventory screen: the dashboard and tablet B show 40 after the syncs.
|
|
- A sync that resends an already acknowledged `line_added`: the stock doesn't change.
|
|
- A rejected `line_added`: the stock doesn't change on the server, and the tablet's stock goes back after the sync.
|
|
- A tablet on the previous app version (no `stock_set`, still sends `stockAdjustmentResults`): its sales still count,
|
|
its catalog `stock` is ignored.
|