feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+133
View File
@@ -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.
+140
View File
@@ -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 = "<event id>:<index>"` (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 `"<event id>:<index>"`: 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": "<event id>:0", "addedAt": "…", "removedAt": null },
{ "id": "<event 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.
+38 -1
View File
@@ -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<Column> 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<Column> 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,
);
}
},
);
}
+618
View File
@@ -3060,6 +3060,18 @@ class $RemoteSyncStateTable extends RemoteSyncState
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _settlementsCursorMeta = const VerificationMeta(
'settlementsCursor',
);
@override
late final GeneratedColumn<String> settlementsCursor =
GeneratedColumn<String>(
'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<GeneratedColumn> 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<RemoteSyncStateRow> {
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<String>(serverCursor);
}
if (!nullToAbsent || settlementsCursor != null) {
map['settlements_cursor'] = Variable<String>(settlementsCursor);
}
if (!nullToAbsent || lastSuccessfulSyncAt != null) {
map['last_successful_sync_at'] = Variable<DateTime>(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<String>(json['deviceId']),
serverCursor: serializer.fromJson<String?>(json['serverCursor']),
settlementsCursor: serializer.fromJson<String?>(
json['settlementsCursor'],
),
lastSuccessfulSyncAt: serializer.fromJson<DateTime?>(
json['lastSuccessfulSyncAt'],
),
@@ -3268,6 +3307,7 @@ class RemoteSyncStateRow extends DataClass
return <String, dynamic>{
'deviceId': serializer.toJson<String>(deviceId),
'serverCursor': serializer.toJson<String?>(serverCursor),
'settlementsCursor': serializer.toJson<String?>(settlementsCursor),
'lastSuccessfulSyncAt': serializer.toJson<DateTime?>(
lastSuccessfulSyncAt,
),
@@ -3281,12 +3321,16 @@ class RemoteSyncStateRow extends DataClass
RemoteSyncStateRow copyWith({
String? deviceId,
Value<String?> serverCursor = const Value.absent(),
Value<String?> settlementsCursor = const Value.absent(),
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
Value<DateTime?> 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<RemoteSyncStateRow> {
final Value<String> deviceId;
final Value<String?> serverCursor;
final Value<String?> settlementsCursor;
final Value<DateTime?> lastSuccessfulSyncAt;
final Value<DateTime?> lastAttemptedSyncAt;
final Value<int> consecutiveFailureCount;
@@ -3355,6 +3406,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
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<RemoteSyncStateRow> {
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<RemoteSyncStateRow> {
static Insertable<RemoteSyncStateRow> custom({
Expression<String>? deviceId,
Expression<String>? serverCursor,
Expression<String>? settlementsCursor,
Expression<DateTime>? lastSuccessfulSyncAt,
Expression<DateTime>? lastAttemptedSyncAt,
Expression<int>? consecutiveFailureCount,
@@ -3379,6 +3433,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
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<RemoteSyncStateRow> {
RemoteSyncStateCompanion copyWith({
Value<String>? deviceId,
Value<String?>? serverCursor,
Value<String?>? settlementsCursor,
Value<DateTime?>? lastSuccessfulSyncAt,
Value<DateTime?>? lastAttemptedSyncAt,
Value<int>? consecutiveFailureCount,
@@ -3400,6 +3456,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
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<RemoteSyncStateRow> {
if (serverCursor.present) {
map['server_cursor'] = Variable<String>(serverCursor.value);
}
if (settlementsCursor.present) {
map['settlements_cursor'] = Variable<String>(settlementsCursor.value);
}
if (lastSuccessfulSyncAt.present) {
map['last_successful_sync_at'] = Variable<DateTime>(
lastSuccessfulSyncAt.value,
@@ -3443,6 +3503,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
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<TabEventRow> {
}
}
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<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _payloadMeta = const VerificationMeta(
'payload',
);
@override
late final GeneratedColumn<String> payload = GeneratedColumn<String>(
'payload',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _acknowledgedMeta = const VerificationMeta(
'acknowledged',
);
@override
late final GeneratedColumn<bool> acknowledged = GeneratedColumn<bool>(
'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<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> 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<StockAdjustmentResultRow> 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<GeneratedColumn> get $primaryKey => {id};
@override
StockAdjustmentResultRow map(
Map<String, dynamic> 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<StockAdjustmentResultRow> {
/// 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['payload'] = Variable<String>(payload);
map['acknowledged'] = Variable<bool>(acknowledged);
map['created_at'] = Variable<DateTime>(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<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return StockAdjustmentResultRow(
id: serializer.fromJson<String>(json['id']),
payload: serializer.fromJson<String>(json['payload']),
acknowledged: serializer.fromJson<bool>(json['acknowledged']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'payload': serializer.toJson<String>(payload),
'acknowledged': serializer.toJson<bool>(acknowledged),
'createdAt': serializer.toJson<DateTime>(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<StockAdjustmentResultRow> {
final Value<String> id;
final Value<String> payload;
final Value<bool> acknowledged;
final Value<DateTime> createdAt;
final Value<int> 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<StockAdjustmentResultRow> custom({
Expression<String>? id,
Expression<String>? payload,
Expression<bool>? acknowledged,
Expression<DateTime>? createdAt,
Expression<int>? 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<String>? id,
Value<String>? payload,
Value<bool>? acknowledged,
Value<DateTime>? createdAt,
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (payload.present) {
map['payload'] = Variable<String>(payload.value);
}
if (acknowledged.present) {
map['acknowledged'] = Variable<bool>(acknowledged.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(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<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -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<String?> serverCursor,
Value<String?> settlementsCursor,
Value<DateTime?> lastSuccessfulSyncAt,
Value<DateTime?> lastAttemptedSyncAt,
Value<int> consecutiveFailureCount,
@@ -6364,6 +6755,7 @@ typedef $$RemoteSyncStateTableUpdateCompanionBuilder =
RemoteSyncStateCompanion Function({
Value<String> deviceId,
Value<String?> serverCursor,
Value<String?> settlementsCursor,
Value<DateTime?> lastSuccessfulSyncAt,
Value<DateTime?> lastAttemptedSyncAt,
Value<int> consecutiveFailureCount,
@@ -6389,6 +6781,11 @@ class $$RemoteSyncStateTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get settlementsCursor => $composableBuilder(
column: $table.settlementsCursor,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
column: $table.lastSuccessfulSyncAt,
builder: (column) => ColumnFilters(column),
@@ -6424,6 +6821,11 @@ class $$RemoteSyncStateTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get settlementsCursor => $composableBuilder(
column: $table.settlementsCursor,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
column: $table.lastSuccessfulSyncAt,
builder: (column) => ColumnOrderings(column),
@@ -6457,6 +6859,11 @@ class $$RemoteSyncStateTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<String> get settlementsCursor => $composableBuilder(
column: $table.settlementsCursor,
builder: (column) => column,
);
GeneratedColumn<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
column: $table.lastSuccessfulSyncAt,
builder: (column) => column,
@@ -6512,6 +6919,7 @@ class $$RemoteSyncStateTableTableManager
({
Value<String> deviceId = const Value.absent(),
Value<String?> serverCursor = const Value.absent(),
Value<String?> settlementsCursor = const Value.absent(),
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
Value<int> 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<String?> serverCursor = const Value.absent(),
Value<String?> settlementsCursor = const Value.absent(),
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
Value<int> 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<bool> acknowledged,
required DateTime createdAt,
Value<int> rowid,
});
typedef $$StockAdjustmentResultsTableUpdateCompanionBuilder =
StockAdjustmentResultsCompanion Function({
Value<String> id,
Value<String> payload,
Value<bool> acknowledged,
Value<DateTime> createdAt,
Value<int> rowid,
});
class $$StockAdjustmentResultsTableFilterComposer
extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> {
$$StockAdjustmentResultsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get payload => $composableBuilder(
column: $table.payload,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get acknowledged => $composableBuilder(
column: $table.acknowledged,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> 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<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get payload => $composableBuilder(
column: $table.payload,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get acknowledged => $composableBuilder(
column: $table.acknowledged,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> 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<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get payload =>
$composableBuilder(column: $table.payload, builder: (column) => column);
GeneratedColumn<bool> get acknowledged => $composableBuilder(
column: $table.acknowledged,
builder: (column) => column,
);
GeneratedColumn<DateTime> 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<String> id = const Value.absent(),
Value<String> payload = const Value.absent(),
Value<bool> acknowledged = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => StockAdjustmentResultsCompanion(
id: id,
payload: payload,
acknowledged: acknowledged,
createdAt: createdAt,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String payload,
Value<bool> acknowledged = const Value.absent(),
required DateTime createdAt,
Value<int> 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,
);
}
+112 -30
View File
@@ -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<void> 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<void> increaseStock(String productId, int amount) async {
_validateStockAmount(amount);
final updatedRows =
await (update(products)..where(
(row) => row.id.equals(productId) & row.infiniteStock.not(),
))
if (await changeTrackedStock(productId, amount) == 0) {
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<int> changeTrackedStock(String productId, int delta) {
return (update(products)
..where((row) => row.id.equals(productId) & row.infiniteStock.not()))
.write(
ProductsCompanion.custom(
stockQuantity: products.stockQuantity + Variable(amount),
stockQuantity: products.stockQuantity + Variable(delta),
),
);
}
if (updatedRows != 1) await _findProduct(productId);
/// 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<void> 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<void> rebaseStock(List<ServerStock> 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<int>('rowid')),
]))
.get();
for (final row in outbox) {
final event = jsonDecode(row.payload) as Map<String, dynamic>;
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<ProductRow> _findProduct(String productId) async {
+178
View File
@@ -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<String, dynamic> 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<String, dynamic> 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<RemoteCommand> commands;
final String? cursor;
@@ -81,12 +164,38 @@ class SyncResponse {
final List<String> acknowledgedEventIds;
final List<RejectedEvent> rejectedEvents;
/// Oldest first.
final List<StockAdjustment> stockAdjustments;
final List<String> acknowledgedStockAdjustmentResultIds;
final List<RejectedEvent> 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<ServerTab>? tabs;
/// Settlements made anywhere in the venue since [settlementsCursor] was
/// last sent, oldest first.
final List<ServerSettlement> 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<ServerStock>? 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<String, dynamic> json) {
@@ -108,8 +217,61 @@ class SyncResponse {
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(event as Map<String, dynamic>),
],
stockAdjustments: [
for (final adjustment
in json['stockAdjustments'] as List<dynamic>? ?? [])
?_parseStockAdjustment(adjustment),
],
acknowledgedStockAdjustmentResultIds: [
for (final id
in json['acknowledgedStockAdjustmentResultIds'] as List<dynamic>? ??
[])
id as String,
],
rejectedStockAdjustmentResults: [
for (final result
in json['rejectedStockAdjustmentResults'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(result as Map<String, dynamic>),
],
tabs: switch (json['tabs']) {
final List<dynamic> tabs => [
for (final tab in tabs)
ServerTab.fromJson(tab as Map<String, dynamic>),
],
_ => null,
},
settlements: [
for (final settlement in json['settlements'] as List<dynamic>? ?? [])
ServerSettlement.fromJson(settlement as Map<String, dynamic>),
],
settlementsCursor: json['settlementsCursor'] as String?,
stock: switch (json['stock']) {
final List<dynamic> 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<String, dynamic>);
} 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<String, dynamic>);
} 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<Map<String, dynamic>> events;
/// The complete product catalog with stock: a product left out is removed
/// from the dashboard.
final List<Map<String, dynamic>> products;
/// Unacknowledged stock adjustment outcomes, oldest first.
final List<Map<String, dynamic>> 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<String, dynamic> 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,
};
}
+122
View File
@@ -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: `<event id>:<index within that event>`.
final String id;
final DateTime addedAt;
final DateTime? removedAt;
const ServerUnit({required this.id, required this.addedAt, this.removedAt});
factory ServerUnit.fromJson(Map<String, dynamic> 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<ServerUnit> 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<String, dynamic> 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<dynamic>? ?? [])
ServerUnit.fromJson(unit as Map<String, dynamic>),
],
);
}
}
/// 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<ServerTabLine> lines;
const ServerTab({
required this.id,
required this.customerName,
required this.openedAt,
required this.version,
this.lines = const [],
});
factory ServerTab.fromJson(Map<String, dynamic> 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<dynamic>? ?? [])
ServerTabLine.fromJson(line as Map<String, dynamic>),
],
);
}
}
/// 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<ServerTabLine> 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<String, dynamic> 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<dynamic>? ?? [])
ServerTabLine.fromJson(line as Map<String, dynamic>),
],
);
}
}
+85 -5
View File
@@ -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<String, dynamic> 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,
+453 -65
View File
@@ -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<int> getClosedTabCount({String? customerName});
Future<List<String>> 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<void> applyServerState(List<ServerTab> tabs);
/// Adds settlements made anywhere in the venue to the history. Settlements
/// already in it (such as this tablet's own) are skipped.
Future<void> recordSettlements(List<ServerSettlement> 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<void> _removeLatestPurchases(String tabItemId, int count) async {
Future<void> _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<void> _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<void> _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<void> _incrementTabVersion(String tabId) async {
await (database.update(
database.barTabs,
@@ -300,9 +357,11 @@ class DriftBarTabService implements BarTabService {
@override
Stream<List<BarTab>> 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<BarTab> createTab({required String customerName}) async {
final id = _uuid.v4();
final name = _validateCustomerName(customerName);
final openedAt = DateTime.now();
await database.transaction(() async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: id,
customerName: _validateCustomerName(customerName),
openedAt: DateTime.now(),
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(
await _recordLineChange(
tabId: tab.id,
customerName: tab.customerName,
lineId: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
),
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(
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(
await _recordLineChange(
tabId: tabId,
customerName: tabRow.customerName,
lineId: existingItem.id,
productId: existingItem.productId,
productName: existingItem.productName,
unitPriceInCents: existingItem.unitPriceInCents,
quantity: 1,
),
delta: 1,
);
} else {
final tabItemId = _uuid.v4();
@@ -433,14 +508,14 @@ class DriftBarTabService implements BarTabService {
),
);
await _recordPurchase(tabItemId);
await _recordEvent(
eventFactory.lineAdded(
await _recordLineChange(
tabId: tabId,
customerName: tabRow.customerName,
lineId: tabItemId,
productId: product.id,
productName: product.name,
unitPriceInCents: product.priceInCents,
quantity: 1,
),
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(
await _recordLineChange(
tabId: item.tabId,
customerName: tabRow?.customerName ?? '',
lineId: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: actualDelta.abs(),
),
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<void> applyServerState(List<ServerTab> 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<int>('rowid')),
]))
.get();
for (final row in outbox) {
await _replayEvent(jsonDecode(row.payload) as Map<String, dynamic>);
}
});
}
Future<void> _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<void> _replayEvent(Map<String, dynamic> 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<dynamic>? ?? []) {
await _replaySettledLine(tabId, item as Map<String, dynamic>);
}
// 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<void> _replayLineAdded(
Map<String, dynamic> 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<void> _replaySettledLine(
String tabId,
Map<String, dynamic> 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<void> _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<TabItemRow?> _findLine(
String tabId,
Map<String, dynamic> 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<TabItemRow?> first(Expression<bool> 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<void> _setLineQuantity(String lineId, int quantity) async {
await (database.update(database.tabItems)
..where((row) => row.id.equals(lineId)))
.write(TabItemsCompanion(quantity: Value(quantity)));
}
@override
Future<void> recordSettlements(List<ServerSettlement> 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),
),
]);
});
}
});
}
}
+8 -1
View File
@@ -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) {
+39 -7
View File
@@ -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,11 +97,14 @@ class DriftProductService implements ProductService {
required int priceInCents,
required String? imagePath,
}) async {
final id = _uuid.v4();
await database.transaction(() async {
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: _uuid.v4(),
id: id,
name: name,
category: category,
stockQuantity: stockQuantity,
@@ -109,14 +114,26 @@ class DriftProductService implements ProductService {
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<void> 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(
await (database.update(
database.products,
)..where((row) => row.id.equals(product.id))).write(
ProductsCompanion(
name: Value(product.name),
category: Value(product.category),
@@ -128,6 +145,21 @@ class DriftProductService implements ProductService {
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.
+226 -14
View File
@@ -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<List<BarTab>>? _tabChanges;
StreamSubscription<Set<String>>? _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<Set<String>> _watchNewOutboxEvents() {
Set<String>? 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<void> _doSync(_Credentials credentials) async {
/// Returns whether any stock adjustment was applied, which only happens
/// with a server that doesn't own the stock.
Future<bool> _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<String, dynamic>,
],
products: products.map(_toCatalogEntry).toList(),
stockAdjustmentResults: [
for (final row in stockResults)
jsonDecode(row.payload) as Map<String, dynamic>,
],
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);
}
// 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<List<TabEventRow>> _pendingEvents() {
@@ -295,6 +370,134 @@ class RemoteSyncService {
)..where((row) => row.id.isIn(ids))).go();
}
Future<List<StockAdjustmentResultRow>> _pendingStockResults() {
return (database.select(database.stockAdjustmentResults)
..where((row) => row.acknowledged.not())
..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
])
..limit(maxStockResultsPerSync))
.get();
}
/// Received outcomes stay as the record that their adjustment was handled;
/// [pruneStockAdjustmentResults] removes them later.
Future<void> _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<bool> _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<String, dynamic> 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<String, dynamic> _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<void> 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<String, dynamic> _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<void> _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<void> _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,
),
@@ -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<T> _guard<T>(String action, Future<T> Function() body) async {
try {
return await body();
} on InsufficientStockException {
rethrow;
} catch (e, stack) {
debugPrint('BarScreenViewModel: $action error: $e');
unawaited(Sentry.captureException(e, stackTrace: stack));
+3 -15
View File
@@ -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<BarScreenView> {
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<BarScreenView> {
final viewModel = context.watch<BarScreenViewModel>();
final productsViewModel = context.watch<ProductListViewModel>();
final settings = context.watch<SettingsViewModel>().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<BarScreenView> {
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
stockByProductId: stockByProductId,
showSearch: _showSearch,
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
+4 -20
View File
@@ -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<String, int> stockByProductId;
final Future<void> 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<String, int> stockByProductId;
final Future<void> 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),
),
],
-4
View File
@@ -39,7 +39,6 @@ class TabPanel extends StatefulWidget {
final List<BarTab> tabs;
final BarTab? selectedTab;
final String? selectedTabId;
final Map<String, int> stockByProductId;
final bool showSearch;
final ValueChanged<String> onTabSelected;
final Future<void> 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<TabPanel> {
)
: SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId:
widget.stockByProductId,
onItemQuantityChanged:
widget.onItemQuantityChanged,
onCloseTabPressed:
+13 -34
View File
@@ -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,
),
),
);
+1 -1
View File
@@ -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
+3 -4
View File
@@ -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<InsufficientStockException>()),
throwsA(isA<TabNotFoundException>()),
);
expect(viewModel.loadError, isNull);
+7 -21
View File
@@ -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<InsufficientStockException>()),
);
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<InsufficientStockException>()),
);
expect((await service.getTabById(tab.id))!.version, tab.version);
});
});
group('watchOpenTabs', () {
+8
View File
@@ -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));
});
}
+9 -12
View File
@@ -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<InsufficientStockException>()),
);
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([
await Future.wait([
service.decreaseStock('concurrent-stock', 1),
service.decreaseStock('concurrent-stock', 1),
]),
throwsA(isA<InsufficientStockException>()),
);
]);
final product = await service.getProductById('concurrent-stock');
expect(product!.stockQuantity, 0);
expect(product!.stockQuantity, -1);
});
});
File diff suppressed because it is too large Load Diff
+265 -17
View File
@@ -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<BarTab> openTab(String name) async {
final tab = await service.createTab(customerName: name);
await database.delete(database.tabEvents).go();
return tab;
}
Future<BarTabRow> 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 {
test(
'deleting a tab writes line_removed for each line, then tab_deleted',
() async {
final tab = await tabWithTwoColas();
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<TabNotFoundException>()),
);
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<int> 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'});
}
}
+14 -48
View File
@@ -249,7 +249,7 @@ void main() {
final tabId = tabIds[random.nextInt(tabIds.length)];
final product = random.nextBool() ? _beer : _wine;
try {
// Running out is part of the scenario: the stock goes negative
switch (random.nextInt(10)) {
case < 6:
await tabs.addProductToTab(tabId: tabId, product: product);
@@ -268,9 +268,6 @@ void main() {
tabIds.remove(tabId);
tabIds.add((await tabs.createTab(customerName: 'New')).id);
}
} on InsufficientStockException {
// Running out is part of the scenario.
}
for (final product in [_beer, _wine]) {
final accounted =
@@ -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.
}
}
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(
final applied = await tabs.adjustTabItemQuantity(
tabItemId: await itemId(tab.id, 'wine'),
delta: 5,
),
throwsA(isA<InsufficientStockException>()),
);
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<InsufficientStockException>()),
);
final open = (await tabs.getTabById(tab.id))!;
expect(open.items, isEmpty);
expect(open.totalInCents, 0);
});
});
}
-1
View File
@@ -59,7 +59,6 @@ void main() {
tabs: tabs,
selectedTab: selectedTab,
selectedTabId: selectedTab?.id,
stockByProductId: const {},
showSearch: showSearch,
onTabSelected: (_) {},
onItemQuantityChanged: (_, _) async {},