269 lines
14 KiB
Markdown
269 lines
14 KiB
Markdown
## KoolTap Flutter App — Stock: Track It, Report It, Apply Changes from the Dashboard
|
||
|
||
The dashboard (backoffice) now has a **Stock** page. Managers can see how much of every product is left, set a
|
||
low stock warning level, register deliveries, take stock out (breakage, own use) and enter stock counts.
|
||
|
||
The **terminal stays the owner of the stock**. It counts sales down itself, also while offline, and reports
|
||
its product catalog with the current stock on every sync. The dashboard never overwrites the stock directly:
|
||
it sends **stock adjustments** through the sync response, the terminal applies them and reports the outcome
|
||
in the next sync.
|
||
|
||
Add three things to the app:
|
||
|
||
1. **Stock tracking**: a nullable stock per product that goes down when drinks are put on a tab and back up when
|
||
they are taken off.
|
||
2. **Report the catalog**: send the complete product list with stock in every sync.
|
||
3. **Apply stock adjustments** from the sync response, exactly once each, and report the outcome through a
|
||
small outbox.
|
||
|
||
Nothing else about the sync changes: `openTabs`, `events`, commands, acks and `plannedTabNames` keep working
|
||
exactly as they do now.
|
||
|
||
### Existing Files (read these first)
|
||
|
||
- `lib/models/remote_command.dart` — `SyncResponse`, `RemoteCommand`, `SnapshotTab` and the sync request
|
||
- `lib/services/remote_sync_service.dart` — the sync loop (`_performSync()` or similar)
|
||
- `lib/services/bar_tab_service.dart` — creates and changes tabs, adds/removes lines, settles tabs, and writes
|
||
the tab events (`line_added`, `line_removed`, `settled`) to the event outbox
|
||
- `lib/database/app_database.dart` — the Drift database schema and migrations (has the `TabEvents` outbox)
|
||
- The product model, its table and the screen(s) where products are managed (search for `Product`, `products`,
|
||
`price`)
|
||
|
||
Before writing code:
|
||
- Find the product model and table. Note how a product is identified (its id must be **stable**: the same
|
||
product must keep the same id across app restarts and edits) and whether it already has a stock or category
|
||
field.
|
||
- Find **every** place that writes a `line_added` or `line_removed` event. Stock changes in exactly those
|
||
places. List them in your plan.
|
||
|
||
### 1. Stock per Product (Drift)
|
||
|
||
If the product table has no stock column yet, add one, bump `schemaVersion` and add a migration step
|
||
(existing data must stay):
|
||
|
||
```dart
|
||
IntColumn get stock => integer().nullable()(); // null = stock is not tracked for this product
|
||
```
|
||
|
||
- `null` means **not tracked**: nothing happens to it on sales, and it is shown as "not tracked" on the
|
||
dashboard. All existing products start as `null`.
|
||
- The value **may go negative**. Never block or warn-block a sale because stock is 0 or below: the bar must
|
||
keep serving, the count was simply off.
|
||
- If the product table already has a stock field, use it instead of adding a new one, and map its meaning to
|
||
the rules above (e.g. a separate "track stock" flag → send `null` when tracking is off).
|
||
|
||
### 2. Change Stock Together with the Tab Events
|
||
|
||
Every time the app writes a tab event for a line, change the stock of that product **in the same database
|
||
transaction** (only when the product's stock is not `null`):
|
||
|
||
| What happens on the tab | Event written (already exists) | Stock change |
|
||
|---|---|---|
|
||
| Product added / quantity increased by N | `line_added` with `quantity: N` | `stock -= N` |
|
||
| Quantity decreased by N / line removed | `line_removed` with `quantity: N` | `stock += N` |
|
||
| Price of a line changed | `line_removed` + `line_added` | net zero (do both, they cancel out) |
|
||
| Tab deleted while it still has lines | `line_removed` per line | `stock += quantity` per line |
|
||
| Tab settled (locally or by a remote `close_tab`) | `settled` | **no change** — the drinks were already counted down when they were added |
|
||
| Customer renamed | none | none |
|
||
|
||
Lines are matched to products by the product the line was created from. If a line has no link to a product
|
||
(e.g. a free-text line, or the product was deleted since), change no stock for it.
|
||
|
||
Put this in the same helper that writes the line events (e.g. `TabEventFactory` or the methods on
|
||
`BarTabService`), so a line event and its stock change can never drift apart.
|
||
|
||
Optional, if it fits the existing UI: show the stock on the product buttons when tracked (e.g. a small
|
||
number in a corner, red at 0 or below). Optional too: a local "set stock" action on the product management
|
||
screen — a local change is simply part of the stock that the next sync reports.
|
||
|
||
### 3. Report the Catalog in the Sync
|
||
|
||
Add a `products` field to the sync request with **all** products (also untracked ones and products that are
|
||
hidden/inactive but still exist), every sync:
|
||
|
||
```json
|
||
{
|
||
"deviceId": "…",
|
||
"openTabs": [ … ],
|
||
"events": [ … ],
|
||
"products": [
|
||
{ "id": "12", "name": "Cola", "category": "Frisdrank", "priceInCents": 250, "stock": 48 },
|
||
{ "id": "13", "name": "Water", "category": null, "priceInCents": 200, "stock": null }
|
||
],
|
||
"stockAdjustmentResults": [ … ]
|
||
}
|
||
```
|
||
|
||
| Field | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | string | The product's stable id. If it is an int in the app, send `id.toString()`. Max 100 chars. |
|
||
| `name` | string | As shown on the terminal. Not empty, max 200 chars. |
|
||
| `category` | string or null | The product's category/group name if the app has one, else `null` |
|
||
| `priceInCents` | int ≥ 0 | Current price |
|
||
| `stock` | int or null | Current stock, may be negative; `null` when not tracked. **Always send the key**, also when null. |
|
||
|
||
- This is the **complete** list: a product left out is removed from the dashboard. Never send a partial list.
|
||
- Read the products in the same database read as the open tabs and events (after applying the stock
|
||
adjustments of the previous response, see step 4), so the stock matches the events that are sent.
|
||
- At most 2000 products. The server skips an invalid product (and logs it) instead of failing the sync.
|
||
|
||
### 4. Apply Stock Adjustments from the Response
|
||
|
||
The sync response now contains:
|
||
|
||
```json
|
||
{
|
||
"stockAdjustments": [
|
||
{ "id": "5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c", "productId": "12", "type": "add", "quantity": 24,
|
||
"note": "Delivery brewery", "requestedBy": "bram@example.com", "createdAt": "2026-10-24T15:02:11.000Z" },
|
||
{ "id": "6f1c9c5f-2e3d-4a4b-8c9d-8e7f6a5b4c3d", "productId": "13", "type": "set", "quantity": 30,
|
||
"note": null, "requestedBy": "bram@example.com", "createdAt": "2026-10-24T15:03:40.000Z" }
|
||
],
|
||
"acknowledgedStockAdjustmentResultIds": [ "…" ],
|
||
"rejectedStockAdjustmentResults": [ { "id": "…", "reason": "…" } ]
|
||
}
|
||
```
|
||
|
||
- `type: "add"` → `stock += quantity`. `quantity` is never 0 and **can be negative** (stock taken out:
|
||
breakage, own use).
|
||
- `type: "set"` → `stock = quantity` (a stock count, always ≥ 0). On a product whose stock is `null` this
|
||
**starts tracking** it.
|
||
- They arrive **oldest first**. Apply them in that order.
|
||
- The server sends **every** pending adjustment on **every** sync until it has received the outcome. The same
|
||
adjustment will therefore arrive several times: **apply each id only once**.
|
||
|
||
Add an outbox table for the outcomes, which doubles as the record of what was already applied:
|
||
|
||
```dart
|
||
class StockAdjustmentResults extends Table {
|
||
TextColumn get id => text()(); // the adjustment id from the server
|
||
TextColumn get payload => text()(); // the result as JSON, see below
|
||
BoolColumn get acknowledged => boolean().withDefault(const Constant(false))();
|
||
DateTimeColumn get createdAt => dateTime()();
|
||
@override
|
||
Set<Column> get primaryKey => {id};
|
||
}
|
||
```
|
||
|
||
For each adjustment in `stockAdjustments`, in order, in **one database transaction per adjustment**:
|
||
|
||
1. If a row with this id exists in `StockAdjustmentResults` → skip it (already applied or rejected).
|
||
2. Look up the product by `productId`:
|
||
- Not found → result `{ "id", "status": "rejected", "reason": "unknown_product" }`.
|
||
- `type == "add"` and the product's stock is `null` → result
|
||
`{ "id", "status": "rejected", "reason": "stock_not_tracked" }`.
|
||
- Otherwise apply it and write result `{ "id", "status": "applied", "stockAfter": <the new stock> }`.
|
||
3. Insert the result row (`acknowledged = false`) in the same transaction as the stock change, so a crash can
|
||
never apply an adjustment without remembering it (it would be applied twice) or the other way around.
|
||
|
||
The reasons `unknown_product` and `stock_not_tracked` are shown translated on the dashboard; any other
|
||
reason string is shown as is.
|
||
|
||
If any adjustment was applied, start the next sync right away (instead of waiting for the normal interval),
|
||
so the dashboard shows the new stock within seconds. Don't loop: one extra sync is enough.
|
||
|
||
### 5. Send the Outcomes and Clean Up
|
||
|
||
In the sync request, add `stockAdjustmentResults` with the payloads of the **oldest 500** rows where
|
||
`acknowledged = false` (ordered by `createdAt`):
|
||
|
||
```json
|
||
"stockAdjustmentResults": [
|
||
{ "id": "5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c", "status": "applied", "stockAfter": 72 },
|
||
{ "id": "6f1c9c5f-2e3d-4a4b-8c9d-8e7f6a5b4c3d", "status": "rejected", "reason": "unknown_product" }
|
||
]
|
||
```
|
||
|
||
After a successful sync:
|
||
- Set `acknowledged = true` for every id in `acknowledgedStockAdjustmentResultIds`.
|
||
- Do the same for every id in `rejectedStockAdjustmentResults` and log `id` + `reason` (debug log is fine):
|
||
re-sending won't help, it's a bug in the app.
|
||
- **Don't delete** acknowledged rows right away: they are the record that prevents applying an adjustment
|
||
twice if it is still in a response that was computed before the server saw the outcome. Delete
|
||
acknowledged rows older than 30 days (e.g. once per app start).
|
||
|
||
If the sync request fails (network, 5xx), change nothing; the same results are sent again next time. The
|
||
server records each outcome once, so re-sending is safe. A late outcome is still recorded on the server even
|
||
if the adjustment was cancelled on the dashboard in the meantime: what the terminal did is the truth.
|
||
|
||
### Order Within One Sync
|
||
|
||
1. Read events, open tabs, **products** and unacknowledged **stock results** (one consistent read).
|
||
2. Send the request.
|
||
3. On success: delete acknowledged/rejected events (existing), mark acknowledged/rejected stock results.
|
||
4. Process commands (existing), planned tab names (existing), then **apply `stockAdjustments`**.
|
||
5. If any adjustment was applied, schedule one immediate follow-up sync.
|
||
|
||
### Models (`lib/models/remote_command.dart`)
|
||
|
||
Add to the request: `List<Map<String, dynamic>> products` and `List<Map<String, dynamic>> stockAdjustmentResults`.
|
||
|
||
Add to `SyncResponse` (all optional, default to empty lists so an older server still works):
|
||
|
||
```dart
|
||
final List<StockAdjustment> stockAdjustments; // json['stockAdjustments']
|
||
final List<String> acknowledgedStockAdjustmentResultIds; // json['acknowledgedStockAdjustmentResultIds']
|
||
final List<RejectedEvent> rejectedStockAdjustmentResults; // json['rejectedStockAdjustmentResults']: [{ "id": String?, "reason": String }]
|
||
```
|
||
|
||
```dart
|
||
class StockAdjustment {
|
||
final String id;
|
||
final String productId;
|
||
final String type; // 'add' | 'set'; ignore (don't apply, don't report) any other type
|
||
final int quantity;
|
||
final String? note;
|
||
final String requestedBy;
|
||
final DateTime createdAt;
|
||
}
|
||
```
|
||
|
||
Reuse the existing `RejectedEvent` class for `rejectedStockAdjustmentResults` (same shape).
|
||
|
||
### Important Notes
|
||
|
||
- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`.
|
||
- Stock is a whole number of pieces (bottles, cans, glasses…), never a fraction.
|
||
- Stock tracking must never slow down or block putting drinks on a tab.
|
||
- Keep all of this in the local database, never only in memory: an adjustment received just before the tablet
|
||
dies must not be applied twice or lost.
|
||
- Don't change how tabs, versions, `openTabs`, `events`, commands or planned tabs work.
|
||
|
||
### Testing
|
||
|
||
Unit tests:
|
||
- Adding 3 Cola to a tab lowers Cola's stock by 3; reducing the line to 1 raises it by 2; deleting the tab with
|
||
the line raises it by 1. All in the same transaction as the matching event.
|
||
- Settling a tab (locally and via `close_tab`) does not change stock.
|
||
- A product with `stock == null` never changes on sales.
|
||
- Stock can go below 0 and the sale still goes through.
|
||
- `add` +24 on stock 48 → 72, result `applied` with `stockAfter: 72`. `add` −6 → 42.
|
||
- `set` 30 on an untracked product → stock 30 (tracking starts), result `applied`.
|
||
- `add` on an untracked product → `rejected` / `stock_not_tracked`, stock stays `null`.
|
||
- Unknown `productId` → `rejected` / `unknown_product`.
|
||
- The same adjustment received twice (also in two different sync responses) is applied once.
|
||
- The sync sends the complete product list (stock `null` included as a key) and at most 500 unacknowledged
|
||
results, oldest first; it marks exactly the acknowledged and rejected ids; a failed sync changes nothing.
|
||
|
||
Manual check against the server:
|
||
1. Sync once: the dashboard's Stock page lists all products, all "Not tracked".
|
||
2. On the dashboard click **Track stock** on Cola and count 48. Within a sync or two the terminal shows 48 and
|
||
the dashboard history shows the change as **Applied** with "→ 48".
|
||
3. Put 2 Cola on a tab: after the next sync the dashboard shows 46.
|
||
4. **Register delivery** with 24 Cola: the terminal shows 70.
|
||
5. Turn off wifi, send a delivery from the dashboard, sell a few Cola on the terminal, turn wifi back on: the
|
||
delivery is applied once, the sales are counted down, and the dashboard ends up with the same number as the
|
||
terminal.
|
||
6. On the server: `SELECT "productName", type, quantity, status, "stockAfter", result FROM "StockAdjustment" ORDER BY "createdAt";`
|
||
|
||
### Files to Modify
|
||
|
||
- `lib/database/app_database.dart` — `stock` on the product table (if missing), `StockAdjustmentResults` table,
|
||
schema version and migration
|
||
- `lib/services/bar_tab_service.dart` (and the event helper) — change stock together with every line event
|
||
- `lib/models/remote_command.dart` — `products` and `stockAdjustmentResults` in the sync request;
|
||
`stockAdjustments`, `acknowledgedStockAdjustmentResultIds` and `rejectedStockAdjustmentResults` in `SyncResponse`
|
||
- `lib/services/remote_sync_service.dart` — send products and results, apply adjustments, mark results
|
||
acknowledged, prune old ones, one immediate follow-up sync after applying
|
||
- Optional: the product grid / product management screen to show (and locally set) stock
|