134 lines
7.7 KiB
Markdown
134 lines
7.7 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 (`products` in the request)
|
|
|
|
- Keep upserting `name`, `category` and `priceInCents` per reported product.
|
|
- **Ignore `stock`** in the report once the server owns the stock (keep accepting the field; older servers use it).
|
|
- A product the server sees for the first time is created with `stock = null` (untracked). Its starting stock arrives
|
|
as a `stock_set` event in the same request.
|
|
- **Stop removing products that one tablet doesn't report**: with several tablets, tablet B not knowing a product of
|
|
tablet A must not delete A's stock. Deleting or hiding products becomes a dashboard action (out of scope here).
|
|
|
|
Process `products` **before** the events of the same request, so a `stock_set` for a new product finds it.
|
|
|
|
### 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.
|