diff --git a/docs/server-stock-source-of-truth.md b/docs/server-stock-source-of-truth.md new file mode 100644 index 0000000..a9568b0 --- /dev/null +++ b/docs/server-stock-source-of-truth.md @@ -0,0 +1,133 @@ +## 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. diff --git a/docs/server-tabs-source-of-truth.md b/docs/server-tabs-source-of-truth.md new file mode 100644 index 0000000..d007d18 --- /dev/null +++ b/docs/server-tabs-source-of-truth.md @@ -0,0 +1,140 @@ +## KoolTap Server — Hold the Open Tabs (Source of Truth), Settle from the Dashboard + +Until now every tablet owned its own tabs: it sent them as `openTabs` and never received tabs back. A newly paired +tablet started empty, and two tablets in one venue never saw each other's tabs. + +From now on the **server holds the venue's open tabs**. Tablets keep working fully offline: every change is made +locally first and queued as an **event** in the tablet's outbox. On every sync the server applies the events it +receives, and answers with the venue's **complete** open tab state. The tablet replaces its local tabs with that state +and replays the events the server hasn't applied yet on top. + +The app side is done (KoolTap app, `lib/services/remote_sync_service.dart`, `lib/services/bar_tab_service.dart`). +Until the server sends `tabs`, the app behaves exactly as before. + +### 1. Events (request `events`, unchanged transport) + +Events arrive in `events`, oldest first, at most 500 per sync. Acknowledge/reject them exactly as now +(`acknowledgedEventIds`, `rejectedEvents`). **Apply each event id once**: a tablet resends an event until it is +acknowledged, and an acknowledged id can still arrive again if the response got lost. + +Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt` (ISO 8601 UTC). + +| `type` | Extra fields | New? | +|---|---|---| +| `tab_opened` | `openedAt` | **new** | +| `tab_renamed` | — (`customerName` is the new name) | **new** | +| `tab_deleted` | — (always preceded by a `line_removed` for every line still on the tab) | **new** | +| `line_added` | `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) | `lineId`, `productId` new | +| `line_removed` | same as `line_added` | `lineId`, `productId` new | +| `settled` | `amountInCents`, `paymentMethod`, `commandId?`, `items[{ productId, productName, quantity, unitPriceInCents }]` | `productId` in items new | + +Events created by an older app version may lack `lineId` / `productId`; match those by name (see below). + +### 2. The venue's tab state + +``` +Tab { id, customerName, openedAt, version, lines: Line[] } +Line { id, productId, productName, unitPriceInCents, quantity, units: Unit[] } +Unit { id, addedAt, removedAt | null } // one per piece ever put on the line +``` + +`quantity` = number of units with `removedAt == null`. Units are what lets every tablet show each drink with its time +(also struck-through drinks that were taken back). + +**Finding the line of an event** (on the event's tab): the line with `id == lineId`; else the oldest line with the same +`productId` and `unitPriceInCents`; else the oldest line with the same `productName` and `unitPriceInCents`. + +**Applying events** (each applied event bumps the tab's `version` by 1): + +| Event | Rule | +|---|---| +| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). | +| `tab_renamed` | Set `customerName`. Unknown tab → ignore (acknowledge). | +| `tab_deleted` | Remove the tab and its lines. Unknown tab → ignore. | +| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = ":"` (index 0…quantity−1) and `addedAt = occurredAt`. | +| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). | +| `settled` | For each item: find the line; take `min(item.quantity, line.quantity)` of the **oldest** active units off it, together with the line's removed units; drop the line when nothing is left. Drinks added after the settlement (by another tablet) **stay on the tab**. Record a settlement (section 4) with id = the event's `id`. The tab itself stays open (tabs are never closed, only emptied). | + +The unit ids **must** follow `":"`: the tablet gives replayed units the same ids, so a sync that +changes nothing changes nothing on screen. + +Stock is applied in the same transaction, see `server-stock-source-of-truth.md`. + +### 3. Sync response: `tabs` + +```json +{ + "tabs": [ + { "id": "…", "customerName": "Alice", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7, + "lines": [ + { "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1, + "units": [ { "id": ":0", "addedAt": "…", "removedAt": null }, + { "id": ":1", "addedAt": "…", "removedAt": "…" } ] } ] } + ] +} +``` + +- The **complete** list of the venue's open tabs (also empty ones) **after** applying the events acknowledged in this + same response. A tab left out disappears from the tablet. +- Events from this request that you reject must **not** be applied: the tablet drops them. +- Send `tabs` on every successful sync. Leaving the key out means "this server doesn't hold tabs" (old behaviour). +- `openTabs` in the request is the tablet's own view; ignore it once `tabs` is sent (it will be removed later). + +### 4. Settlements feed: `settlements` + `settlementsCursor` + +Every tablet's History screen shows all settlements of the venue, also offline. + +Request: `settlementsCursor` (string, absent on the first sync after pairing). +Response: + +```json +{ + "settlements": [ + { "id": "…", "tabId": "…", "customerName": "Alice", "settledAt": "…", "paymentMethod": "payconiq", + "lines": [ { "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 2, + "units": [ { "id": "…", "addedAt": "…", "removedAt": null } ] } ] } + ], + "settlementsCursor": "opaque" +} +``` + +- Settlements made **anywhere** (this tablet, other tablets, the dashboard) after the cursor, oldest first, at most + 500 per sync; the tablet stores the returned cursor and sends it back next time. +- Without a cursor: the settlements of the last 30 days (or fewer; this only fills a new tablet's history). +- A settlement made on a tablet has the `settled` event's id; the tablet already has those and skips them by id. Include + them anyway: the feed doesn't need to know where a settlement came from. +- `lines[].units` = the units that were settled, plus removed (struck-through) units of those lines. +- `paymentMethod`: `cash`, `payconiq` or `payment_pending`. + +### 5. Dashboard: settle and planned tabs + +- **Close tab on the dashboard**: the server settles the tab itself, immediately, also when every tablet is offline. + Apply it like a `settled` event covering all active units (record `requestedBy`), bump `version`, and add it to the + settlements feed. Tablets see the lines disappear on their next sync. **Stop sending `close_tab` commands** to + tablets for this (the app still handles them, for older servers). + Use the tab's `version` as an optimistic check if the dashboard confirms a total: refuse when it changed since. +- **Planned tab names**: create them as tabs in the server state. Once `tabs` is sent, the app ignores + `plannedTabNames` (otherwise every tablet would open them). + +### Order within one sync + +1. Apply the received events (once per id), oldest first; acknowledge/reject them. +2. Build `tabs` from the resulting state, and `settlements` after the request's cursor. +3. Commands, stock adjustments etc. unchanged. + +### Not in this change + +- **Stock with several tablets**: see `server-stock-source-of-truth.md`. + +### Testing + +- Pair a second tablet: it shows the venue's open tabs on its first sync. +- Tablet A adds 2 Cola to Alice, tablet B adds 1: both show 3, dashboard shows 3. +- Tablet A offline: adds drinks, opens a tab, renames one, deletes one. Back online: everything arrives once, both + tablets and the dashboard agree. +- Tablet A (offline) settles Alice with 2 Cola while tablet B adds a third: after both synced, Alice has 1 Cola left and + the settlement has 2. +- Tablet B deletes Bob while offline tablet A adds a beer to Bob: Bob comes back with that beer. +- Settle on the dashboard with every tablet off: the tab is emptied at once; the tablets show it in History after their + next sync, exactly once. +- The same event id sent twice (also in two syncs) is applied once. diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 04319c7..c59642e 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -162,6 +162,9 @@ class RemoteSyncState extends Table { TextColumn get serverCursor => text().nullable()(); + /// Where the feed of settlements made anywhere in the venue left off. + TextColumn get settlementsCursor => text().nullable()(); + DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()(); DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()(); @@ -204,6 +207,26 @@ class TabEvents extends Table { Set get primaryKey => {id}; } +/// Outcomes of the stock adjustments sent by the dashboard, waiting to be +/// reported. A row also records that its adjustment was handled, so it is +/// kept for a while after the server acknowledges it: the same adjustment +/// can still arrive in a response computed before the server saw the outcome. +@DataClassName('StockAdjustmentResultRow') +class StockAdjustmentResults extends Table { + /// The adjustment's id from the server. + TextColumn get id => text()(); + + /// The outcome as JSON, exactly as it is sent. + TextColumn get payload => text()(); + + BoolColumn get acknowledged => boolean().withDefault(const Constant(false))(); + + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} + @DriftDatabase( tables: [ Products, @@ -216,13 +239,14 @@ class TabEvents extends Table { RemoteSyncState, ProcessedRemoteCommands, TabEvents, + StockAdjustmentResults, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 15; + int get schemaVersion => 17; @override MigrationStrategy get migration { @@ -325,6 +349,19 @@ class AppDatabase extends _$AppDatabase { if (from < 15) { await migrator.createTable(tabEvents); } + + if (from < 16) { + await migrator.createTable(stockAdjustmentResults); + } + + if (from < 17 && + await hasTableColumn('remote_sync_state', 'device_id') && + !await hasTableColumn('remote_sync_state', 'settlements_cursor')) { + await migrator.addColumn( + remoteSyncState, + remoteSyncState.settlementsCursor, + ); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index ede97eb..c9d0d08 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -3060,6 +3060,18 @@ class $RemoteSyncStateTable extends RemoteSyncState type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _settlementsCursorMeta = const VerificationMeta( + 'settlementsCursor', + ); + @override + late final GeneratedColumn settlementsCursor = + GeneratedColumn( + 'settlements_cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _lastSuccessfulSyncAtMeta = const VerificationMeta('lastSuccessfulSyncAt'); @override @@ -3098,6 +3110,7 @@ class $RemoteSyncStateTable extends RemoteSyncState List get $columns => [ deviceId, serverCursor, + settlementsCursor, lastSuccessfulSyncAt, lastAttemptedSyncAt, consecutiveFailureCount, @@ -3131,6 +3144,15 @@ class $RemoteSyncStateTable extends RemoteSyncState ), ); } + if (data.containsKey('settlements_cursor')) { + context.handle( + _settlementsCursorMeta, + settlementsCursor.isAcceptableOrUnknown( + data['settlements_cursor']!, + _settlementsCursorMeta, + ), + ); + } if (data.containsKey('last_successful_sync_at')) { context.handle( _lastSuccessfulSyncAtMeta, @@ -3175,6 +3197,10 @@ class $RemoteSyncStateTable extends RemoteSyncState DriftSqlType.string, data['${effectivePrefix}server_cursor'], ), + settlementsCursor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}settlements_cursor'], + ), lastSuccessfulSyncAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}last_successful_sync_at'], @@ -3200,12 +3226,16 @@ class RemoteSyncStateRow extends DataClass implements Insertable { final String deviceId; final String? serverCursor; + + /// Where the feed of settlements made anywhere in the venue left off. + final String? settlementsCursor; final DateTime? lastSuccessfulSyncAt; final DateTime? lastAttemptedSyncAt; final int consecutiveFailureCount; const RemoteSyncStateRow({ required this.deviceId, this.serverCursor, + this.settlementsCursor, this.lastSuccessfulSyncAt, this.lastAttemptedSyncAt, required this.consecutiveFailureCount, @@ -3217,6 +3247,9 @@ class RemoteSyncStateRow extends DataClass if (!nullToAbsent || serverCursor != null) { map['server_cursor'] = Variable(serverCursor); } + if (!nullToAbsent || settlementsCursor != null) { + map['settlements_cursor'] = Variable(settlementsCursor); + } if (!nullToAbsent || lastSuccessfulSyncAt != null) { map['last_successful_sync_at'] = Variable(lastSuccessfulSyncAt); } @@ -3233,6 +3266,9 @@ class RemoteSyncStateRow extends DataClass serverCursor: serverCursor == null && nullToAbsent ? const Value.absent() : Value(serverCursor), + settlementsCursor: settlementsCursor == null && nullToAbsent + ? const Value.absent() + : Value(settlementsCursor), lastSuccessfulSyncAt: lastSuccessfulSyncAt == null && nullToAbsent ? const Value.absent() : Value(lastSuccessfulSyncAt), @@ -3251,6 +3287,9 @@ class RemoteSyncStateRow extends DataClass return RemoteSyncStateRow( deviceId: serializer.fromJson(json['deviceId']), serverCursor: serializer.fromJson(json['serverCursor']), + settlementsCursor: serializer.fromJson( + json['settlementsCursor'], + ), lastSuccessfulSyncAt: serializer.fromJson( json['lastSuccessfulSyncAt'], ), @@ -3268,6 +3307,7 @@ class RemoteSyncStateRow extends DataClass return { 'deviceId': serializer.toJson(deviceId), 'serverCursor': serializer.toJson(serverCursor), + 'settlementsCursor': serializer.toJson(settlementsCursor), 'lastSuccessfulSyncAt': serializer.toJson( lastSuccessfulSyncAt, ), @@ -3281,12 +3321,16 @@ class RemoteSyncStateRow extends DataClass RemoteSyncStateRow copyWith({ String? deviceId, Value serverCursor = const Value.absent(), + Value settlementsCursor = const Value.absent(), Value lastSuccessfulSyncAt = const Value.absent(), Value lastAttemptedSyncAt = const Value.absent(), int? consecutiveFailureCount, }) => RemoteSyncStateRow( deviceId: deviceId ?? this.deviceId, serverCursor: serverCursor.present ? serverCursor.value : this.serverCursor, + settlementsCursor: settlementsCursor.present + ? settlementsCursor.value + : this.settlementsCursor, lastSuccessfulSyncAt: lastSuccessfulSyncAt.present ? lastSuccessfulSyncAt.value : this.lastSuccessfulSyncAt, @@ -3302,6 +3346,9 @@ class RemoteSyncStateRow extends DataClass serverCursor: data.serverCursor.present ? data.serverCursor.value : this.serverCursor, + settlementsCursor: data.settlementsCursor.present + ? data.settlementsCursor.value + : this.settlementsCursor, lastSuccessfulSyncAt: data.lastSuccessfulSyncAt.present ? data.lastSuccessfulSyncAt.value : this.lastSuccessfulSyncAt, @@ -3319,6 +3366,7 @@ class RemoteSyncStateRow extends DataClass return (StringBuffer('RemoteSyncStateRow(') ..write('deviceId: $deviceId, ') ..write('serverCursor: $serverCursor, ') + ..write('settlementsCursor: $settlementsCursor, ') ..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ') ..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ') ..write('consecutiveFailureCount: $consecutiveFailureCount') @@ -3330,6 +3378,7 @@ class RemoteSyncStateRow extends DataClass int get hashCode => Object.hash( deviceId, serverCursor, + settlementsCursor, lastSuccessfulSyncAt, lastAttemptedSyncAt, consecutiveFailureCount, @@ -3340,6 +3389,7 @@ class RemoteSyncStateRow extends DataClass (other is RemoteSyncStateRow && other.deviceId == this.deviceId && other.serverCursor == this.serverCursor && + other.settlementsCursor == this.settlementsCursor && other.lastSuccessfulSyncAt == this.lastSuccessfulSyncAt && other.lastAttemptedSyncAt == this.lastAttemptedSyncAt && other.consecutiveFailureCount == this.consecutiveFailureCount); @@ -3348,6 +3398,7 @@ class RemoteSyncStateRow extends DataClass class RemoteSyncStateCompanion extends UpdateCompanion { final Value deviceId; final Value serverCursor; + final Value settlementsCursor; final Value lastSuccessfulSyncAt; final Value lastAttemptedSyncAt; final Value consecutiveFailureCount; @@ -3355,6 +3406,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { const RemoteSyncStateCompanion({ this.deviceId = const Value.absent(), this.serverCursor = const Value.absent(), + this.settlementsCursor = const Value.absent(), this.lastSuccessfulSyncAt = const Value.absent(), this.lastAttemptedSyncAt = const Value.absent(), this.consecutiveFailureCount = const Value.absent(), @@ -3363,6 +3415,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { RemoteSyncStateCompanion.insert({ required String deviceId, this.serverCursor = const Value.absent(), + this.settlementsCursor = const Value.absent(), this.lastSuccessfulSyncAt = const Value.absent(), this.lastAttemptedSyncAt = const Value.absent(), this.consecutiveFailureCount = const Value.absent(), @@ -3371,6 +3424,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { static Insertable custom({ Expression? deviceId, Expression? serverCursor, + Expression? settlementsCursor, Expression? lastSuccessfulSyncAt, Expression? lastAttemptedSyncAt, Expression? consecutiveFailureCount, @@ -3379,6 +3433,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { return RawValuesInsertable({ if (deviceId != null) 'device_id': deviceId, if (serverCursor != null) 'server_cursor': serverCursor, + if (settlementsCursor != null) 'settlements_cursor': settlementsCursor, if (lastSuccessfulSyncAt != null) 'last_successful_sync_at': lastSuccessfulSyncAt, if (lastAttemptedSyncAt != null) @@ -3392,6 +3447,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { RemoteSyncStateCompanion copyWith({ Value? deviceId, Value? serverCursor, + Value? settlementsCursor, Value? lastSuccessfulSyncAt, Value? lastAttemptedSyncAt, Value? consecutiveFailureCount, @@ -3400,6 +3456,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { return RemoteSyncStateCompanion( deviceId: deviceId ?? this.deviceId, serverCursor: serverCursor ?? this.serverCursor, + settlementsCursor: settlementsCursor ?? this.settlementsCursor, lastSuccessfulSyncAt: lastSuccessfulSyncAt ?? this.lastSuccessfulSyncAt, lastAttemptedSyncAt: lastAttemptedSyncAt ?? this.lastAttemptedSyncAt, consecutiveFailureCount: @@ -3417,6 +3474,9 @@ class RemoteSyncStateCompanion extends UpdateCompanion { if (serverCursor.present) { map['server_cursor'] = Variable(serverCursor.value); } + if (settlementsCursor.present) { + map['settlements_cursor'] = Variable(settlementsCursor.value); + } if (lastSuccessfulSyncAt.present) { map['last_successful_sync_at'] = Variable( lastSuccessfulSyncAt.value, @@ -3443,6 +3503,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion { return (StringBuffer('RemoteSyncStateCompanion(') ..write('deviceId: $deviceId, ') ..write('serverCursor: $serverCursor, ') + ..write('settlementsCursor: $settlementsCursor, ') ..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ') ..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ') ..write('consecutiveFailureCount: $consecutiveFailureCount, ') @@ -4060,6 +4121,332 @@ class TabEventsCompanion extends UpdateCompanion { } } +class $StockAdjustmentResultsTable extends StockAdjustmentResults + with TableInfo<$StockAdjustmentResultsTable, StockAdjustmentResultRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $StockAdjustmentResultsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _payloadMeta = const VerificationMeta( + 'payload', + ); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _acknowledgedMeta = const VerificationMeta( + 'acknowledged', + ); + @override + late final GeneratedColumn acknowledged = GeneratedColumn( + 'acknowledged', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("acknowledged" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, payload, acknowledged, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stock_adjustment_results'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('payload')) { + context.handle( + _payloadMeta, + payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta), + ); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('acknowledged')) { + context.handle( + _acknowledgedMeta, + acknowledged.isAcceptableOrUnknown( + data['acknowledged']!, + _acknowledgedMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + StockAdjustmentResultRow map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StockAdjustmentResultRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + payload: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payload'], + )!, + acknowledged: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}acknowledged'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $StockAdjustmentResultsTable createAlias(String alias) { + return $StockAdjustmentResultsTable(attachedDatabase, alias); + } +} + +class StockAdjustmentResultRow extends DataClass + implements Insertable { + /// The adjustment's id from the server. + final String id; + + /// The outcome as JSON, exactly as it is sent. + final String payload; + final bool acknowledged; + final DateTime createdAt; + const StockAdjustmentResultRow({ + required this.id, + required this.payload, + required this.acknowledged, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['payload'] = Variable(payload); + map['acknowledged'] = Variable(acknowledged); + map['created_at'] = Variable(createdAt); + return map; + } + + StockAdjustmentResultsCompanion toCompanion(bool nullToAbsent) { + return StockAdjustmentResultsCompanion( + id: Value(id), + payload: Value(payload), + acknowledged: Value(acknowledged), + createdAt: Value(createdAt), + ); + } + + factory StockAdjustmentResultRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StockAdjustmentResultRow( + id: serializer.fromJson(json['id']), + payload: serializer.fromJson(json['payload']), + acknowledged: serializer.fromJson(json['acknowledged']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'payload': serializer.toJson(payload), + 'acknowledged': serializer.toJson(acknowledged), + 'createdAt': serializer.toJson(createdAt), + }; + } + + StockAdjustmentResultRow copyWith({ + String? id, + String? payload, + bool? acknowledged, + DateTime? createdAt, + }) => StockAdjustmentResultRow( + id: id ?? this.id, + payload: payload ?? this.payload, + acknowledged: acknowledged ?? this.acknowledged, + createdAt: createdAt ?? this.createdAt, + ); + StockAdjustmentResultRow copyWithCompanion( + StockAdjustmentResultsCompanion data, + ) { + return StockAdjustmentResultRow( + id: data.id.present ? data.id.value : this.id, + payload: data.payload.present ? data.payload.value : this.payload, + acknowledged: data.acknowledged.present + ? data.acknowledged.value + : this.acknowledged, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('StockAdjustmentResultRow(') + ..write('id: $id, ') + ..write('payload: $payload, ') + ..write('acknowledged: $acknowledged, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, payload, acknowledged, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StockAdjustmentResultRow && + other.id == this.id && + other.payload == this.payload && + other.acknowledged == this.acknowledged && + other.createdAt == this.createdAt); +} + +class StockAdjustmentResultsCompanion + extends UpdateCompanion { + final Value id; + final Value payload; + final Value acknowledged; + final Value createdAt; + final Value rowid; + const StockAdjustmentResultsCompanion({ + this.id = const Value.absent(), + this.payload = const Value.absent(), + this.acknowledged = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + StockAdjustmentResultsCompanion.insert({ + required String id, + required String payload, + this.acknowledged = const Value.absent(), + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + payload = Value(payload), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? payload, + Expression? acknowledged, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (payload != null) 'payload': payload, + if (acknowledged != null) 'acknowledged': acknowledged, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + StockAdjustmentResultsCompanion copyWith({ + Value? id, + Value? payload, + Value? acknowledged, + Value? createdAt, + Value? rowid, + }) { + return StockAdjustmentResultsCompanion( + id: id ?? this.id, + payload: payload ?? this.payload, + acknowledged: acknowledged ?? this.acknowledged, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (acknowledged.present) { + map['acknowledged'] = Variable(acknowledged.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StockAdjustmentResultsCompanion(') + ..write('id: $id, ') + ..write('payload: $payload, ') + ..write('acknowledged: $acknowledged, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -4080,6 +4467,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ProcessedRemoteCommandsTable processedRemoteCommands = $ProcessedRemoteCommandsTable(this); late final $TabEventsTable tabEvents = $TabEventsTable(this); + late final $StockAdjustmentResultsTable stockAdjustmentResults = + $StockAdjustmentResultsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -4095,6 +4484,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { remoteSyncState, processedRemoteCommands, tabEvents, + stockAdjustmentResults, ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ @@ -6355,6 +6745,7 @@ typedef $$RemoteSyncStateTableCreateCompanionBuilder = RemoteSyncStateCompanion Function({ required String deviceId, Value serverCursor, + Value settlementsCursor, Value lastSuccessfulSyncAt, Value lastAttemptedSyncAt, Value consecutiveFailureCount, @@ -6364,6 +6755,7 @@ typedef $$RemoteSyncStateTableUpdateCompanionBuilder = RemoteSyncStateCompanion Function({ Value deviceId, Value serverCursor, + Value settlementsCursor, Value lastSuccessfulSyncAt, Value lastAttemptedSyncAt, Value consecutiveFailureCount, @@ -6389,6 +6781,11 @@ class $$RemoteSyncStateTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get settlementsCursor => $composableBuilder( + column: $table.settlementsCursor, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get lastSuccessfulSyncAt => $composableBuilder( column: $table.lastSuccessfulSyncAt, builder: (column) => ColumnFilters(column), @@ -6424,6 +6821,11 @@ class $$RemoteSyncStateTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get settlementsCursor => $composableBuilder( + column: $table.settlementsCursor, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get lastSuccessfulSyncAt => $composableBuilder( column: $table.lastSuccessfulSyncAt, builder: (column) => ColumnOrderings(column), @@ -6457,6 +6859,11 @@ class $$RemoteSyncStateTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get settlementsCursor => $composableBuilder( + column: $table.settlementsCursor, + builder: (column) => column, + ); + GeneratedColumn get lastSuccessfulSyncAt => $composableBuilder( column: $table.lastSuccessfulSyncAt, builder: (column) => column, @@ -6512,6 +6919,7 @@ class $$RemoteSyncStateTableTableManager ({ Value deviceId = const Value.absent(), Value serverCursor = const Value.absent(), + Value settlementsCursor = const Value.absent(), Value lastSuccessfulSyncAt = const Value.absent(), Value lastAttemptedSyncAt = const Value.absent(), Value consecutiveFailureCount = const Value.absent(), @@ -6519,6 +6927,7 @@ class $$RemoteSyncStateTableTableManager }) => RemoteSyncStateCompanion( deviceId: deviceId, serverCursor: serverCursor, + settlementsCursor: settlementsCursor, lastSuccessfulSyncAt: lastSuccessfulSyncAt, lastAttemptedSyncAt: lastAttemptedSyncAt, consecutiveFailureCount: consecutiveFailureCount, @@ -6528,6 +6937,7 @@ class $$RemoteSyncStateTableTableManager ({ required String deviceId, Value serverCursor = const Value.absent(), + Value settlementsCursor = const Value.absent(), Value lastSuccessfulSyncAt = const Value.absent(), Value lastAttemptedSyncAt = const Value.absent(), Value consecutiveFailureCount = const Value.absent(), @@ -6535,6 +6945,7 @@ class $$RemoteSyncStateTableTableManager }) => RemoteSyncStateCompanion.insert( deviceId: deviceId, serverCursor: serverCursor, + settlementsCursor: settlementsCursor, lastSuccessfulSyncAt: lastSuccessfulSyncAt, lastAttemptedSyncAt: lastAttemptedSyncAt, consecutiveFailureCount: consecutiveFailureCount, @@ -6936,6 +7347,208 @@ typedef $$TabEventsTableProcessedTableManager = TabEventRow, PrefetchHooks Function() >; +typedef $$StockAdjustmentResultsTableCreateCompanionBuilder = + StockAdjustmentResultsCompanion Function({ + required String id, + required String payload, + Value acknowledged, + required DateTime createdAt, + Value rowid, + }); +typedef $$StockAdjustmentResultsTableUpdateCompanionBuilder = + StockAdjustmentResultsCompanion Function({ + Value id, + Value payload, + Value acknowledged, + Value createdAt, + Value rowid, + }); + +class $$StockAdjustmentResultsTableFilterComposer + extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> { + $$StockAdjustmentResultsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get acknowledged => $composableBuilder( + column: $table.acknowledged, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$StockAdjustmentResultsTableOrderingComposer + extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> { + $$StockAdjustmentResultsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get acknowledged => $composableBuilder( + column: $table.acknowledged, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$StockAdjustmentResultsTableAnnotationComposer + extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> { + $$StockAdjustmentResultsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get acknowledged => $composableBuilder( + column: $table.acknowledged, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$StockAdjustmentResultsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $StockAdjustmentResultsTable, + StockAdjustmentResultRow, + $$StockAdjustmentResultsTableFilterComposer, + $$StockAdjustmentResultsTableOrderingComposer, + $$StockAdjustmentResultsTableAnnotationComposer, + $$StockAdjustmentResultsTableCreateCompanionBuilder, + $$StockAdjustmentResultsTableUpdateCompanionBuilder, + ( + StockAdjustmentResultRow, + BaseReferences< + _$AppDatabase, + $StockAdjustmentResultsTable, + StockAdjustmentResultRow + >, + ), + StockAdjustmentResultRow, + PrefetchHooks Function() + > { + $$StockAdjustmentResultsTableTableManager( + _$AppDatabase db, + $StockAdjustmentResultsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$StockAdjustmentResultsTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$StockAdjustmentResultsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$StockAdjustmentResultsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value payload = const Value.absent(), + Value acknowledged = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => StockAdjustmentResultsCompanion( + id: id, + payload: payload, + acknowledged: acknowledged, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String payload, + Value acknowledged = const Value.absent(), + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => StockAdjustmentResultsCompanion.insert( + id: id, + payload: payload, + acknowledged: acknowledged, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$StockAdjustmentResultsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $StockAdjustmentResultsTable, + StockAdjustmentResultRow, + $$StockAdjustmentResultsTableFilterComposer, + $$StockAdjustmentResultsTableOrderingComposer, + $$StockAdjustmentResultsTableAnnotationComposer, + $$StockAdjustmentResultsTableCreateCompanionBuilder, + $$StockAdjustmentResultsTableUpdateCompanionBuilder, + ( + StockAdjustmentResultRow, + BaseReferences< + _$AppDatabase, + $StockAdjustmentResultsTable, + StockAdjustmentResultRow + >, + ), + StockAdjustmentResultRow, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -6963,4 +7576,9 @@ class $AppDatabaseManager { ); $$TabEventsTableTableManager get tabEvents => $$TabEventsTableTableManager(_db, _db.tabEvents); + $$StockAdjustmentResultsTableTableManager get stockAdjustmentResults => + $$StockAdjustmentResultsTableTableManager( + _db, + _db.stockAdjustmentResults, + ); } diff --git a/lib/database/stock_queries.dart b/lib/database/stock_queries.dart index 079590b..f67c166 100644 --- a/lib/database/stock_queries.dart +++ b/lib/database/stock_queries.dart @@ -1,14 +1,11 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; +import '../models/remote_command.dart'; +import '../models/tab_event.dart'; import 'app_database.dart'; -class InsufficientStockException implements Exception { - const InsufficientStockException(); - - @override - String toString() => 'Not enough stock'; -} - class ProductNotFoundException implements Exception { final String productId; @@ -20,44 +17,129 @@ class ProductNotFoundException implements Exception { /// Atomic stock updates shared by the product and tab services, so they can /// run inside either one's transaction. +/// +/// Stock may go below zero: the bar keeps serving when the count was off. extension StockQueries on AppDatabase { /// Products with infinite stock are only checked for existence. Future decreaseStock(String productId, int amount) async { _validateStockAmount(amount); - final updatedRows = - await (update(products)..where( - (row) => - row.id.equals(productId) & - row.infiniteStock.not() & - row.stockQuantity.isBiggerOrEqualValue(amount), - )) - .write( - ProductsCompanion.custom( - stockQuantity: products.stockQuantity - Variable(amount), - ), - ); - - if (updatedRows == 1) return; - - final product = await _findProduct(productId); - if (!product.infiniteStock) throw const InsufficientStockException(); + if (await changeTrackedStock(productId, -amount) == 0) { + await _findProduct(productId); + } } Future increaseStock(String productId, int amount) async { _validateStockAmount(amount); - final updatedRows = - await (update(products)..where( - (row) => row.id.equals(productId) & row.infiniteStock.not(), - )) - .write( - ProductsCompanion.custom( - stockQuantity: products.stockQuantity + Variable(amount), - ), - ); + if (await changeTrackedStock(productId, amount) == 0) { + await _findProduct(productId); + } + } - if (updatedRows != 1) await _findProduct(productId); + /// Adds [delta] to the stock of [productId] when its stock is tracked. + /// Returns the number of products changed: 0 when the product has infinite + /// stock or no longer exists, which is not an error. + Future changeTrackedStock(String productId, int delta) { + return (update(products) + ..where((row) => row.id.equals(productId) & row.infiniteStock.not())) + .write( + ProductsCompanion.custom( + stockQuantity: products.stockQuantity + Variable(delta), + ), + ); + } + + /// Queues a `stock_set` event, so a stock count or tracking change made on + /// this tablet reaches the server. Call it inside the transaction that + /// writes the stock. + Future queueStockSet(TabEvent event) async { + await into(tabEvents).insert( + TabEventsCompanion.insert( + id: event.id, + payload: jsonEncode(event.payload), + createdAt: DateTime.now(), + ), + ); + } + + /// Replaces the local stock of the products in [serverStock] with the + /// server's, then replays the outbox events the server hasn't applied yet + /// on top, the way the server will. The events the server acknowledged + /// must have left the outbox first: they are in [serverStock] already. + /// + /// Products the server doesn't know keep their local stock. + Future rebaseStock(List serverStock) { + return transaction(() async { + final stock = { + for (final entry in serverStock) + entry.productId: (stock: entry.stock, countedAt: entry.countedAt), + }; + + // Read here, so changes made while the sync was in flight are included + final outbox = + await (select(tabEvents)..orderBy([ + (row) => OrderingTerm.asc(row.createdAt), + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + for (final row in outbox) { + final event = jsonDecode(row.payload) as Map; + final productId = event['productId'] as String?; + final current = stock[productId]; + if (productId == null || current == null) continue; + + final occurredAt = DateTime.tryParse( + event['occurredAt'] as String? ?? '', + ); + if (occurredAt == null) continue; + final countedAt = current.countedAt; + + switch (TabEventType.fromValue(event['type'] as String? ?? '')) { + // An older count than the server's is outdated + case TabEventType.stockSet: + if (countedAt != null && occurredAt.isBefore(countedAt)) continue; + stock[productId] = ( + stock: event['quantity'] as int?, + countedAt: occurredAt, + ); + // A sale before the count is already in the count + case TabEventType.lineAdded || TabEventType.lineRemoved + when current.stock != null && + (countedAt == null || occurredAt.isAfter(countedAt)): + final quantity = event['quantity'] as int? ?? 0; + final sign = event['type'] == TabEventType.lineAdded.value ? -1 : 1; + stock[productId] = ( + stock: current.stock! + sign * quantity, + countedAt: countedAt, + ); + default: + continue; + } + } + + for (final product in await select(products).get()) { + final rebased = stock[product.id]; + if (rebased == null) continue; + + final infinite = rebased.stock == null; + // An untracked product keeps its last count for when tracking resumes + final quantity = rebased.stock ?? product.stockQuantity; + if (product.infiniteStock == infinite && + product.stockQuantity == quantity) { + continue; + } + + await (update( + products, + )..where((row) => row.id.equals(product.id))).write( + ProductsCompanion( + stockQuantity: Value(quantity), + infiniteStock: Value(infinite), + ), + ); + } + }); } Future _findProduct(String productId) async { diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart index 79531ad..a493a2d 100644 --- a/lib/models/remote_command.dart +++ b/lib/models/remote_command.dart @@ -1,4 +1,5 @@ import '../utils/app_config.dart'; +import 'server_tab.dart'; enum CommandType { closeTab('close_tab'); @@ -74,6 +75,88 @@ class RejectedEvent { } } +enum StockAdjustmentType { + /// `stock += quantity`; quantity may be negative (breakage, own use). + add('add'), + + /// `stock = quantity`, a stock count. Starts tracking an untracked product. + set('set'); + + final String value; + + const StockAdjustmentType(this.value); + + static StockAdjustmentType? fromValue(String value) { + for (final type in StockAdjustmentType.values) { + if (type.value == value) return type; + } + return null; + } +} + +/// A stock change made on the dashboard. The server resends it on every sync +/// until it has received the outcome, so it must be applied only once. +class StockAdjustment { + final String id; + final String productId; + final String rawType; + final int quantity; + final String? note; + final String requestedBy; + final DateTime createdAt; + + const StockAdjustment({ + required this.id, + required this.productId, + required this.rawType, + required this.quantity, + this.note, + required this.requestedBy, + required this.createdAt, + }); + + factory StockAdjustment.fromJson(Map json) { + return StockAdjustment( + id: json['id'] as String, + productId: json['productId'] as String, + rawType: json['type'] as String, + quantity: json['quantity'] as int, + note: json['note'] as String?, + requestedBy: json['requestedBy'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Null for a type this app version doesn't know; such an adjustment is + /// neither applied nor reported. + StockAdjustmentType? get type => StockAdjustmentType.fromValue(rawType); +} + +/// The server's stock of one product, after applying the events it +/// acknowledged. The server owns the stock once it sends these. +class ServerStock { + final String productId; + + /// Null when the stock of this product is not tracked. May be negative. + final int? stock; + + /// When the stock was last counted (set). Line events that happened before + /// it are already in the count and don't change the stock. + final DateTime? countedAt; + + const ServerStock({required this.productId, this.stock, this.countedAt}); + + factory ServerStock.fromJson(Map json) { + final countedAt = json['countedAt'] as String?; + + return ServerStock( + productId: json['productId'] as String, + stock: json['stock'] as int?, + countedAt: countedAt == null ? null : DateTime.parse(countedAt), + ); + } +} + class SyncResponse { final List commands; final String? cursor; @@ -81,12 +164,38 @@ class SyncResponse { final List acknowledgedEventIds; final List rejectedEvents; + /// Oldest first. + final List stockAdjustments; + final List acknowledgedStockAdjustmentResultIds; + final List rejectedStockAdjustmentResults; + + /// The venue's open tabs after applying the acknowledged events: the + /// source of truth. Null from a server that doesn't hold the tabs yet. + final List? tabs; + + /// Settlements made anywhere in the venue since [settlementsCursor] was + /// last sent, oldest first. + final List settlements; + final String? settlementsCursor; + + /// The stock of the venue's products: the source of truth. Null from a + /// server that doesn't own the stock yet; the tablet then counts its own + /// sales and applies [stockAdjustments] itself. + final List? stock; + const SyncResponse({ required this.commands, this.cursor, this.plannedTabNames = const [], this.acknowledgedEventIds = const [], this.rejectedEvents = const [], + this.stockAdjustments = const [], + this.acknowledgedStockAdjustmentResultIds = const [], + this.rejectedStockAdjustmentResults = const [], + this.tabs, + this.settlements = const [], + this.settlementsCursor, + this.stock, }); factory SyncResponse.fromJson(Map json) { @@ -108,8 +217,61 @@ class SyncResponse { for (final event in json['rejectedEvents'] as List? ?? []) RejectedEvent.fromJson(event as Map), ], + stockAdjustments: [ + for (final adjustment + in json['stockAdjustments'] as List? ?? []) + ?_parseStockAdjustment(adjustment), + ], + acknowledgedStockAdjustmentResultIds: [ + for (final id + in json['acknowledgedStockAdjustmentResultIds'] as List? ?? + []) + id as String, + ], + rejectedStockAdjustmentResults: [ + for (final result + in json['rejectedStockAdjustmentResults'] as List? ?? []) + RejectedEvent.fromJson(result as Map), + ], + tabs: switch (json['tabs']) { + final List tabs => [ + for (final tab in tabs) + ServerTab.fromJson(tab as Map), + ], + _ => null, + }, + settlements: [ + for (final settlement in json['settlements'] as List? ?? []) + ServerSettlement.fromJson(settlement as Map), + ], + settlementsCursor: json['settlementsCursor'] as String?, + stock: switch (json['stock']) { + final List stock => [ + for (final entry in stock) ?_parseServerStock(entry), + ], + _ => null, + }, ); } + + /// A malformed entry only leaves that product's local stock as it is. + static ServerStock? _parseServerStock(Object? json) { + try { + return ServerStock.fromJson(json! as Map); + } catch (_) { + return null; + } + } + + /// A malformed adjustment is skipped instead of failing every sync from + /// now on, since the server keeps sending it. + static StockAdjustment? _parseStockAdjustment(Object? json) { + try { + return StockAdjustment.fromJson(json! as Map); + } catch (_) { + return null; + } + } } /// One line on a tab as shown in the dashboard, e.g. "10× Cola". @@ -166,11 +328,24 @@ class SyncRequest { /// Outbox tab events, oldest first, as their stored JSON objects. final List> events; + /// The complete product catalog with stock: a product left out is removed + /// from the dashboard. + final List> products; + + /// Unacknowledged stock adjustment outcomes, oldest first. + final List> stockAdjustmentResults; + + /// Where the settlement feed left off; null asks for the recent ones. + final String? settlementsCursor; + const SyncRequest({ required this.deviceId, this.cursor, required this.openTabs, this.events = const [], + this.products = const [], + this.stockAdjustmentResults = const [], + this.settlementsCursor, }); Map toJson() => { @@ -178,6 +353,9 @@ class SyncRequest { if (cursor != null) 'cursor': cursor, 'openTabs': openTabs.map((tab) => tab.toJson()).toList(), 'events': events, + 'products': products, + 'stockAdjustmentResults': stockAdjustmentResults, + if (settlementsCursor != null) 'settlementsCursor': settlementsCursor, }; } diff --git a/lib/models/server_tab.dart b/lib/models/server_tab.dart new file mode 100644 index 0000000..e0b3a23 --- /dev/null +++ b/lib/models/server_tab.dart @@ -0,0 +1,122 @@ +import 'payment_method.dart'; + +/// One piece on a line: when it was put on the tab and, if it was taken off +/// again, when. Kept so history can show every drink with its time. +class ServerUnit { + /// Stable across syncs: `:`. + final String id; + final DateTime addedAt; + final DateTime? removedAt; + + const ServerUnit({required this.id, required this.addedAt, this.removedAt}); + + factory ServerUnit.fromJson(Map json) { + return ServerUnit( + id: json['id'] as String, + addedAt: DateTime.parse(json['addedAt'] as String).toLocal(), + removedAt: switch (json['removedAt']) { + final String value => DateTime.parse(value).toLocal(), + _ => null, + }, + ); + } +} + +class ServerTabLine { + final String id; + final String productId; + final String productName; + final int unitPriceInCents; + final int quantity; + final List units; + + const ServerTabLine({ + required this.id, + required this.productId, + required this.productName, + required this.unitPriceInCents, + required this.quantity, + this.units = const [], + }); + + factory ServerTabLine.fromJson(Map json) { + return ServerTabLine( + id: json['id'] as String, + productId: json['productId'] as String? ?? '', + productName: json['productName'] as String, + unitPriceInCents: json['unitPriceInCents'] as int, + quantity: json['quantity'] as int, + units: [ + for (final unit in json['units'] as List? ?? []) + ServerUnit.fromJson(unit as Map), + ], + ); + } +} + +/// An open tab as the server holds it: the venue's source of truth. +class ServerTab { + final String id; + final String customerName; + final DateTime openedAt; + final int version; + final List lines; + + const ServerTab({ + required this.id, + required this.customerName, + required this.openedAt, + required this.version, + this.lines = const [], + }); + + factory ServerTab.fromJson(Map json) { + return ServerTab( + id: json['id'] as String, + customerName: json['customerName'] as String, + openedAt: DateTime.parse(json['openedAt'] as String).toLocal(), + version: json['version'] as int? ?? 0, + lines: [ + for (final line in json['lines'] as List? ?? []) + ServerTabLine.fromJson(line as Map), + ], + ); + } +} + +/// A tab settled anywhere in the venue (this tablet, another tablet or the +/// dashboard), for the local history. +class ServerSettlement { + /// For a settlement made on a tablet: the id of its `settled` event. + final String id; + final String tabId; + final String customerName; + final DateTime settledAt; + final PaymentMethod paymentMethod; + final List lines; + + const ServerSettlement({ + required this.id, + required this.tabId, + required this.customerName, + required this.settledAt, + required this.paymentMethod, + this.lines = const [], + }); + + factory ServerSettlement.fromJson(Map json) { + return ServerSettlement( + id: json['id'] as String, + tabId: json['tabId'] as String, + customerName: json['customerName'] as String, + settledAt: DateTime.parse(json['settledAt'] as String).toLocal(), + paymentMethod: PaymentMethod.fromValue( + json['paymentMethod'] as String? ?? 'cash', + ), + lines: [ + for (final line in json['lines'] as List? ?? []) + ServerTabLine.fromJson(line as Map), + ], + ); + } +} diff --git a/lib/models/tab_event.dart b/lib/models/tab_event.dart index 0d1a01a..b92e8b7 100644 --- a/lib/models/tab_event.dart +++ b/lib/models/tab_event.dart @@ -4,18 +4,33 @@ import 'payment_method.dart'; /// Types of [TabEvent] the server understands. enum TabEventType { + tabOpened('tab_opened'), + tabRenamed('tab_renamed'), + tabDeleted('tab_deleted'), lineAdded('line_added'), lineRemoved('line_removed'), - settled('settled'); + settled('settled'), + + /// A stock count or tracking change made on the tablet. Not about a tab: + /// it has no `tabId`. + stockSet('stock_set'); final String value; const TabEventType(this.value); + + static TabEventType? fromValue(String value) { + for (final type in TabEventType.values) { + if (type.value == value) return type; + } + return null; + } } -/// Something that happened on a tab, reported to the server for sales -/// statistics. [payload] is the JSON object that is stored in the outbox and -/// sent as is. +/// Something that happened on a tab. Together the events form the operation +/// log the server builds the venue's tabs from, and the tablet replays the +/// ones the server hasn't applied yet on top of the server's tabs. [payload] +/// is the JSON object that is stored in the outbox and sent as is. class TabEvent { final String id; final Map payload; @@ -25,6 +40,7 @@ class TabEvent { /// One settled line in a `settled` event. typedef SettledLine = ({ + String productId, String productName, int quantity, int unitPriceInCents, @@ -37,10 +53,46 @@ class TabEventFactory { TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now; - /// [quantity] is how many pieces were added; always positive. + TabEvent tabOpened({ + required String tabId, + required String customerName, + required DateTime openedAt, + }) { + return _event( + TabEventType.tabOpened, + tabId: tabId, + customerName: customerName, + fields: {'openedAt': openedAt.toUtc().toIso8601String()}, + ); + } + + /// [customerName] is the new name. + TabEvent tabRenamed({required String tabId, required String customerName}) { + return _event( + TabEventType.tabRenamed, + tabId: tabId, + customerName: customerName, + fields: const {}, + ); + } + + /// Written after a `line_removed` for every line still on the tab. + TabEvent tabDeleted({required String tabId, required String customerName}) { + return _event( + TabEventType.tabDeleted, + tabId: tabId, + customerName: customerName, + fields: const {}, + ); + } + + /// [quantity] is how many pieces were added; always positive. [lineId] is + /// the local line, so the server can keep its id. TabEvent lineAdded({ required String tabId, required String customerName, + required String lineId, + required String productId, required String productName, required int unitPriceInCents, required int quantity, @@ -49,6 +101,8 @@ class TabEventFactory { TabEventType.lineAdded, tabId: tabId, customerName: customerName, + lineId: lineId, + productId: productId, productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, @@ -59,6 +113,8 @@ class TabEventFactory { TabEvent lineRemoved({ required String tabId, required String customerName, + required String lineId, + required String productId, required String productName, required int unitPriceInCents, required int quantity, @@ -67,6 +123,8 @@ class TabEventFactory { TabEventType.lineRemoved, tabId: tabId, customerName: customerName, + lineId: lineId, + productId: productId, productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, @@ -92,6 +150,7 @@ class TabEventFactory { 'items': [ for (final item in items) { + 'productId': item.productId, 'productName': item.productName, 'quantity': item.quantity, 'unitPriceInCents': item.unitPriceInCents, @@ -101,6 +160,23 @@ class TabEventFactory { ); } + /// The stock of [productId] was set to [quantity] on this tablet (a count, + /// or a new product's starting stock); null stops tracking it. + TabEvent stockSet({required String productId, required int? quantity}) { + final id = _uuid.v4(); + + return TabEvent( + id: id, + payload: { + 'id': id, + 'type': TabEventType.stockSet.value, + 'productId': productId, + 'quantity': quantity, + 'occurredAt': _now().toUtc().toIso8601String(), + }, + ); + } + /// The server's payment method names: `cash`, `payconiq`, /// `payment_pending` or `other`. static String paymentMethodValue(PaymentMethod method) { @@ -115,6 +191,8 @@ class TabEventFactory { TabEventType type, { required String tabId, required String customerName, + required String lineId, + required String productId, required String productName, required int unitPriceInCents, required int quantity, @@ -128,6 +206,8 @@ class TabEventFactory { tabId: tabId, customerName: customerName, fields: { + 'lineId': lineId, + 'productId': productId, 'productName': productName, 'unitPriceInCents': unitPriceInCents, 'quantity': quantity, diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 7c83a5e..fec09f4 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; @@ -11,6 +12,7 @@ import '../models/closed_tab.dart'; import '../models/closed_tab_item.dart'; import '../models/payment_method.dart'; import '../models/product.dart'; +import '../models/server_tab.dart'; import '../models/tab_event.dart'; import '../models/tab_item.dart'; import '../models/tab_item_purchase.dart'; @@ -91,6 +93,16 @@ abstract class BarTabService { Future getClosedTabCount({String? customerName}); Future> getDistinctCustomerNames(); + + /// Replaces the open tabs with the server's [tabs], the venue's source of + /// truth, and replays the changes the server hasn't applied yet (the + /// events still in the outbox) on top. Stock is left alone: the local + /// changes already counted it. + Future applyServerState(List tabs); + + /// Adds settlements made anywhere in the venue to the history. Settlements + /// already in it (such as this tablet's own) are skipped. + Future recordSettlements(List settlements); } class DriftBarTabService implements BarTabService { @@ -226,7 +238,11 @@ class DriftBarTabService implements BarTabService { /// Marks the most recent active purchases as removed, keeping them in the /// audit trail. - Future _removeLatestPurchases(String tabItemId, int count) async { + Future _removeLatestPurchases( + String tabItemId, + int count, { + DateTime? removedAt, + }) async { final latest = await (database.select(database.tabItemPurchases) ..where( @@ -243,7 +259,18 @@ class DriftBarTabService implements BarTabService { await (database.update(database.tabItemPurchases) ..where((purchase) => purchase.id.isIn(latest.map((row) => row.id)))) - .write(TabItemPurchasesCompanion(removedAt: Value(DateTime.now()))); + .write( + TabItemPurchasesCompanion( + removedAt: Value(removedAt ?? DateTime.now()), + ), + ); + } + + Future _deleteTabWithItems(String tabId) async { + await _deleteItemsOfTab(tabId); + await (database.delete( + database.barTabs, + )..where((row) => row.id.equals(tabId))).go(); } /// Foreign keys aren't enforced, so purchases are deleted explicitly. @@ -276,6 +303,36 @@ class DriftBarTabService implements BarTabService { ); } + /// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0) + /// event and moves the stock of [productId], the product the line was + /// created from, the other way. Every line event goes through here, so a + /// line event and its stock change can never drift apart. + Future _recordLineChange({ + required String tabId, + required String customerName, + required String lineId, + required String productId, + required String productName, + required int unitPriceInCents, + required int delta, + }) async { + final lineEvent = delta > 0 + ? eventFactory.lineAdded + : eventFactory.lineRemoved; + await _recordEvent( + lineEvent( + tabId: tabId, + customerName: customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: delta.abs(), + ), + ); + await database.changeTrackedStock(productId, -delta); + } + Future _incrementTabVersion(String tabId) async { await (database.update( database.barTabs, @@ -300,9 +357,11 @@ class DriftBarTabService implements BarTabService { @override Stream> watchOpenTabs() { - return _tabsQuery().watch().map( - (rows) => _groupTabs(rows, includeRemoved: false), - ); + return _tabsQuery() + .watch() + .map((rows) => _groupTabs(rows, includeRemoved: false)) + // A sync rewrites the tabs even when nothing changed + .distinct(listEquals); } @override @@ -320,17 +379,28 @@ class DriftBarTabService implements BarTabService { @override Future createTab({required String customerName}) async { final id = _uuid.v4(); + final name = _validateCustomerName(customerName); + final openedAt = DateTime.now(); - await database - .into(database.barTabs) - .insert( - BarTabsCompanion.insert( - id: id, - customerName: _validateCustomerName(customerName), - openedAt: DateTime.now(), - version: const Value(1), - ), - ); + await database.transaction(() async { + await database + .into(database.barTabs) + .insert( + BarTabsCompanion.insert( + id: id, + customerName: name, + openedAt: openedAt, + version: const Value(1), + ), + ); + await _recordEvent( + eventFactory.tabOpened( + tabId: id, + customerName: name, + openedAt: openedAt, + ), + ); + }); return (await getTabById(id))!; } @@ -350,6 +420,9 @@ class DriftBarTabService implements BarTabService { if (updatedRows != 1) throw TabNotFoundException(tabId); + await _recordEvent( + eventFactory.tabRenamed(tabId: tabId, customerName: name), + ); await _incrementTabVersion(tabId); }); } @@ -361,22 +434,21 @@ class DriftBarTabService implements BarTabService { if (tab == null) return; for (final item in tab.items) { - await database.increaseStock(item.productId, item.quantity); - await _recordEvent( - eventFactory.lineRemoved( - tabId: tab.id, - customerName: tab.customerName, - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - quantity: item.quantity, - ), + await _recordLineChange( + tabId: tab.id, + customerName: tab.customerName, + lineId: item.id, + productId: item.productId, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + delta: -item.quantity, ); } + await _recordEvent( + eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName), + ); - await _deleteItemsOfTab(tabId); - await (database.delete( - database.barTabs, - )..where((row) => row.id.equals(tabId))).go(); + await _deleteTabWithItems(tabId); }); } @@ -389,14 +461,17 @@ class DriftBarTabService implements BarTabService { final tabRow = await _getTabRow(tabId); if (tabRow == null) throw TabNotFoundException(tabId); - await database.decreaseStock(product.id, 1); - + // A merge on the server can leave two lines of one product at + // different prices; new drinks go on the oldest final existingItem = - await (database.select(database.tabItems)..where( - (item) => - item.tabId.equals(tabId) & - item.productId.equals(product.id), - )) + await (database.select(database.tabItems) + ..where( + (item) => + item.tabId.equals(tabId) & + item.productId.equals(product.id), + ) + ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]) + ..limit(1)) .getSingleOrNull(); if (existingItem != null) { @@ -407,14 +482,14 @@ class DriftBarTabService implements BarTabService { ); await _recordPurchase(existingItem.id); // The line keeps the price it was first added at - await _recordEvent( - eventFactory.lineAdded( - tabId: tabId, - customerName: tabRow.customerName, - productName: existingItem.productName, - unitPriceInCents: existingItem.unitPriceInCents, - quantity: 1, - ), + await _recordLineChange( + tabId: tabId, + customerName: tabRow.customerName, + lineId: existingItem.id, + productId: existingItem.productId, + productName: existingItem.productName, + unitPriceInCents: existingItem.unitPriceInCents, + delta: 1, ); } else { final tabItemId = _uuid.v4(); @@ -433,14 +508,14 @@ class DriftBarTabService implements BarTabService { ), ); await _recordPurchase(tabItemId); - await _recordEvent( - eventFactory.lineAdded( - tabId: tabId, - customerName: tabRow.customerName, - productName: product.name, - unitPriceInCents: product.priceInCents, - quantity: 1, - ), + await _recordLineChange( + tabId: tabId, + customerName: tabRow.customerName, + lineId: tabItemId, + productId: product.id, + productName: product.name, + unitPriceInCents: product.priceInCents, + delta: 1, ); } @@ -468,12 +543,10 @@ class DriftBarTabService implements BarTabService { if (actualDelta == 0) return 0; if (actualDelta > 0) { - await database.decreaseStock(item.productId, actualDelta); for (var index = 0; index < actualDelta; index++) { await _recordPurchase(item.id); } } else { - await database.increaseStock(item.productId, -actualDelta); await _removeLatestPurchases(item.id, -actualDelta); } @@ -482,17 +555,14 @@ class DriftBarTabService implements BarTabService { .write(TabItemsCompanion(quantity: Value(newQuantity))); final tabRow = await _getTabRow(item.tabId); - final lineEvent = actualDelta > 0 - ? eventFactory.lineAdded - : eventFactory.lineRemoved; - await _recordEvent( - lineEvent( - tabId: item.tabId, - customerName: tabRow?.customerName ?? '', - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - quantity: actualDelta.abs(), - ), + await _recordLineChange( + tabId: item.tabId, + customerName: tabRow?.customerName ?? '', + lineId: item.id, + productId: item.productId, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + delta: actualDelta, ); await _incrementTabVersion(item.tabId); @@ -524,7 +594,36 @@ class DriftBarTabService implements BarTabService { if (items.isEmpty) return; - final closedTabId = _uuid.v4(); + // One settlement, not a removal per line: these drinks were sold + final settledItems = [ + for (final item in items) + if (item.quantity > 0) item, + ]; + final settledEvent = settledItems.isEmpty + ? null + : eventFactory.settled( + tabId: tabId, + customerName: tabRow.customerName, + amountInCents: settledItems.fold( + 0, + (total, item) => total + item.lineTotalInCents, + ), + paymentMethod: paymentMethod, + commandId: commandId, + items: [ + for (final item in settledItems) + ( + productId: item.productId, + productName: item.productName, + quantity: item.quantity, + unitPriceInCents: item.unitPriceInCents, + ), + ], + ); + + // The server reports this settlement back under the event's id, so it + // isn't added to history twice + final closedTabId = settledEvent?.id ?? _uuid.v4(); await database .into(database.closedTabs) @@ -555,33 +654,7 @@ class DriftBarTabService implements BarTabService { ]); }); - // One settlement, not a removal per line: these drinks were sold - final settledItems = [ - for (final item in items) - if (item.quantity > 0) item, - ]; - if (settledItems.isNotEmpty) { - await _recordEvent( - eventFactory.settled( - tabId: tabId, - customerName: tabRow.customerName, - amountInCents: settledItems.fold( - 0, - (total, item) => total + item.lineTotalInCents, - ), - paymentMethod: paymentMethod, - commandId: commandId, - items: [ - for (final item in settledItems) - ( - productName: item.productName, - quantity: item.quantity, - unitPriceInCents: item.unitPriceInCents, - ), - ], - ), - ); - } + if (settledEvent != null) await _recordEvent(settledEvent); await _deleteItemsOfTab(tabId); await _incrementTabVersion(tabId); @@ -679,4 +752,319 @@ class DriftBarTabService implements BarTabService { final rows = await query.get(); return rows.map((row) => row.read(customerName)!).toList(); } + + @override + Future applyServerState(List tabs) async { + await database.transaction(() async { + for (final tab in await database.select(database.barTabs).get()) { + await _deleteTabWithItems(tab.id); + } + + for (final tab in tabs) { + await _insertServerTab(tab); + } + + // Read here, so changes made while the sync was in flight are included + final outbox = + await (database.select(database.tabEvents)..orderBy([ + (row) => OrderingTerm.asc(row.createdAt), + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + for (final row in outbox) { + await _replayEvent(jsonDecode(row.payload) as Map); + } + }); + } + + Future _insertServerTab(ServerTab tab) async { + await database + .into(database.barTabs) + .insert( + BarTabsCompanion.insert( + id: tab.id, + customerName: tab.customerName, + openedAt: tab.openedAt, + version: Value(tab.version), + ), + ); + + for (final line in tab.lines) { + final createdAt = line.units.isEmpty + ? tab.openedAt + : line.units + .map((unit) => unit.addedAt) + .reduce((a, b) => a.isBefore(b) ? a : b); + + await database + .into(database.tabItems) + .insert( + TabItemsCompanion.insert( + id: line.id, + tabId: tab.id, + productId: line.productId, + productName: line.productName, + quantity: line.quantity, + unitPriceInCents: line.unitPriceInCents, + createdAt: createdAt, + ), + ); + await database.batch((batch) { + batch.insertAll(database.tabItemPurchases, [ + for (final unit in line.units) + TabItemPurchasesCompanion.insert( + id: unit.id, + tabItemId: line.id, + purchasedAt: unit.addedAt, + removedAt: Value(unit.removedAt), + ), + ]); + }); + } + } + + /// Applies an outbox event to the local tables the way the server will: + /// without writing events and without touching stock. Unknown types and + /// events about tabs that are gone are skipped. + Future _replayEvent(Map event) async { + final tabId = event['tabId'] as String?; + if (tabId == null) return; + + final customerName = event['customerName'] as String? ?? ''; + final occurredAt = + DateTime.tryParse(event['occurredAt'] as String? ?? '')?.toLocal() ?? + DateTime.now(); + + switch (TabEventType.fromValue(event['type'] as String? ?? '')) { + case TabEventType.tabOpened: + final openedAt = DateTime.tryParse( + event['openedAt'] as String? ?? '', + )?.toLocal(); + await _ensureTab(tabId, customerName, openedAt ?? occurredAt); + case TabEventType.tabRenamed: + await (database.update(database.barTabs) + ..where((tab) => tab.id.equals(tabId))) + .write(BarTabsCompanion(customerName: Value(customerName))); + case TabEventType.tabDeleted: + await _deleteTabWithItems(tabId); + case TabEventType.lineAdded: + await _replayLineAdded(event, tabId, customerName, occurredAt); + case TabEventType.lineRemoved: + final line = await _findLine(tabId, event); + final quantity = event['quantity'] as int? ?? 0; + if (line == null || quantity <= 0) return; + + final removed = min(quantity, line.quantity); + await _setLineQuantity(line.id, line.quantity - removed); + await _removeLatestPurchases(line.id, removed, removedAt: occurredAt); + case TabEventType.settled: + for (final item in event['items'] as List? ?? []) { + await _replaySettledLine(tabId, item as Map); + } + // Stock is rebased separately, see StockQueries.rebaseStock + case TabEventType.stockSet: + case null: + return; + } + } + + /// A drink put on a tab that was deleted elsewhere brings the tab back, so + /// no sale is lost. + Future _replayLineAdded( + Map event, + String tabId, + String customerName, + DateTime occurredAt, + ) async { + final quantity = event['quantity'] as int? ?? 0; + if (quantity <= 0) return; + + await _ensureTab(tabId, customerName, occurredAt); + + var line = await _findLine(tabId, event); + if (line == null) { + final lineId = event['lineId'] as String?; + final idTaken = + lineId == null || + await (database.select( + database.tabItems, + )..where((item) => item.id.equals(lineId))).getSingleOrNull() != + null; + + line = TabItemRow( + id: idTaken ? _uuid.v4() : lineId, + tabId: tabId, + productId: event['productId'] as String? ?? '', + productName: event['productName'] as String? ?? '', + quantity: 0, + unitPriceInCents: event['unitPriceInCents'] as int? ?? 0, + createdAt: occurredAt, + ); + await database.into(database.tabItems).insert(line); + } + + await _setLineQuantity(line.id, line.quantity + quantity); + await database.batch((batch) { + batch.insertAll(database.tabItemPurchases, [ + for (var index = 0; index < quantity; index++) + TabItemPurchasesCompanion.insert( + // The same id the server gives this unit + id: '${event['id']}:$index', + tabItemId: line!.id, + purchasedAt: occurredAt, + ), + ]); + }); + } + + /// Takes the settled pieces off the line, oldest first: drinks added + /// elsewhere after the settlement stay on the tab. + Future _replaySettledLine( + String tabId, + Map item, + ) async { + final line = await _findLine(tabId, item); + final quantity = item['quantity'] as int? ?? 0; + if (line == null || quantity <= 0) return; + + final settled = min(quantity, line.quantity); + final oldest = + await (database.select(database.tabItemPurchases) + ..where( + (purchase) => + purchase.tabItemId.equals(line.id) & + purchase.removedAt.isNull(), + ) + ..orderBy([ + (purchase) => OrderingTerm.asc(purchase.purchasedAt), + (purchase) => OrderingTerm.asc(purchase.id), + ]) + ..limit(settled)) + .get(); + + // Undone drinks went into the history with the settlement + await (database.delete(database.tabItemPurchases)..where( + (purchase) => + purchase.id.isIn(oldest.map((row) => row.id)) | + (purchase.tabItemId.equals(line.id) & + purchase.removedAt.isNotNull()), + )) + .go(); + + if (line.quantity == settled) { + await (database.delete( + database.tabItems, + )..where((row) => row.id.equals(line.id))).go(); + } else { + await _setLineQuantity(line.id, line.quantity - settled); + } + } + + Future _ensureTab( + String tabId, + String customerName, + DateTime openedAt, + ) async { + await database + .into(database.barTabs) + .insert( + BarTabsCompanion.insert( + id: tabId, + customerName: customerName, + openedAt: openedAt, + version: const Value(1), + ), + mode: InsertMode.insertOrIgnore, + ); + } + + /// The line [event] is about: the same line id, else the same product at + /// the same price, else the same name at the same price. + Future _findLine( + String tabId, + Map event, + ) async { + final lineId = event['lineId'] as String?; + final productId = event['productId'] as String?; + final productName = event['productName'] as String?; + final price = event['unitPriceInCents'] as int?; + + Future first(Expression Function($TabItemsTable) where) { + return (database.select(database.tabItems) + ..where((item) => item.tabId.equals(tabId) & where(item)) + ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]) + ..limit(1)) + .getSingleOrNull(); + } + + if (lineId != null) { + final line = await first((item) => item.id.equals(lineId)); + if (line != null) return line; + } + if (price == null) return null; + if (productId != null && productId.isNotEmpty) { + final line = await first( + (item) => + item.productId.equals(productId) & + item.unitPriceInCents.equals(price), + ); + if (line != null) return line; + } + if (productName == null) return null; + return first( + (item) => + item.productName.equals(productName) & + item.unitPriceInCents.equals(price), + ); + } + + Future _setLineQuantity(String lineId, int quantity) async { + await (database.update(database.tabItems) + ..where((row) => row.id.equals(lineId))) + .write(TabItemsCompanion(quantity: Value(quantity))); + } + + @override + Future recordSettlements(List settlements) async { + if (settlements.isEmpty) return; + + await database.transaction(() async { + for (final settlement in settlements) { + final known = await (database.select( + database.closedTabs, + )..where((row) => row.id.equals(settlement.id))).getSingleOrNull(); + // Already in the history, e.g. settled on this tablet + if (known != null) continue; + + await database + .into(database.closedTabs) + .insert( + ClosedTabsCompanion.insert( + id: settlement.id, + originalTabId: settlement.tabId, + customerName: settlement.customerName, + closedAt: settlement.settledAt, + paymentMethod: Value(settlement.paymentMethod), + ), + ); + + await database.batch((batch) { + batch.insertAll(database.closedTabItems, [ + for (final line in settlement.lines) + for (final unit in line.units) + ClosedTabItemsCompanion.insert( + id: _uuid.v4(), + closedTabId: settlement.id, + productId: line.productId, + productName: line.productName, + quantity: 1, + unitPriceInCents: line.unitPriceInCents, + purchasedAt: Value(unit.addedAt), + removedAt: Value(unit.removedAt), + ), + ]); + }); + } + }); + } } diff --git a/lib/services/default_product_seeder.dart b/lib/services/default_product_seeder.dart index 2fb4a5e..9f1de22 100644 --- a/lib/services/default_product_seeder.dart +++ b/lib/services/default_product_seeder.dart @@ -11,6 +11,8 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; +import '../database/stock_queries.dart'; +import '../models/tab_event.dart'; class DefaultProductSeeder { final AppDatabase database; @@ -46,6 +48,7 @@ class DefaultProductSeeder { await database.transaction(() async { final uuid = const Uuid(); + final events = TabEventFactory(); for (final (name, priceInCents, stock, category) in _defaultProducts) { String? imagePath; @@ -54,11 +57,12 @@ class DefaultProductSeeder { imagePath = await _copyBundledImage(name, imagesDir); } + final id = uuid.v4(); await database .into(database.products) .insert( ProductsCompanion.insert( - id: uuid.v4(), + id: id, name: name, category: category, stockQuantity: stock, @@ -67,6 +71,9 @@ class DefaultProductSeeder { imagePath: Value(imagePath), ), ); + await database.queueStockSet( + events.stockSet(productId: id, quantity: stock), + ); } }); } catch (e, stack) { diff --git a/lib/services/product_service.dart b/lib/services/product_service.dart index 6beb0da..4f4a5c9 100644 --- a/lib/services/product_service.dart +++ b/lib/services/product_service.dart @@ -4,9 +4,9 @@ import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; import '../database/stock_queries.dart'; import '../models/product.dart'; +import '../models/tab_event.dart'; -export '../database/stock_queries.dart' - show InsufficientStockException, ProductNotFoundException; +export '../database/stock_queries.dart' show ProductNotFoundException; abstract class ProductService { /// Active products, re-emitted whenever the products table changes. @@ -37,9 +37,11 @@ abstract class ProductService { class DriftProductService implements ProductService { final AppDatabase database; + final TabEventFactory eventFactory; final _uuid = const Uuid(); - DriftProductService({required this.database}); + DriftProductService({required this.database, TabEventFactory? eventFactory}) + : eventFactory = eventFactory ?? TabEventFactory(); Product _mapRowToProduct(ProductRow row) { return Product( @@ -95,39 +97,69 @@ class DriftProductService implements ProductService { required int priceInCents, required String? imagePath, }) async { - await database - .into(database.products) - .insert( - ProductsCompanion.insert( - id: _uuid.v4(), - name: name, - category: category, - stockQuantity: stockQuantity, - lowStockThreshold: lowStockThreshold, - infiniteStock: Value(infiniteStock), - priceInCents: priceInCents, - imagePath: Value(imagePath), - ), + final id = _uuid.v4(); + + await database.transaction(() async { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: id, + name: name, + category: category, + stockQuantity: stockQuantity, + lowStockThreshold: lowStockThreshold, + infiniteStock: Value(infiniteStock), + priceInCents: priceInCents, + imagePath: Value(imagePath), + ), + ); + // The server starts a product it doesn't know as untracked + if (!infiniteStock) { + await database.queueStockSet( + eventFactory.stockSet(productId: id, quantity: stockQuantity), ); + } + }); } + /// A changed stock or tracking is queued for the server as a count. @override Future updateProduct(Product product) async { - final query = database.update(database.products) - ..where((row) => row.id.equals(product.id)); + await database.transaction(() async { + final current = await (database.select( + database.products, + )..where((row) => row.id.equals(product.id))).getSingleOrNull(); - await query.write( - ProductsCompanion( - name: Value(product.name), - category: Value(product.category), - stockQuantity: Value(product.stockQuantity), - lowStockThreshold: Value(product.lowStockThreshold), - infiniteStock: Value(product.infiniteStock), - priceInCents: Value(product.priceInCents), - imagePath: Value(product.imagePath), - active: Value(product.active), - ), - ); + await (database.update( + database.products, + )..where((row) => row.id.equals(product.id))).write( + ProductsCompanion( + name: Value(product.name), + category: Value(product.category), + stockQuantity: Value(product.stockQuantity), + lowStockThreshold: Value(product.lowStockThreshold), + infiniteStock: Value(product.infiniteStock), + priceInCents: Value(product.priceInCents), + imagePath: Value(product.imagePath), + active: Value(product.active), + ), + ); + + if (current == null) return; + final stockChanged = product.infiniteStock + ? !current.infiniteStock + : current.infiniteStock || + current.stockQuantity != product.stockQuantity; + if (stockChanged) { + await database.queueStockSet( + eventFactory.stockSet( + productId: product.id, + quantity: product.infiniteStock ? null : product.stockQuantity, + ), + ); + } + }); } /// Only hidden, since open tabs and history still refer to it. diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 98b1a19..437f421 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; import '../database/app_database.dart'; +import '../database/stock_queries.dart'; import '../models/bar_tab.dart'; import '../models/payment_method.dart'; import '../models/remote_command.dart'; @@ -49,6 +50,13 @@ class RemoteSyncService { /// Most outbox events sent per sync; a larger backlog drains over syncs. static const maxEventsPerSync = 500; + /// Most stock adjustment outcomes sent per sync. + static const maxStockResultsPerSync = 500; + + /// How long a handled stock adjustment is remembered after the server + /// acknowledged its outcome. + static const stockResultRetention = Duration(days: 30); + static const _pollingInterval = Duration( seconds: kSyncPollingIntervalSeconds, ); @@ -66,7 +74,7 @@ class RemoteSyncService { bool _paused = false; int _consecutiveFailures = 0; - StreamSubscription>? _tabChanges; + StreamSubscription>? _tabChanges; Timer? _changeTimer; DateTime? _firstPendingChange; bool _syncQueued = false; @@ -98,11 +106,8 @@ class RemoteSyncService { void startPeriodicSync() { _isRunning = true; - // The first emission is the current state, not a change - _tabChanges ??= barTabService - .watchOpenTabs() - .skip(1) - .listen((_) => _onTabsChanged()); + unawaited(pruneStockAdjustmentResults()); + _tabChanges ??= _watchNewOutboxEvents(); _scheduleNext(_pollingInterval); } @@ -126,6 +131,27 @@ class RemoteSyncService { _paused = false; } + /// Every local tab change queues an event, so a new event in the outbox + /// means a change to push. Watching the tabs instead would also fire on + /// the server state a sync writes, and sync again forever. + StreamSubscription> _watchNewOutboxEvents() { + Set? known; + + final ids = database.selectOnly(database.tabEvents) + ..addColumns([database.tabEvents.id]); + + return ids + .map((row) => row.read(database.tabEvents.id)!) + .watch() + .map((rows) => rows.toSet()) + .listen((current) { + // The first emission is the current state, not a change + final isNew = known != null && !current.every(known!.contains); + known = current; + if (isNew) _onTabsChanged(); + }); + } + /// Pushes tab changes to the server soon after they happen instead of /// waiting for the next poll. A burst of changes (several drinks rung up in /// a row) becomes one sync, sent once things are quiet for [changeDebounce] @@ -183,9 +209,11 @@ class RemoteSyncService { Duration nextDelay; try { - await _doSync(credentials); + final appliedStock = await _doSync(credentials); _consecutiveFailures = 0; - nextDelay = _pollingInterval; + // Show the new stock on the dashboard within seconds. The follow-up + // skips the adjustments it already applied, so this doesn't loop. + nextDelay = appliedStock ? Duration.zero : _pollingInterval; _emit(RemoteSyncEventType.syncCompleted); } on UnauthorizedException { debugPrint('RemoteSyncService: token revoked'); @@ -229,11 +257,20 @@ class RemoteSyncService { return retryAfter ?? _backoff(); } - Future _doSync(_Credentials credentials) async { + /// Returns whether any stock adjustment was applied, which only happens + /// with a server that doesn't own the stock. + Future _doSync(_Credentials credentials) async { final state = await _getSyncState(); - // Read before the snapshot, so the snapshot is never older than the events - final outbox = await _pendingEvents(); - final openTabs = await barTabService.getOpenTabs(); + // One read, so the snapshot and the stock match the events that are sent + final (outbox, openTabs, products, stockResults) = await database + .transaction( + () async => ( + await _pendingEvents(), + await barTabService.getOpenTabs(), + await database.select(database.products).get(), + await _pendingStockResults(), + ), + ); final response = await apiClient.sync( token: credentials.token, @@ -245,22 +282,60 @@ class RemoteSyncService { for (final row in outbox) jsonDecode(row.payload) as Map, ], + products: products.map(_toCatalogEntry).toList(), + stockAdjustmentResults: [ + for (final row in stockResults) + jsonDecode(row.payload) as Map, + ], + settlementsCursor: state?.settlementsCursor, ), ); await _clearSentEvents(response); + await _markStockResultsReceived(response); + await barTabService.recordSettlements(response.settlements); + + final serverTabs = response.tabs; + if (serverTabs != null) { + // After the acked events left the outbox: what is left still has to + // reach the server and is replayed on top + await barTabService.applyServerState(serverTabs); + } + + // Same rebase for the stock: the server counts every tablet's sales + final serverStock = response.stock; + if (serverStock != null) await database.rebaseStock(serverStock); for (final command in response.commands) { await _processCommand(command, credentials); } - final openNames = openTabs.map((tab) => tab.customerName).toSet(); - for (final name in response.plannedTabNames) { - if (name.trim().isEmpty || !openNames.add(name)) continue; - await barTabService.createTab(customerName: name); + // A server that holds the tabs creates planned tabs itself; doing it + // here too would open them once per tablet + if (serverTabs == null) { + final openNames = openTabs.map((tab) => tab.customerName).toSet(); + for (final name in response.plannedTabNames) { + if (name.trim().isEmpty || !openNames.add(name)) continue; + await barTabService.createTab(customerName: name); + } } - await _updateSyncState(success: true, cursor: response.cursor); + // A server that owns the stock applies the dashboard's adjustments + // itself; doing it here too would apply a delivery once per tablet + var appliedStock = false; + if (serverStock == null) { + for (final adjustment in response.stockAdjustments) { + if (await _applyStockAdjustment(adjustment)) appliedStock = true; + } + } + + await _updateSyncState( + success: true, + cursor: response.cursor, + settlementsCursor: response.settlementsCursor, + ); + + return appliedStock; } Future> _pendingEvents() { @@ -295,6 +370,134 @@ class RemoteSyncService { )..where((row) => row.id.isIn(ids))).go(); } + Future> _pendingStockResults() { + return (database.select(database.stockAdjustmentResults) + ..where((row) => row.acknowledged.not()) + ..orderBy([ + (row) => OrderingTerm.asc(row.createdAt), + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ]) + ..limit(maxStockResultsPerSync)) + .get(); + } + + /// Received outcomes stay as the record that their adjustment was handled; + /// [pruneStockAdjustmentResults] removes them later. + Future _markStockResultsReceived(SyncResponse response) async { + for (final rejected in response.rejectedStockAdjustmentResults) { + debugPrint( + 'RemoteSyncService: stock result ${rejected.id} rejected: ' + '${rejected.reason}', + ); + } + + final ids = { + ...response.acknowledgedStockAdjustmentResultIds, + for (final rejected in response.rejectedStockAdjustmentResults) + if (rejected.id != null) rejected.id!, + }; + if (ids.isEmpty) return; + + await (database.update( + database.stockAdjustmentResults, + )..where((row) => row.id.isIn(ids))).write( + const StockAdjustmentResultsCompanion(acknowledged: Value(true)), + ); + } + + /// Applies [adjustment] unless it was handled before, and stores the + /// outcome in the same transaction, so a crash can neither apply it twice + /// nor lose it. Returns whether the stock changed. + Future _applyStockAdjustment(StockAdjustment adjustment) async { + final type = adjustment.type; + if (type == null) return false; + + return database.transaction(() async { + final handled = await (database.select( + database.stockAdjustmentResults, + )..where((row) => row.id.equals(adjustment.id))).getSingleOrNull(); + if (handled != null) return false; + + final product = await (database.select( + database.products, + )..where((row) => row.id.equals(adjustment.productId))).getSingleOrNull(); + + final Map result; + if (product == null) { + result = _rejectedStockResult(adjustment, 'unknown_product'); + } else if (type == StockAdjustmentType.add && product.infiniteStock) { + result = _rejectedStockResult(adjustment, 'stock_not_tracked'); + } else { + final stockAfter = switch (type) { + StockAdjustmentType.add => + product.stockQuantity + adjustment.quantity, + StockAdjustmentType.set => adjustment.quantity, + }; + // A stock count on an untracked product starts tracking it + await (database.update( + database.products, + )..where((row) => row.id.equals(product.id))).write( + ProductsCompanion( + stockQuantity: Value(stockAfter), + infiniteStock: const Value(false), + ), + ); + result = { + 'id': adjustment.id, + 'status': 'applied', + 'stockAfter': stockAfter, + }; + } + + await database + .into(database.stockAdjustmentResults) + .insert( + StockAdjustmentResultsCompanion.insert( + id: adjustment.id, + payload: jsonEncode(result), + createdAt: DateTime.now(), + ), + ); + + return result['status'] == 'applied'; + }); + } + + Map _rejectedStockResult( + StockAdjustment adjustment, + String reason, + ) { + debugPrint( + 'RemoteSyncService: stock adjustment ${adjustment.id} rejected: $reason', + ); + return {'id': adjustment.id, 'status': 'rejected', 'reason': reason}; + } + + /// Forgets handled stock adjustments whose outcome the server received + /// more than [stockResultRetention] ago. + Future pruneStockAdjustmentResults({DateTime? now}) async { + final cutoff = (now ?? DateTime.now()).subtract(stockResultRetention); + + await (database.delete(database.stockAdjustmentResults)..where( + (row) => row.acknowledged & row.createdAt.isSmallerThanValue(cutoff), + )) + .go(); + } + + /// Hidden products are included: they still exist. A server that owns the + /// stock ignores `stock`; it gets the counts as `stock_set` events. + Map _toCatalogEntry(ProductRow product) { + final category = product.category.trim(); + + return { + 'id': product.id, + 'name': product.name, + 'category': category.isEmpty ? null : category, + 'priceInCents': product.priceInCents, + 'stock': product.infiniteStock ? null : product.stockQuantity, + }; + } + SnapshotTab _toSnapshot(BarTab tab) { return SnapshotTab( id: tab.id, @@ -404,7 +607,13 @@ class RemoteSyncService { )..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull(); } - Future _updateSyncState({required bool success, String? cursor}) async { + /// Settlements are deduplicated by id, so a cursor lost in a crash only + /// makes the server send some of them again. + Future _updateSyncState({ + required bool success, + String? cursor, + String? settlementsCursor, + }) async { final now = DateTime.now(); final existing = await _getSyncState(); @@ -414,6 +623,9 @@ class RemoteSyncService { RemoteSyncStateCompanion.insert( deviceId: _syncStateId, serverCursor: Value(cursor ?? existing?.serverCursor), + settlementsCursor: Value( + settlementsCursor ?? existing?.settlementsCursor, + ), lastSuccessfulSyncAt: Value( success ? now : existing?.lastSuccessfulSyncAt, ), diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index 3e0447c..fdd8d1a 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -10,7 +10,6 @@ import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; import '../services/bar_tab_service.dart'; -import '../services/product_service.dart'; /// Tabs come from a database stream, so every change (local or remote) shows /// up without manual reloads. Actions report to Sentry and rethrow; the view @@ -129,12 +128,9 @@ class BarScreenViewModel extends ChangeNotifier { ); } - /// Running out of stock is an expected outcome, not a bug worth reporting. Future _guard(String action, Future Function() body) async { try { return await body(); - } on InsufficientStockException { - rethrow; } catch (e, stack) { debugPrint('BarScreenViewModel: $action error: $e'); unawaited(Sentry.captureException(e, stackTrace: stack)); diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index d7db75d..96be5c5 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -9,7 +9,6 @@ import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations_helpers.dart'; import '../../models/product.dart'; import '../../models/remote_command.dart'; -import '../../services/product_service.dart'; import '../../utils/app_updater.dart'; import '../../viewmodels/admin_pin_view_model.dart'; import '../../viewmodels/bar_screen_view_model.dart'; @@ -85,15 +84,9 @@ class _BarScreenViewState extends State { await viewModel.addProductToSelectedTab(product); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e is InsufficientStockException - ? l10n.productOutOfStock - : l10n.couldNotAddProductToTab, - ), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotAddProductToTab))); } } @@ -108,10 +101,6 @@ class _BarScreenViewState extends State { final viewModel = context.watch(); final productsViewModel = context.watch(); final settings = context.watch().settings; - final stockByProductId = { - for (final product in productsViewModel.products) - if (!product.infiniteStock) product.id: product.stockQuantity, - }; return Scaffold( appBar: AppBar( @@ -206,7 +195,6 @@ class _BarScreenViewState extends State { tabs: viewModel.tabs, selectedTab: viewModel.selectedTab, selectedTabId: viewModel.selectedTabId, - stockByProductId: stockByProductId, showSearch: _showSearch, onTabSelected: viewModel.selectTab, onItemQuantityChanged: viewModel.changeItemQuantity, diff --git a/lib/views/bar/selected_tab_details.dart b/lib/views/bar/selected_tab_details.dart index e6bc2b6..b3739f5 100644 --- a/lib/views/bar/selected_tab_details.dart +++ b/lib/views/bar/selected_tab_details.dart @@ -3,19 +3,16 @@ import 'package:flutter/material.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item.dart'; -import '../../services/product_service.dart'; import '../../utils/money.dart'; class SelectedTabDetails extends StatelessWidget { final BarTab tab; - final Map stockByProductId; final Future Function(TabItem item, int delta) onItemQuantityChanged; final VoidCallback onCloseTabPressed; const SelectedTabDetails({ super.key, required this.tab, - required this.stockByProductId, required this.onItemQuantityChanged, required this.onCloseTabPressed, }); @@ -55,7 +52,6 @@ class SelectedTabDetails extends StatelessWidget { return TabItemRow( item: item, - stockByProductId: stockByProductId, onQuantityChanged: onItemQuantityChanged, ); }, @@ -108,13 +104,11 @@ class SelectedTabDetails extends StatelessWidget { class TabItemRow extends StatelessWidget { final TabItem item; - final Map stockByProductId; final Future Function(TabItem item, int delta) onQuantityChanged; const TabItemRow({ super.key, required this.item, - required this.stockByProductId, required this.onQuantityChanged, }); @@ -125,23 +119,15 @@ class TabItemRow extends StatelessWidget { await onQuantityChanged(item, delta); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e is InsufficientStockException - ? l10n.productOutOfStock - : l10n.couldNotUpdateQuantity, - ), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotUpdateQuantity))); } } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - final availableStock = stockByProductId[item.productId]; - final canIncrease = availableStock == null || availableStock > 0; return Padding( padding: const EdgeInsets.symmetric(vertical: 10), @@ -188,9 +174,7 @@ class TabItemRow extends StatelessWidget { ), IconButton( visualDensity: VisualDensity.compact, - onPressed: canIncrease - ? () => _changeQuantity(context, 1) - : null, + onPressed: () => _changeQuantity(context, 1), icon: const Icon(Icons.add_rounded, size: 18), ), ], diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart index ae6aacd..1f57392 100644 --- a/lib/views/bar/tab_panel.dart +++ b/lib/views/bar/tab_panel.dart @@ -39,7 +39,6 @@ class TabPanel extends StatefulWidget { final List tabs; final BarTab? selectedTab; final String? selectedTabId; - final Map stockByProductId; final bool showSearch; final ValueChanged onTabSelected; final Future Function(TabItem item, int delta) onItemQuantityChanged; @@ -52,7 +51,6 @@ class TabPanel extends StatefulWidget { required this.tabs, required this.selectedTab, required this.selectedTabId, - required this.stockByProductId, required this.showSearch, required this.onTabSelected, required this.onItemQuantityChanged, @@ -409,8 +407,6 @@ class _TabPanelState extends State { ) : SelectedTabDetails( tab: widget.selectedTab!, - stockByProductId: - widget.stockByProductId, onItemQuantityChanged: widget.onItemQuantityChanged, onCloseTabPressed: diff --git a/lib/views/widgets/product_tile.dart b/lib/views/widgets/product_tile.dart index 65606ac..b9e5315 100644 --- a/lib/views/widgets/product_tile.dart +++ b/lib/views/widgets/product_tile.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import '../../l10n/app_localizations.dart'; import '../../models/product.dart'; class ProductTile extends StatelessWidget { @@ -24,8 +23,6 @@ class ProductTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; - final l10n = AppLocalizations.of(context); - final outOfStock = product.isOutOfStock; final lowStock = !outOfStock && product.isLowStock; @@ -50,13 +47,14 @@ class ProductTile extends StatelessWidget { child: Material( color: theme.cardTheme.color ?? scheme.surface, child: InkWell( - onTap: enabled && !outOfStock ? onTap : null, + // Never blocked by stock: the count may simply be off + onTap: enabled ? onTap : null, onLongPress: onLongPress, child: Stack( fit: StackFit.expand, children: [ Opacity( - opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4), + opacity: enabled ? 1 : 0.4, child: hasImage ? Image.file( File(product.imagePath!), @@ -90,28 +88,6 @@ class ProductTile extends StatelessWidget { right: 10, child: _StockBadge(product: product), ), - - if (outOfStock) - Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: scheme.error.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - l10n.outOfStock, - style: TextStyle( - color: scheme.onError, - fontWeight: FontWeight.w800, - fontSize: 12, - ), - ), - ), - ), ], ), ), @@ -140,16 +116,15 @@ class _StockBadge extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - if (product.isOutOfStock) { - return const SizedBox.shrink(); - } - - final low = product.isLowStock; + final out = product.isOutOfStock; + final low = !out && product.isLowStock; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: low + color: out + ? scheme.error.withValues(alpha: 0.9) + : low ? Colors.amber.withValues(alpha: 0.9) : scheme.primary.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(999), @@ -159,7 +134,11 @@ class _StockBadge extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w800, - color: low ? Colors.black : scheme.onPrimary, + color: out + ? scheme.onError + : low + ? Colors.black + : scheme.onPrimary, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 4b29611..4966604 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.3.0+0 +version: 1.3.1+0 environment: sdk: ^3.12.2 diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index f143497..c33041a 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/models/bar_tab.dart'; import 'package:kooltap/models/tab_item.dart'; import 'package:kooltap/services/bar_tab_service.dart'; -import 'package:kooltap/services/product_service.dart'; import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; import 'package:mocktail/mocktail.dart'; @@ -87,7 +86,7 @@ void main() { expect(viewModel.isLoading, false); }); - test('propagates an insufficient-stock error from the transaction', () async { + test('propagates an error from the transaction', () async { const item = TabItem( id: 'item-1', tabId: 'tab-1', @@ -99,11 +98,11 @@ void main() { when( () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), - ).thenAnswer((_) async => throw const InsufficientStockException()); + ).thenAnswer((_) async => throw const TabNotFoundException('tab-1')); await expectLater( viewModel.changeItemQuantity(item, 1), - throwsA(isA()), + throwsA(isA()), ); expect(viewModel.loadError, isNull); diff --git a/test/bar_tab_service_test.dart b/test/bar_tab_service_test.dart index 4bfeabb..5aaeaf4 100644 --- a/test/bar_tab_service_test.dart +++ b/test/bar_tab_service_test.dart @@ -226,7 +226,7 @@ void main() { expect(storedProduct.stockQuantity, 99); }); - test('rolls back the tab insert when stock is unavailable', () async { + test('still sells when the stock is at zero', () async { final tab = await service.createTab(customerName: 'John'); final product = createTestProduct(); @@ -234,13 +234,14 @@ void main() { ..where((row) => row.id.equals(product.id))) .write(const ProductsCompanion(stockQuantity: Value(0))); - await expectLater( - service.addProductToTab(tabId: tab.id, product: product), - throwsA(isA()), - ); + await service.addProductToTab(tabId: tab.id, product: product); final updatedTab = await service.getTabById(tab.id); - expect(updatedTab!.items, isEmpty); + expect(updatedTab!.items.single.quantity, 1); + final storedProduct = await (database.select( + database.products, + )..where((row) => row.id.equals(product.id))).getSingle(); + expect(storedProduct.stockQuantity, -1); }); }); @@ -480,21 +481,6 @@ void main() { expect(afterAdjust.version, afterAdd.version + 1); expect(afterRename.version, afterAdjust.version + 1); }); - - test('a failed add leaves the version untouched', () async { - final tab = await service.createTab(customerName: 'John'); - final product = createTestProduct(); - await (database.update(database.products) - ..where((row) => row.id.equals(product.id))) - .write(const ProductsCompanion(stockQuantity: Value(0))); - - await expectLater( - service.addProductToTab(tabId: tab.id, product: product), - throwsA(isA()), - ); - - expect((await service.getTabById(tab.id))!.version, tab.version); - }); }); group('watchOpenTabs', () { diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart index 1280986..b7b3c61 100644 --- a/test/database_migration_test.dart +++ b/test/database_migration_test.dart @@ -94,4 +94,12 @@ void main() { expect(await database.select(database.tabEvents).get(), isEmpty); expect((await database.select(database.barTabs).get()), hasLength(1)); }); + + test('creates an empty stock adjustment outbox', () async { + expect( + await database.select(database.stockAdjustmentResults).get(), + isEmpty, + ); + expect((await database.select(database.products).get()), hasLength(1)); + }); } diff --git a/test/product_service_test.dart b/test/product_service_test.dart index 039a997..84ff0c9 100644 --- a/test/product_service_test.dart +++ b/test/product_service_test.dart @@ -223,7 +223,7 @@ void main() { ); }); - test('throws when not enough stock', () async { + test('goes below zero instead of refusing', () async { await database .into(database.products) .insert( @@ -237,10 +237,10 @@ void main() { ), ); - expect( - () => service.decreaseStock('low-stock', 5), - throwsA(isA()), - ); + await service.decreaseStock('low-stock', 5); + + final product = await service.getProductById('low-stock'); + expect(product!.stockQuantity, -2); }); }); @@ -296,7 +296,7 @@ void main() { ); }); - test('does not allow concurrent decrements below zero', () async { + test('counts every concurrent decrement', () async { await database .into(database.products) .insert( @@ -310,16 +310,13 @@ void main() { ), ); - await expectLater( - Future.wait([ - service.decreaseStock('concurrent-stock', 1), - service.decreaseStock('concurrent-stock', 1), - ]), - throwsA(isA()), - ); + await Future.wait([ + service.decreaseStock('concurrent-stock', 1), + service.decreaseStock('concurrent-stock', 1), + ]); final product = await service.getProductById('concurrent-stock'); - expect(product!.stockQuantity, 0); + expect(product!.stockQuantity, -1); }); }); diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart index 659fb83..27716c4 100644 --- a/test/remote_sync_service_test.dart +++ b/test/remote_sync_service_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -10,6 +11,7 @@ import 'package:kooltap/models/product.dart'; import 'package:kooltap/models/remote_command.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/services/kooltap_api_client.dart'; +import 'package:kooltap/services/product_service.dart'; import 'package:kooltap/services/remote_sync_service.dart'; import 'helpers/memory_secure_storage.dart'; @@ -536,10 +538,11 @@ void main() { server.holdSync = null; await running; - expect(sentIds(), hasLength(1)); + // tab_opened and the first line_added + expect(sentIds(), hasLength(2)); final remaining = await outboxIds(); expect(remaining, hasLength(1)); - expect(remaining, isNot(contains(sentIds().single))); + expect(sentIds(), isNot(contains(remaining.single))); }); test('a close_tab command writes one settled event', () async { @@ -588,7 +591,1094 @@ void main() { for (final event in server.syncRequests.last['events'] as List) event['type'], ]; - expect(types, ['line_added']); + expect(types, ['tab_opened', 'line_added']); + }); + }); + + group('stock', () { + Map adjustment({ + required String id, + String productId = 'prod-1', + String type = 'add', + required int quantity, + }) { + return { + 'id': id, + 'productId': productId, + 'type': type, + 'quantity': quantity, + 'note': null, + 'requestedBy': 'bram@example.com', + 'createdAt': '2026-10-24T15:02:11.000Z', + }; + } + + Future product(String id) { + return (database.select( + database.products, + )..where((row) => row.id.equals(id))).getSingle(); + } + + Future setStock(String id, int stock) async { + await (database.update(database.products) + ..where((row) => row.id.equals(id))) + .write(ProductsCompanion(stockQuantity: Value(stock))); + } + + Future insertProduct( + String id, { + bool infiniteStock = false, + bool active = true, + String category = 'Drinks', + }) async { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: id, + name: id, + category: category, + stockQuantity: 0, + lowStockThreshold: 0, + priceInCents: 200, + infiniteStock: Value(infiniteStock), + active: Value(active), + ), + ); + } + + Future>> storedResults() async { + final rows = await database.select(database.stockAdjustmentResults).get(); + return { + for (final row in rows) + row.id: jsonDecode(row.payload) as Map, + }; + } + + Future> acknowledged() async { + final rows = await database.select(database.stockAdjustmentResults).get(); + return {for (final row in rows) row.id: row.acknowledged}; + } + + Future queueResult( + String id, + DateTime createdAt, { + bool acknowledged = false, + }) async { + await database + .into(database.stockAdjustmentResults) + .insert( + StockAdjustmentResultsCompanion.insert( + id: id, + payload: jsonEncode({'id': id, 'status': 'applied'}), + createdAt: createdAt, + acknowledged: Value(acknowledged), + ), + ); + } + + List sentResultIds([int request = 0]) => [ + for (final result + in server.syncRequests[request]['stockAdjustmentResults'] as List) + result['id'] as String, + ]; + + test('sends every product, with a null stock when untracked', () async { + await insertProduct('water', infiniteStock: true, category: ' '); + await insertProduct('old', active: false); + + await service.sync(); + + final products = server.syncRequests.single['products'] as List; + expect(products, [ + { + 'id': 'prod-1', + 'name': 'Beer', + 'category': 'Drinks', + 'priceInCents': 400, + 'stock': 100, + }, + { + 'id': 'water', + 'name': 'water', + 'category': null, + 'priceInCents': 200, + 'stock': null, + }, + { + 'id': 'old', + 'name': 'old', + 'category': 'Drinks', + 'priceInCents': 200, + 'stock': 0, + }, + ]); + expect((products[1] as Map).containsKey('stock'), isTrue); + }); + + test('the catalog stock matches the events that are sent', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + + await service.sync(); + + final request = server.syncRequests.single; + expect((request['events'] as List), hasLength(3)); + expect((request['products'] as List).single['stock'], 98); + }); + + test('adds deliveries and takes stock out', () async { + await setStock('prod-1', 48); + server.respondWith({ + 'stockAdjustments': [ + adjustment(id: 'a1', quantity: 24), + adjustment(id: 'a2', quantity: -30), + ], + }); + + await service.sync(); + + expect((await product('prod-1')).stockQuantity, 42); + expect(await storedResults(), { + 'a1': {'id': 'a1', 'status': 'applied', 'stockAfter': 72}, + 'a2': {'id': 'a2', 'status': 'applied', 'stockAfter': 42}, + }); + }); + + test('a stock count starts tracking an untracked product', () async { + await insertProduct('water', infiniteStock: true); + server.respondWith({ + 'stockAdjustments': [ + adjustment(id: 'a1', productId: 'water', type: 'set', quantity: 30), + ], + }); + + await service.sync(); + + final water = await product('water'); + expect(water.infiniteStock, isFalse); + expect(water.stockQuantity, 30); + expect((await storedResults())['a1'], { + 'id': 'a1', + 'status': 'applied', + 'stockAfter': 30, + }); + }); + + test('refuses to add to an untracked product', () async { + await insertProduct('water', infiniteStock: true); + server.respondWith({ + 'stockAdjustments': [ + adjustment(id: 'a1', productId: 'water', quantity: 24), + ], + }); + + await service.sync(); + + final water = await product('water'); + expect(water.infiniteStock, isTrue); + expect(water.stockQuantity, 0); + expect((await storedResults())['a1'], { + 'id': 'a1', + 'status': 'rejected', + 'reason': 'stock_not_tracked', + }); + }); + + test('refuses an unknown product', () async { + server.respondWith({ + 'stockAdjustments': [ + adjustment(id: 'a1', productId: 'nope', quantity: 5), + ], + }); + + await service.sync(); + + expect((await storedResults())['a1'], { + 'id': 'a1', + 'status': 'rejected', + 'reason': 'unknown_product', + }); + }); + + test('ignores an unknown type and a malformed adjustment', () async { + server.respondWith({ + 'stockAdjustments': [ + adjustment(id: 'a1', type: 'multiply', quantity: 2), + {'id': 'a2'}, + adjustment(id: 'a3', quantity: 1), + ], + }); + + await service.sync(); + + expect((await storedResults()).keys, ['a3']); + expect((await product('prod-1')).stockQuantity, 101); + }); + + test('applies an adjustment once, however often it arrives', () async { + server.respondWith({ + 'stockAdjustments': [ + adjustment(id: 'a1', quantity: 24), + adjustment(id: 'a1', quantity: 24), + ], + }); + + await service.sync(); + // Still in a response computed before the server saw the outcome + server.respondWith({ + 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], + 'acknowledgedStockAdjustmentResultIds': ['a1'], + }); + await service.sync(); + await service.sync(); + + expect((await product('prod-1')).stockQuantity, 124); + expect(sentResultIds(1), ['a1']); + expect(sentResultIds(2), isEmpty); + }); + + test('sends the result and the new stock in the next sync', () async { + server.respondWith({ + 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], + }); + + await service.sync(); + await service.sync(); + + final request = server.syncRequests[1]; + expect(request['stockAdjustmentResults'], [ + {'id': 'a1', 'status': 'applied', 'stockAfter': 124}, + ]); + expect((request['products'] as List).single['stock'], 124); + }); + + test('sends at most 500 unacknowledged results, oldest first', () async { + final start = DateTime(2026, 10, 24, 20); + await queueResult('done', start, acknowledged: true); + for (var i = 501; i >= 1; i--) { + await queueResult('r$i', start.add(Duration(minutes: i))); + } + + await service.sync(); + + expect(sentResultIds(), [for (var i = 1; i <= 500; i++) 'r$i']); + }); + + test('marks exactly the acknowledged and rejected results', () async { + final now = DateTime.now(); + for (final id in ['r1', 'r2', 'r3']) { + await queueResult(id, now); + } + server.respondWith({ + 'acknowledgedStockAdjustmentResultIds': ['r1'], + 'rejectedStockAdjustmentResults': [ + {'id': 'r2', 'reason': 'bad payload'}, + {'reason': 'not an object'}, + ], + }); + + await service.sync(); + + expect(await acknowledged(), {'r1': true, 'r2': true, 'r3': false}); + }); + + test('a failed sync changes nothing', () async { + await queueResult('r1', DateTime.now()); + server.syncResponse = http.Response( + jsonEncode({ + 'acknowledgedStockAdjustmentResultIds': ['r1'], + 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], + }), + 500, + ); + + await service.sync(); + + expect(await acknowledged(), {'r1': false}); + expect((await product('prod-1')).stockQuantity, 100); + }); + + test('forgets acknowledged results after 30 days', () async { + final now = DateTime(2026, 10, 24); + final old = now.subtract(const Duration(days: 31)); + await queueResult('old-acked', old, acknowledged: true); + await queueResult('old-pending', old); + await queueResult( + 'recent-acked', + now.subtract(const Duration(days: 29)), + acknowledged: true, + ); + + await service.pruneStockAdjustmentResults(now: now); + + expect((await acknowledged()).keys.toSet(), { + 'old-pending', + 'recent-acked', + }); + }); + + test('syncs once more right after applying an adjustment', () async { + server.respondWith({ + 'stockAdjustments': [adjustment(id: 'a1', quantity: 24)], + }); + service.startPeriodicSync(); + + await service.sync(); + await Future.delayed(const Duration(milliseconds: 200)); + + expect(server.syncRequests, hasLength(2)); + expect(sentResultIds(1), ['a1']); + }); + }); + + group('server holds the tabs', () { + final at = DateTime.utc(2026, 10, 24, 20); + + Map unit( + String id, { + int minutes = 0, + bool removed = false, + }) { + return { + 'id': id, + 'addedAt': at.add(Duration(minutes: minutes)).toIso8601String(), + 'removedAt': removed + ? at.add(Duration(minutes: minutes + 1)).toIso8601String() + : null, + }; + } + + Map beerLine({ + required String id, + required List> units, + int price = 400, + }) { + return { + 'id': id, + 'productId': 'prod-1', + 'productName': 'Beer', + 'unitPriceInCents': price, + 'quantity': units.where((unit) => unit['removedAt'] == null).length, + 'units': units, + }; + } + + Map serverTab( + String id, + String name, { + int version = 3, + List> lines = const [], + }) { + return { + 'id': id, + 'customerName': name, + 'openedAt': at.toIso8601String(), + 'version': version, + 'lines': lines, + }; + } + + Future> outboxTypes() async { + final rows = + await (database.select(database.tabEvents)..orderBy([ + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + return [ + for (final row in rows) + (jsonDecode(row.payload) as Map)['type'] as String, + ]; + } + + List sentIds([int request = 0]) => [ + for (final event in server.syncRequests[request]['events'] as List) + event['id'] as String, + ]; + + Future beerStock() async { + final row = await (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + return row.stockQuantity; + } + + test('a newly paired tablet shows the venue\'s open tabs', () async { + server.respondWith({ + 'tabs': [ + serverTab( + 'tab-1', + 'Alice', + lines: [ + beerLine( + id: 'line-1', + units: [unit('u1'), unit('u2', minutes: 5, removed: true)], + ), + ], + ), + serverTab('tab-2', 'Bob'), + ], + }); + + await service.sync(); + + final tabs = await barTabService.getOpenTabs(includeRemoved: true); + expect(tabs.map((tab) => tab.customerName).toSet(), {'Alice', 'Bob'}); + final alice = tabs.firstWhere((tab) => tab.id == 'tab-1'); + expect(alice.version, 3); + expect(alice.totalInCents, 400); + final line = alice.items.single; + expect(line.id, 'line-1'); + expect(line.quantity, 1); + expect(line.purchases.map((purchase) => purchase.id), ['u1', 'u2']); + expect(line.purchases.last.isRemoved, isTrue); + expect(await beerStock(), 100, reason: 'the server state is no sale'); + }); + + test('replaces local tabs the server has applied', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.holdSync = Completer(); + final running = service.sync(); + await pumpEventQueue(); + // Settled elsewhere, and someone opened Bob + server.respondWith({ + 'acknowledgedEventIds': sentIds(), + 'tabs': [serverTab(tab.id, 'Alice'), serverTab('tab-2', 'Bob')], + }); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + final tabs = await barTabService.getOpenTabs(); + expect(tabs.map((tab) => tab.customerName).toSet(), {'Alice', 'Bob'}); + final aliceId = tab.id; + expect(tabs.firstWhere((open) => open.id == aliceId).items, isEmpty); + expect(await outboxTypes(), isEmpty); + }); + + test('keeps changes the server has not applied yet', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({'tabs': >[]}); + + await service.sync(); + + final alice = (await barTabService.getTabById(tab.id))!; + expect(alice.customerName, 'Alice'); + expect(alice.items.single.quantity, 1); + expect(await outboxTypes(), ['tab_opened', 'line_added']); + expect(await beerStock(), 99, reason: 'a replay is no second sale'); + }); + + test('keeps changes made while the sync was in flight', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final lineId = (await barTabService.getTabById(tab.id))!.items.single.id; + server.holdSync = Completer(); + + final running = service.sync(); + await pumpEventQueue(); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({ + 'acknowledgedEventIds': sentIds(), + 'tabs': [ + serverTab( + tab.id, + 'Alice', + lines: [ + beerLine(id: lineId, units: [unit('u1')]), + ], + ), + ], + }); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + final line = (await barTabService.getTabById(tab.id))!.items.single; + expect(line.id, lineId); + expect(line.quantity, 2); + expect(await outboxTypes(), ['line_added']); + }); + + test('drops changes the server rejected', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.holdSync = Completer(); + final running = service.sync(); + await pumpEventQueue(); + final [opened, added] = sentIds(); + server.respondWith({ + 'acknowledgedEventIds': [opened], + 'rejectedEvents': [ + {'id': added, 'reason': 'invalid'}, + ], + 'tabs': [serverTab(tab.id, 'Alice')], + }); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + expect((await barTabService.getTabById(tab.id))!.items, isEmpty); + }); + + test( + 'a drink put on a tab deleted elsewhere brings the tab back', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + server.holdSync = Completer(); + final running = service.sync(); + await pumpEventQueue(); + server.respondWith({'acknowledgedEventIds': sentIds()}); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + // Deleted on another tablet while this one adds a beer + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({'tabs': >[]}); + await service.sync(); + + final alice = (await barTabService.getTabById(tab.id))!; + expect(alice.customerName, 'Alice'); + expect(alice.items.single.quantity, 1); + }, + ); + + test( + 'a local settlement leaves drinks added elsewhere on the tab', + () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final lineId = (await barTabService.getTabById( + tab.id, + ))!.items.single.id; + await barTabService.closeTab(tab.id); + final settledId = + (await database.select(database.tabEvents).get()) + .map( + (row) => jsonDecode(row.payload) as Map, + ) + .singleWhere((event) => event['type'] == 'settled')['id'] + as String; + + // The server hasn't seen the settlement yet, and another tablet added + // a third beer + server.holdSync = Completer(); + final running = service.sync(); + await pumpEventQueue(); + server.respondWith({ + 'acknowledgedEventIds': [ + for (final id in sentIds()) + if (id != settledId) id, + ], + 'tabs': [ + serverTab( + tab.id, + 'Alice', + lines: [ + beerLine( + id: lineId, + units: [unit('u1'), unit('u2'), unit('u3', minutes: 10)], + ), + ], + ), + ], + }); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + final line = (await barTabService.getTabById(tab.id))!.items.single; + expect(line.quantity, 1); + expect(line.purchases.single.id, 'u3'); + }, + ); + + test('a second line of one product still takes new drinks', () async { + server.respondWith({ + 'tabs': [ + serverTab( + 'tab-1', + 'Alice', + lines: [ + beerLine(id: 'old-price', units: [unit('u1')]), + beerLine( + id: 'new-price', + price: 450, + units: [unit('u2', minutes: 5)], + ), + ], + ), + ], + }); + await service.sync(); + + await barTabService.addProductToTab(tabId: 'tab-1', product: _beer); + + final items = (await barTabService.getTabById('tab-1'))!.items; + expect( + {for (final item in items) item.id: item.quantity}, + {'old-price': 2, 'new-price': 1}, + ); + }); + + test('works offline and catches up when the server answers', () async { + server.syncResponse = http.Response('down', 503); + final tab = await barTabService.createTab(customerName: 'Alice'); + await service.sync(); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await service.sync(); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await service.sync(); + + expect( + (await barTabService.getTabById(tab.id))!.items.single.quantity, + 2, + ); + + final lineId = (await barTabService.getTabById(tab.id))!.items.single.id; + server.holdSync = Completer(); + final running = service.sync(); + await pumpEventQueue(); + expect(sentIds(3), hasLength(3), reason: 'every queued change is sent'); + server.respondWith({ + 'acknowledgedEventIds': sentIds(3), + 'tabs': [ + serverTab( + tab.id, + 'Alice', + lines: [ + beerLine(id: lineId, units: [unit('u1'), unit('u2')]), + ], + ), + ], + }); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + expect( + (await barTabService.getTabById(tab.id))!.items.single.quantity, + 2, + ); + expect(await outboxTypes(), isEmpty); + }); + + test('a failed sync leaves the tabs alone', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + server.syncResponse = http.Response( + jsonEncode({ + 'tabs': [serverTab('tab-2', 'Bob')], + }), + 500, + ); + + await service.sync(); + + final tabs = await barTabService.getOpenTabs(); + expect(tabs.single.id, tab.id); + }); + + test('planned tabs come from the server, not from each tablet', () async { + server.respondWith({ + 'tabs': [serverTab('tab-1', 'Party')], + 'plannedTabNames': ['Party', 'Other'], + }); + + await service.sync(); + + final tabs = await barTabService.getOpenTabs(); + expect(tabs.single.customerName, 'Party'); + expect(await outboxTypes(), isEmpty); + }); + + test('adds settlements from elsewhere to the history once', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await barTabService.closeTab(tab.id); + final ownId = (await barTabService.getClosedTabs()).single.id; + + Map settlement(String id, String name) => { + 'id': id, + 'tabId': 'tab-x', + 'customerName': name, + 'settledAt': at.toIso8601String(), + 'paymentMethod': 'payconiq', + 'lines': [ + beerLine(id: 'l', units: [unit('$id-1'), unit('$id-2')]), + ], + }; + server.respondWith({ + 'settlements': [settlement(ownId, 'Alice'), settlement('s-2', 'Bob')], + 'settlementsCursor': 'feed-1', + }); + + await service.sync(); + await service.sync(); + + final closed = await barTabService.getClosedTabs(); + expect(closed, hasLength(2)); + final bob = closed.firstWhere((tab) => tab.id == 's-2'); + expect(bob.customerName, 'Bob'); + expect(bob.paymentMethod.value, 'payconiq'); + expect(bob.totalInCents, 800); + expect( + closed.firstWhere((tab) => tab.id == ownId).totalInCents, + 400, + reason: 'this tablet\'s own settlement is not added again', + ); + expect(server.syncRequests.first.containsKey('settlementsCursor'), false); + expect(server.syncRequests.last['settlementsCursor'], 'feed-1'); + }); + + test('an unchanged server state is not shown again', () async { + final emissions = []; + final subscription = barTabService.watchOpenTabs().listen( + (tabs) => emissions.add(tabs.length), + ); + server.respondWith({ + 'tabs': [ + serverTab( + 'tab-1', + 'Alice', + lines: [ + beerLine(id: 'line-1', units: [unit('u1')]), + ], + ), + ], + }); + + await service.sync(); + await pumpEventQueue(); + await service.sync(); + await service.sync(); + await pumpEventQueue(); + await subscription.cancel(); + + expect(emissions, [0, 1]); + }); + }); + + group('server holds the stock', () { + Future beer() { + return (database.select( + database.products, + )..where((row) => row.id.equals('prod-1'))).getSingle(); + } + + Future>> outbox() async { + final rows = + await (database.select(database.tabEvents)..orderBy([ + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + return [ + for (final row in rows) jsonDecode(row.payload) as Map, + ]; + } + + List sentIds([int request = 0]) => [ + for (final event in server.syncRequests[request]['events'] as List) + event['id'] as String, + ]; + + /// Queues an outbox event that happened at [occurredAt]. + Future queue(Map payload, DateTime occurredAt) { + final id = 'event-${payload['type']}-${occurredAt.toIso8601String()}'; + return database + .into(database.tabEvents) + .insert( + TabEventsCompanion.insert( + id: id, + payload: jsonEncode({ + 'id': id, + ...payload, + 'occurredAt': occurredAt.toUtc().toIso8601String(), + }), + createdAt: occurredAt, + ), + ); + } + + Map stock( + String productId, + int? stock, { + DateTime? countedAt, + }) { + return { + 'productId': productId, + 'stock': stock, + 'countedAt': countedAt?.toUtc().toIso8601String(), + }; + } + + const beerLine = { + 'tabId': 'tab-1', + 'customerName': 'Alice', + 'lineId': 'line-1', + 'productId': 'prod-1', + 'productName': 'Beer', + 'unitPriceInCents': 400, + 'quantity': 1, + }; + + Future insertProduct(String id, int stock) async { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: id, + name: id, + category: 'Drinks', + stockQuantity: stock, + lowStockThreshold: 0, + priceInCents: 200, + ), + ); + } + + test("takes the server's stock for the products it knows", () async { + await insertProduct('water', 12); + await insertProduct('new', 7); + server.respondWith({ + 'stock': [ + stock('prod-1', 46), + stock('water', null), + stock('unknown', 3), + {'productId': 'broken', 'stock': 'many'}, + ], + }); + + await service.sync(); + + final products = { + for (final row in await database.select(database.products).get()) + row.id: (row.stockQuantity, row.infiniteStock), + }; + expect(products, { + 'prod-1': (46, false), + // Keeps its last count for when tracking resumes + 'water': (12, true), + 'new': (7, false), + }); + }); + + test('acknowledged sales are not counted twice', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + expect((await beer()).stockQuantity, 98); + + server.holdSync = Completer(); + final sync = service.sync(); + await pumpEventQueue(); + // Sold while the sync is in flight: not in this request + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + // Another tablet sold 3 more + server.respondWith({ + 'acknowledgedEventIds': sentIds(), + 'stock': [stock('prod-1', 95)], + }); + server.holdSync!.complete(); + await sync; + + expect((await beer()).stockQuantity, 94); + expect(await outbox(), hasLength(1)); + + server.holdSync = null; + server.respondWith({ + 'acknowledgedEventIds': [(await outbox()).single['id']], + 'stock': [stock('prod-1', 94)], + }); + await service.sync(); + + expect((await beer()).stockQuantity, 94); + }); + + test('a rejected sale gives its stock back', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final [opened, sale] = await outbox(); + + server.respondWith({ + 'acknowledgedEventIds': [opened['id']], + 'rejectedEvents': [ + {'id': sale['id'], 'reason': 'invalid'}, + ], + 'stock': [stock('prod-1', 100)], + }); + await service.sync(); + + expect((await beer()).stockQuantity, 100); + }); + + test( + "a count on the tablet replaces the server's stock until it is sent", + () async { + final products = DriftProductService(database: database); + final tab = await barTabService.createTab(customerName: 'Alice'); + await products.updateProduct(_beer.copyWith(stockQuantity: 40)); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + + server.respondWith({ + 'stock': [stock('prod-1', 100)], + }); + await service.sync(); + + expect((await beer()).stockQuantity, 39); + expect( + server.syncRequests.single['events'], + contains(containsPair('type', 'stock_set')), + ); + }, + ); + + test('a count on the tablet can stop tracking a product', () async { + final products = DriftProductService(database: database); + await products.updateProduct(_beer.copyWith(infiniteStock: true)); + + server.respondWith({ + 'stock': [stock('prod-1', 100)], + }); + await service.sync(); + + expect((await beer()).infiniteStock, isTrue); + }); + + test("sales and counts from before the server's count are in it", () async { + await queue({ + 'type': 'line_added', + ...beerLine, + 'quantity': 2, + }, DateTime.utc(2026, 10, 24, 14)); + await queue({ + 'type': 'stock_set', + 'productId': 'prod-1', + 'quantity': 70, + }, DateTime.utc(2026, 10, 24, 14, 30)); + await queue({ + 'type': 'line_added', + ...beerLine, + 'quantity': 3, + }, DateTime.utc(2026, 10, 24, 15, 30)); + + server.respondWith({ + 'stock': [ + stock('prod-1', 30, countedAt: DateTime.utc(2026, 10, 24, 15)), + ], + }); + await service.sync(); + + expect((await beer()).stockQuantity, 27); + }); + + test( + 'an untracked product ignores sales until a count tracks it', + () async { + await queue({ + 'type': 'line_added', + ...beerLine, + }, DateTime.utc(2026, 10, 24, 14)); + await queue({ + 'type': 'stock_set', + 'productId': 'prod-1', + 'quantity': 10, + }, DateTime.utc(2026, 10, 24, 15)); + await queue({ + 'type': 'line_removed', + ...beerLine, + }, DateTime.utc(2026, 10, 24, 16)); + + server.respondWith({ + 'stock': [stock('prod-1', null)], + }); + await service.sync(); + + final row = await beer(); + expect(row.infiniteStock, isFalse); + expect(row.stockQuantity, 11); + }, + ); + + test('dashboard adjustments are left to the server', () async { + server.respondWith({ + 'stockAdjustments': [ + { + 'id': '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c', + 'productId': 'prod-1', + 'type': 'add', + 'quantity': 24, + 'note': 'Delivery', + 'requestedBy': 'bram@example.com', + 'createdAt': '2026-10-24T15:02:11.000Z', + }, + ], + 'stock': [stock('prod-1', 124)], + }); + + await service.sync(); + await service.sync(); + + expect((await beer()).stockQuantity, 124); + expect( + await database.select(database.stockAdjustmentResults).get(), + isEmpty, + ); + }); + }); + + group('sync trigger with server tabs', () { + late RemoteSyncService fast; + + setUp(() { + fast = RemoteSyncService( + database: database, + barTabService: barTabService, + apiClient: KoolTapApiClient(client: server.client, baseUrl: _baseUrl), + secureStorage: storage, + changeDebounce: const Duration(milliseconds: 50), + changeMaxDelay: const Duration(milliseconds: 300), + ); + }); + + tearDown(() => fast.dispose()); + + Future wait(int ms) => + Future.delayed(Duration(milliseconds: ms)); + + test('writing the server state does not sync again', () async { + fast.startPeriodicSync(); + server.respondWith({ + 'tabs': [ + { + 'id': 'tab-1', + 'customerName': 'Bob', + 'openedAt': '2026-10-24T20:00:00.000Z', + 'version': 1, + 'lines': >[], + }, + ], + }); + + await fast.sync(); + await wait(250); + + expect(server.syncRequests, hasLength(1)); + expect((await barTabService.getOpenTabs()).single.customerName, 'Bob'); + }); + + test('a local change syncs shortly after', () async { + fast.startPeriodicSync(); + await wait(100); + + await barTabService.createTab(customerName: 'Alice'); + await wait(250); + + expect(server.syncRequests, hasLength(1)); }); }); } diff --git a/test/tab_events_test.dart b/test/tab_events_test.dart index 732a2ae..e71dd5a 100644 --- a/test/tab_events_test.dart +++ b/test/tab_events_test.dart @@ -4,10 +4,12 @@ import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/bar_tab.dart'; import 'package:kooltap/models/payment_method.dart'; import 'package:kooltap/models/product.dart'; import 'package:kooltap/models/tab_event.dart'; import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/services/product_service.dart'; const _cola = Product( id: 'prod-1', @@ -59,8 +61,15 @@ void main() { return (await service.getTabById(tabId))!.items.single.id; } + /// Opens a tab and drops its `tab_opened` event. + Future openTab(String name) async { + final tab = await service.createTab(customerName: name); + await database.delete(database.tabEvents).go(); + return tab; + } + Future tabWithTwoColas() async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab(tabId: tab.id, product: _cola); await database.delete(database.tabEvents).go(); @@ -71,13 +80,15 @@ void main() { group('line events', () { test('adding a product writes line_added with the common fields', () async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); final event = (await outbox()).single; expect(event['type'], 'line_added'); expect(event['tabId'], tab.id); expect(event['customerName'], 'Jan'); + expect(event['productId'], 'prod-1'); + expect(event['lineId'], await lineIdOf(tab.id)); expect(event['productName'], 'Cola'); expect(event['unitPriceInCents'], 250); expect(event['quantity'], 1); @@ -89,7 +100,7 @@ void main() { }); test('adding to an existing line keeps that line\'s price', () async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab( tabId: tab.id, @@ -108,7 +119,7 @@ void main() { }); test('changing a quantity writes the difference', () async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); await service.addProductToTab(tabId: tab.id, product: _cola); final lineId = await lineIdOf(tab.id); @@ -137,7 +148,7 @@ void main() { }); test('a change that does nothing writes no event', () async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.addProductToTab(tabId: tab.id, product: _cola); final lineId = await lineIdOf(tab.id); await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); @@ -149,25 +160,38 @@ void main() { expect(await outbox(), isEmpty); }); - test('deleting a tab writes line_removed for each line', () async { - final tab = await tabWithTwoColas(); + test( + 'deleting a tab writes line_removed for each line, then tab_deleted', + () async { + final tab = await tabWithTwoColas(); - await service.deleteTab(tab.id); + await service.deleteTab(tab.id); - final event = (await outbox()).single; - expect(event['type'], 'line_removed'); - expect(event['quantity'], 2); - expect(event['customerName'], 'Jan'); - }); + final events = await outbox(); + expect(events.map((event) => event['type']), [ + 'line_removed', + 'tab_deleted', + ]); + expect(events.first['quantity'], 2); + expect(events.first['customerName'], 'Jan'); + expect(events.last['tabId'], tab.id); + }, + ); test('later events use the new name after a rename', () async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.renameTab(tabId: tab.id, customerName: 'Janneke'); - expect(await outbox(), isEmpty); - await service.addProductToTab(tabId: tab.id, product: _cola); - expect((await outbox()).single['customerName'], 'Janneke'); + final events = await outbox(); + expect(events.map((event) => event['type']), [ + 'tab_renamed', + 'line_added', + ]); + expect(events.map((event) => event['customerName']), [ + 'Janneke', + 'Janneke', + ]); }); test('a failed change writes no event', () async { @@ -180,6 +204,29 @@ void main() { }); }); + group('tab events', () { + test('opening a tab writes tab_opened in the same transaction', () async { + final tab = await service.createTab(customerName: 'Jan'); + + final event = (await outbox()).single; + expect(event['type'], 'tab_opened'); + expect(event['tabId'], tab.id); + expect(event['customerName'], 'Jan'); + // The database keeps whole seconds + final openedAt = DateTime.parse(event['openedAt'] as String); + expect(openedAt.difference(tab.openedAt).inSeconds, 0); + }); + + test('a rename of an unknown tab writes nothing', () async { + await expectLater( + service.renameTab(tabId: 'missing', customerName: 'X'), + throwsA(isA()), + ); + + expect(await outbox(), isEmpty); + }); + }); + group('settled events', () { test('settling writes one settled event and no removals', () async { final tab = await tabWithTwoColas(); @@ -193,8 +240,16 @@ void main() { expect(event['paymentMethod'], 'payconiq'); expect(event.containsKey('commandId'), false); expect(event['items'], [ - {'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250}, + { + 'productId': 'prod-1', + 'productName': 'Cola', + 'quantity': 2, + 'unitPriceInCents': 250, + }, ]); + // History and the server's settlement feed share the event's id + final closed = await service.getClosedTabs(); + expect(closed.single.id, event['id']); }); test('lines that were taken back are not settled', () async { @@ -211,7 +266,7 @@ void main() { }); test('settling an empty tab writes nothing', () async { - final tab = await service.createTab(customerName: 'Jan'); + final tab = await openTab('Jan'); await service.closeTab(tab.id); @@ -244,6 +299,170 @@ void main() { }); }); + group('stock', () { + Future colaStock() async { + final row = await (database.select( + database.products, + )..where((row) => row.id.equals(_cola.id))).getSingle(); + return row.stockQuantity; + } + + test('moves with every line event', () async { + final tab = await openTab('Jan'); + for (var i = 0; i < 3; i++) { + await service.addProductToTab(tabId: tab.id, product: _cola); + } + expect(await colaStock(), 97); + + await service.adjustTabItemQuantity( + tabItemId: await lineIdOf(tab.id), + delta: -2, + ); + expect(await colaStock(), 99); + + await service.deleteTab(tab.id); + expect(await colaStock(), 100); + }); + + test('settling leaves the stock alone', () async { + final tab = await tabWithTwoColas(); + + await service.closeTab(tab.id); + + expect(await colaStock(), 98); + }); + + test('a product without tracked stock never changes', () async { + await (database.update(database.products) + ..where((row) => row.id.equals(_cola.id))) + .write(const ProductsCompanion(infiniteStock: Value(true))); + final tab = await openTab('Jan'); + + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.deleteTab(tab.id); + + expect(await colaStock(), 100); + expect(await outbox(), hasLength(4)); + }); + + test('a line whose product is gone changes no stock', () async { + final tab = await tabWithTwoColas(); + await (database.delete( + database.products, + )..where((row) => row.id.equals(_cola.id))).go(); + + await service.deleteTab(tab.id); + + expect((await outbox()).first['type'], 'line_removed'); + expect(await service.getTabById(tab.id), isNull); + }); + + test('the stock only changes when its event is stored', () async { + service = DriftBarTabService( + database: database, + eventFactory: _FixedIdEventFactory(), + ); + final tab = await openTab('Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + + // The second event reuses the id, so storing it fails + await expectLater( + service.addProductToTab(tabId: tab.id, product: _cola), + throwsA(anything), + ); + + expect(await colaStock(), 99); + expect(await outbox(), hasLength(1)); + }); + }); + + group('stock_set events', () { + late DriftProductService products; + + setUp(() { + products = DriftProductService( + database: database, + eventFactory: TabEventFactory( + now: () => DateTime.utc(2026, 10, 24, 15, 3, 40), + ), + ); + }); + + test('have a product and quantity but no tab', () { + final event = TabEventFactory( + now: () => DateTime.utc(2026, 10, 24, 15, 3, 40), + ).stockSet(productId: 'prod-1', quantity: 30); + + expect(event.payload, { + 'id': event.id, + 'type': 'stock_set', + 'productId': 'prod-1', + 'quantity': 30, + 'occurredAt': '2026-10-24T15:03:40.000Z', + }); + }); + + test('a new product queues its starting stock', () async { + await products.createProduct( + name: 'Water', + category: 'Drinks', + stockQuantity: 24, + lowStockThreshold: 5, + priceInCents: 200, + imagePath: null, + ); + await products.createProduct( + name: 'Tap water', + category: 'Drinks', + stockQuantity: 0, + lowStockThreshold: 0, + infiniteStock: true, + priceInCents: 0, + imagePath: null, + ); + + final water = (await products.getProducts()).firstWhere( + (product) => product.name == 'Water', + ); + final events = await outbox(); + expect(events, hasLength(1)); + expect(events.single, containsPair('type', 'stock_set')); + expect(events.single, containsPair('productId', water.id)); + expect(events.single, containsPair('quantity', 24)); + }); + + test('a stock or tracking change queues a count', () async { + final cola = (await products.getProductById('prod-1'))!; + + await products.updateProduct(cola.copyWith(name: 'Coca-Cola')); + expect(await outbox(), isEmpty); + + await products.updateProduct(cola.copyWith(stockQuantity: 40)); + await products.updateProduct( + cola.copyWith(stockQuantity: 40, infiniteStock: true), + ); + await products.updateProduct(cola.copyWith(stockQuantity: 40)); + + expect( + [for (final event in await outbox()) event['quantity']], + [40, null, 40], + ); + }); + + test('are skipped when replaying the outbox onto server tabs', () async { + final tab = await openTab('Jan'); + await products.updateProduct( + (await products.getProductById('prod-1'))!.copyWith(stockQuantity: 5), + ); + + await service.applyServerState([]); + + expect(await service.getTabById(tab.id), isNull); + expect(await outbox(), hasLength(1)); + }); + }); + test('occurredAt is the UTC time it happened', () { final factory = TabEventFactory( now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120), @@ -252,6 +471,8 @@ void main() { final event = factory.lineAdded( tabId: 'a1b2', customerName: 'Jan', + lineId: 'line-1', + productId: 'prod-1', productName: 'Cola', unitPriceInCents: 250, quantity: 2, @@ -262,6 +483,8 @@ void main() { () => factory.lineRemoved( tabId: 'a1b2', customerName: 'Jan', + lineId: 'line-1', + productId: 'prod-1', productName: 'Cola', unitPriceInCents: 250, quantity: 0, @@ -270,3 +493,28 @@ void main() { ); }); } + +/// Gives every event the same id, so the second one can't be stored. +class _FixedIdEventFactory extends TabEventFactory { + @override + TabEvent lineAdded({ + required String tabId, + required String customerName, + required String lineId, + required String productId, + required String productName, + required int unitPriceInCents, + required int quantity, + }) { + final event = super.lineAdded( + tabId: tabId, + customerName: customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: quantity, + ); + return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'}); + } +} diff --git a/test/tab_money_test.dart b/test/tab_money_test.dart index 946ff22..080835a 100644 --- a/test/tab_money_test.dart +++ b/test/tab_money_test.dart @@ -249,27 +249,24 @@ void main() { final tabId = tabIds[random.nextInt(tabIds.length)]; final product = random.nextBool() ? _beer : _wine; - try { - switch (random.nextInt(10)) { - case < 6: - await tabs.addProductToTab(tabId: tabId, product: product); - case < 8: - final tab = (await tabs.getTabById(tabId))!; - if (tab.items.isEmpty) continue; - final item = tab.items[random.nextInt(tab.items.length)]; - await tabs.adjustTabItemQuantity( - tabItemId: item.id, - delta: random.nextInt(5) - 2, - ); - case 8: - await tabs.closeTab(tabId); - default: - await tabs.deleteTab(tabId); - tabIds.remove(tabId); - tabIds.add((await tabs.createTab(customerName: 'New')).id); - } - } on InsufficientStockException { - // Running out is part of the scenario. + // Running out is part of the scenario: the stock goes negative + switch (random.nextInt(10)) { + case < 6: + await tabs.addProductToTab(tabId: tabId, product: product); + case < 8: + final tab = (await tabs.getTabById(tabId))!; + if (tab.items.isEmpty) continue; + final item = tab.items[random.nextInt(tab.items.length)]; + await tabs.adjustTabItemQuantity( + tabItemId: item.id, + delta: random.nextInt(5) - 2, + ); + case 8: + await tabs.closeTab(tabId); + default: + await tabs.deleteTab(tabId); + tabIds.remove(tabId); + tabIds.add((await tabs.createTab(customerName: 'New')).id); } for (final product in [_beer, _wine]) { @@ -278,65 +275,48 @@ void main() { await quantityOnOpenTabs(product.id) + await soldInHistory(product.id); expect(accounted, product.stockQuantity, reason: 'step $step'); - expect(await stockOf(product.id), greaterThanOrEqualTo(0)); } } }); - test('can never sell more than is in stock', () async { + test('keeps selling when the stock runs out', () async { final tab = await tabs.createTab(customerName: 'John'); - var sold = 0; for (var i = 0; i < _wine.stockQuantity + 5; i++) { - try { - await tabs.addProductToTab(tabId: tab.id, product: _wine); - sold++; - } on InsufficientStockException { - // Expected once the wine runs out. - } + await tabs.addProductToTab(tabId: tab.id, product: _wine); } - expect(sold, _wine.stockQuantity); - expect(await stockOf('wine'), 0); - expect((await tabs.getTabById(tab.id))!.totalInCents, 20 * 450); + expect(await stockOf('wine'), -5); + expect((await tabs.getTabById(tab.id))!.totalInCents, 25 * 450); }); - test('simultaneous orders for the last bottle sell it once', () async { + test('simultaneous orders for the last bottle both go through', () async { await products.updateProduct(_wine.copyWith(stockQuantity: 1)); final first = await tabs.createTab(customerName: 'A'); final second = await tabs.createTab(customerName: 'B'); - final results = await Future.wait([ + await Future.wait([ for (final tab in [first, second]) - tabs - .addProductToTab(tabId: tab.id, product: _wine) - .then((_) => true) - .catchError( - (_) => false, - test: (e) => e is InsufficientStockException, - ), + tabs.addProductToTab(tabId: tab.id, product: _wine), ]); - expect(results.where((ok) => ok), hasLength(1)); - expect(await stockOf('wine'), 0); - expect(await quantityOnOpenTabs('wine'), 1); + expect(await stockOf('wine'), -1); + expect(await quantityOnOpenTabs('wine'), 2); }); - test('raising a quantity past the stock adds nothing', () async { + test('raising a quantity past the stock goes below zero', () async { await products.updateProduct(_wine.copyWith(stockQuantity: 2)); final tab = await tabs.createTab(customerName: 'John'); await tabs.addProductToTab(tabId: tab.id, product: _wine); - await expectLater( - tabs.adjustTabItemQuantity( - tabItemId: await itemId(tab.id, 'wine'), - delta: 5, - ), - throwsA(isA()), + final applied = await tabs.adjustTabItemQuantity( + tabItemId: await itemId(tab.id, 'wine'), + delta: 5, ); - expect(await stockOf('wine'), 1); - expect((await tabs.getTabById(tab.id))!.items.single.quantity, 1); + expect(applied, 5); + expect(await stockOf('wine'), -4); + expect((await tabs.getTabById(tab.id))!.items.single.quantity, 6); }); test('lowering past zero only returns what was on the tab', () async { @@ -372,19 +352,5 @@ void main() { expect(await stockOf('water'), 0); }); - - test('a failed add leaves no item behind', () async { - await products.updateProduct(_wine.copyWith(stockQuantity: 0)); - final tab = await tabs.createTab(customerName: 'John'); - - await expectLater( - tabs.addProductToTab(tabId: tab.id, product: _wine), - throwsA(isA()), - ); - - final open = (await tabs.getTabById(tab.id))!; - expect(open.items, isEmpty); - expect(open.totalInCents, 0); - }); }); } diff --git a/test/tab_panel_index_test.dart b/test/tab_panel_index_test.dart index 2202291..725eba5 100644 --- a/test/tab_panel_index_test.dart +++ b/test/tab_panel_index_test.dart @@ -59,7 +59,6 @@ void main() { tabs: tabs, selectedTab: selectedTab, selectedTabId: selectedTab?.id, - stockByProductId: const {}, showSearch: showSearch, onTabSelected: (_) {}, onItemQuantityChanged: (_, _) async {},