14 KiB
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:
- 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.
- Report the catalog: send the complete product list with stock in every sync.
- 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,SnapshotTaband the sync requestlib/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 outboxlib/database/app_database.dart— the Drift database schema and migrations (has theTabEventsoutbox)- 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_addedorline_removedevent. 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):
IntColumn get stock => integer().nullable()(); // null = stock is not tracked for this product
nullmeans not tracked: nothing happens to it on sales, and it is shown as "not tracked" on the dashboard. All existing products start asnull.- 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
nullwhen 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:
{
"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:
{
"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.quantityis 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 isnullthis 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:
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:
- If a row with this id exists in
StockAdjustmentResults→ skip it (already applied or rejected). - Look up the product by
productId:- Not found → result
{ "id", "status": "rejected", "reason": "unknown_product" }. type == "add"and the product's stock isnull→ result{ "id", "status": "rejected", "reason": "stock_not_tracked" }.- Otherwise apply it and write result
{ "id", "status": "applied", "stockAfter": <the new stock> }.
- Not found → result
- 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):
"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 = truefor every id inacknowledgedStockAdjustmentResultIds. - Do the same for every id in
rejectedStockAdjustmentResultsand logid+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
- Read events, open tabs, products and unacknowledged stock results (one consistent read).
- Send the request.
- On success: delete acknowledged/rejected events (existing), mark acknowledged/rejected stock results.
- Process commands (existing), planned tab names (existing), then apply
stockAdjustments. - 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):
final List<StockAdjustment> stockAdjustments; // json['stockAdjustments']
final List<String> acknowledgedStockAdjustmentResultIds; // json['acknowledgedStockAdjustmentResultIds']
final List<RejectedEvent> rejectedStockAdjustmentResults; // json['rejectedStockAdjustmentResults']: [{ "id": String?, "reason": String }]
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 == nullnever changes on sales. - Stock can go below 0 and the sale still goes through.
add+24 on stock 48 → 72, resultappliedwithstockAfter: 72.add−6 → 42.set30 on an untracked product → stock 30 (tracking starts), resultapplied.addon an untracked product →rejected/stock_not_tracked, stock staysnull.- 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
nullincluded 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:
- Sync once: the dashboard's Stock page lists all products, all "Not tracked".
- 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".
- Put 2 Cola on a tab: after the next sync the dashboard shows 46.
- Register delivery with 24 Cola: the terminal shows 70.
- 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.
- On the server:
SELECT "productName", type, quantity, status, "stockAfter", result FROM "StockAdjustment" ORDER BY "createdAt";
Files to Modify
lib/database/app_database.dart—stockon the product table (if missing),StockAdjustmentResultstable, schema version and migrationlib/services/bar_tab_service.dart(and the event helper) — change stock together with every line eventlib/models/remote_command.dart—productsandstockAdjustmentResultsin the sync request;stockAdjustments,acknowledgedStockAdjustmentResultIdsandrejectedStockAdjustmentResultsinSyncResponselib/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