feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 14:45:22 +02:00
parent ee06af8395
commit 1e4438ec5a
24 changed files with 1734 additions and 14 deletions
+22
View File
@@ -55,6 +55,20 @@ Sync semantics:
The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync)
and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its
outbox. An invalid event never fails the sync. At most 1000 events per sync.
- `products` (optional) is the terminal's **complete** product catalog:
`[{ id, name, category?, priceInCents, stock }]`, where `stock` is an integer (may be negative) or `null` when
the terminal doesn't track stock for that product. Products left out are removed from the dashboard. When the
field is omitted (older app versions) the stored products are left alone. An invalid product is skipped and
logged, never failing the sync. At most 2000 products.
- `stockAdjustments` in the response lists **every** pending stock change requested from the dashboard
for the venue, oldest first: `[{ id, productId, type, quantity, note, requestedBy, createdAt }]`.
`type: "add"` changes the stock by `quantity` (negative takes stock out); `type: "set"` makes the stock
`quantity` (a count, ≥ 0, and starts tracking an untracked product). They are re-sent on every sync until the
terminal reports an outcome, so the terminal must apply each id only once.
- `stockAdjustmentResults` (optional) reports outcomes: `[{ id, status: "applied" | "rejected", stockAfter?, reason? }]`
(`stockAfter` is required for `applied`). The response lists `acknowledgedStockAdjustmentResultIds` and
`rejectedStockAdjustmentResults` (`[{ id, reason }]`); the terminal drops both from its outbox. The terminal's
outcome is recorded even if the adjustment was cancelled on the dashboard in the meantime.
### Tabs
`GET /venues/:venueId/tabs` - List open tabs
@@ -75,6 +89,14 @@ Names match case-insensitively and keep their existing spelling; `replace` also
`GET /venues/:venueId/reminders?customerName=` - Reminder history, optionally for one customer
`POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin)
### Stock
`GET /venues/:venueId/stock` - The terminal's products with stock, low stock level and pending adjustments
`GET /venues/:venueId/stock/adjustments?productId=&limit=&offset=` - Stock adjustment history, newest first
`POST /venues/:venueId/stock/adjustments` - Request stock changes (admins and viewers): `{ adjustments: [{ productId, type: 'add' | 'set', quantity, note? }] }`.
`add` takes a non-zero quantity (negative for breakage) and needs a tracked product; `set` takes 0 or more.
`POST /venues/:venueId/stock/adjustments/:id/cancel` - Cancel an adjustment the terminal hasn't received yet (admins and viewers)
`PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only)
### Commands
`POST /venues/:venueId/commands` - Create a command
`GET /venues/:venueId/commands` - List command history
+268
View File
@@ -0,0 +1,268 @@
## 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