feat: make server owner of tabs
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -162,6 +162,9 @@ class RemoteSyncState extends Table {
|
|||||||
|
|
||||||
TextColumn get serverCursor => text().nullable()();
|
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 lastSuccessfulSyncAt => dateTime().nullable()();
|
||||||
|
|
||||||
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
|
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
|
||||||
@@ -204,6 +207,26 @@ class TabEvents extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
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(
|
@DriftDatabase(
|
||||||
tables: [
|
tables: [
|
||||||
Products,
|
Products,
|
||||||
@@ -216,13 +239,14 @@ class TabEvents extends Table {
|
|||||||
RemoteSyncState,
|
RemoteSyncState,
|
||||||
ProcessedRemoteCommands,
|
ProcessedRemoteCommands,
|
||||||
TabEvents,
|
TabEvents,
|
||||||
|
StockAdjustmentResults,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 15;
|
int get schemaVersion => 17;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -325,6 +349,19 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 15) {
|
if (from < 15) {
|
||||||
await migrator.createTable(tabEvents);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3060,6 +3060,18 @@ class $RemoteSyncStateTable extends RemoteSyncState
|
|||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: false,
|
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 =
|
static const VerificationMeta _lastSuccessfulSyncAtMeta =
|
||||||
const VerificationMeta('lastSuccessfulSyncAt');
|
const VerificationMeta('lastSuccessfulSyncAt');
|
||||||
@override
|
@override
|
||||||
@@ -3098,6 +3110,7 @@ class $RemoteSyncStateTable extends RemoteSyncState
|
|||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
deviceId,
|
deviceId,
|
||||||
serverCursor,
|
serverCursor,
|
||||||
|
settlementsCursor,
|
||||||
lastSuccessfulSyncAt,
|
lastSuccessfulSyncAt,
|
||||||
lastAttemptedSyncAt,
|
lastAttemptedSyncAt,
|
||||||
consecutiveFailureCount,
|
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')) {
|
if (data.containsKey('last_successful_sync_at')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_lastSuccessfulSyncAtMeta,
|
_lastSuccessfulSyncAtMeta,
|
||||||
@@ -3175,6 +3197,10 @@ class $RemoteSyncStateTable extends RemoteSyncState
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}server_cursor'],
|
data['${effectivePrefix}server_cursor'],
|
||||||
),
|
),
|
||||||
|
settlementsCursor: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}settlements_cursor'],
|
||||||
|
),
|
||||||
lastSuccessfulSyncAt: attachedDatabase.typeMapping.read(
|
lastSuccessfulSyncAt: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.dateTime,
|
DriftSqlType.dateTime,
|
||||||
data['${effectivePrefix}last_successful_sync_at'],
|
data['${effectivePrefix}last_successful_sync_at'],
|
||||||
@@ -3200,12 +3226,16 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
implements Insertable<RemoteSyncStateRow> {
|
implements Insertable<RemoteSyncStateRow> {
|
||||||
final String deviceId;
|
final String deviceId;
|
||||||
final String? serverCursor;
|
final String? serverCursor;
|
||||||
|
|
||||||
|
/// Where the feed of settlements made anywhere in the venue left off.
|
||||||
|
final String? settlementsCursor;
|
||||||
final DateTime? lastSuccessfulSyncAt;
|
final DateTime? lastSuccessfulSyncAt;
|
||||||
final DateTime? lastAttemptedSyncAt;
|
final DateTime? lastAttemptedSyncAt;
|
||||||
final int consecutiveFailureCount;
|
final int consecutiveFailureCount;
|
||||||
const RemoteSyncStateRow({
|
const RemoteSyncStateRow({
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
this.serverCursor,
|
this.serverCursor,
|
||||||
|
this.settlementsCursor,
|
||||||
this.lastSuccessfulSyncAt,
|
this.lastSuccessfulSyncAt,
|
||||||
this.lastAttemptedSyncAt,
|
this.lastAttemptedSyncAt,
|
||||||
required this.consecutiveFailureCount,
|
required this.consecutiveFailureCount,
|
||||||
@@ -3217,6 +3247,9 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
if (!nullToAbsent || serverCursor != null) {
|
if (!nullToAbsent || serverCursor != null) {
|
||||||
map['server_cursor'] = Variable<String>(serverCursor);
|
map['server_cursor'] = Variable<String>(serverCursor);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || settlementsCursor != null) {
|
||||||
|
map['settlements_cursor'] = Variable<String>(settlementsCursor);
|
||||||
|
}
|
||||||
if (!nullToAbsent || lastSuccessfulSyncAt != null) {
|
if (!nullToAbsent || lastSuccessfulSyncAt != null) {
|
||||||
map['last_successful_sync_at'] = Variable<DateTime>(lastSuccessfulSyncAt);
|
map['last_successful_sync_at'] = Variable<DateTime>(lastSuccessfulSyncAt);
|
||||||
}
|
}
|
||||||
@@ -3233,6 +3266,9 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
serverCursor: serverCursor == null && nullToAbsent
|
serverCursor: serverCursor == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(serverCursor),
|
: Value(serverCursor),
|
||||||
|
settlementsCursor: settlementsCursor == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(settlementsCursor),
|
||||||
lastSuccessfulSyncAt: lastSuccessfulSyncAt == null && nullToAbsent
|
lastSuccessfulSyncAt: lastSuccessfulSyncAt == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(lastSuccessfulSyncAt),
|
: Value(lastSuccessfulSyncAt),
|
||||||
@@ -3251,6 +3287,9 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
return RemoteSyncStateRow(
|
return RemoteSyncStateRow(
|
||||||
deviceId: serializer.fromJson<String>(json['deviceId']),
|
deviceId: serializer.fromJson<String>(json['deviceId']),
|
||||||
serverCursor: serializer.fromJson<String?>(json['serverCursor']),
|
serverCursor: serializer.fromJson<String?>(json['serverCursor']),
|
||||||
|
settlementsCursor: serializer.fromJson<String?>(
|
||||||
|
json['settlementsCursor'],
|
||||||
|
),
|
||||||
lastSuccessfulSyncAt: serializer.fromJson<DateTime?>(
|
lastSuccessfulSyncAt: serializer.fromJson<DateTime?>(
|
||||||
json['lastSuccessfulSyncAt'],
|
json['lastSuccessfulSyncAt'],
|
||||||
),
|
),
|
||||||
@@ -3268,6 +3307,7 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'deviceId': serializer.toJson<String>(deviceId),
|
'deviceId': serializer.toJson<String>(deviceId),
|
||||||
'serverCursor': serializer.toJson<String?>(serverCursor),
|
'serverCursor': serializer.toJson<String?>(serverCursor),
|
||||||
|
'settlementsCursor': serializer.toJson<String?>(settlementsCursor),
|
||||||
'lastSuccessfulSyncAt': serializer.toJson<DateTime?>(
|
'lastSuccessfulSyncAt': serializer.toJson<DateTime?>(
|
||||||
lastSuccessfulSyncAt,
|
lastSuccessfulSyncAt,
|
||||||
),
|
),
|
||||||
@@ -3281,12 +3321,16 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
RemoteSyncStateRow copyWith({
|
RemoteSyncStateRow copyWith({
|
||||||
String? deviceId,
|
String? deviceId,
|
||||||
Value<String?> serverCursor = const Value.absent(),
|
Value<String?> serverCursor = const Value.absent(),
|
||||||
|
Value<String?> settlementsCursor = const Value.absent(),
|
||||||
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
|
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
|
||||||
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
|
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
|
||||||
int? consecutiveFailureCount,
|
int? consecutiveFailureCount,
|
||||||
}) => RemoteSyncStateRow(
|
}) => RemoteSyncStateRow(
|
||||||
deviceId: deviceId ?? this.deviceId,
|
deviceId: deviceId ?? this.deviceId,
|
||||||
serverCursor: serverCursor.present ? serverCursor.value : this.serverCursor,
|
serverCursor: serverCursor.present ? serverCursor.value : this.serverCursor,
|
||||||
|
settlementsCursor: settlementsCursor.present
|
||||||
|
? settlementsCursor.value
|
||||||
|
: this.settlementsCursor,
|
||||||
lastSuccessfulSyncAt: lastSuccessfulSyncAt.present
|
lastSuccessfulSyncAt: lastSuccessfulSyncAt.present
|
||||||
? lastSuccessfulSyncAt.value
|
? lastSuccessfulSyncAt.value
|
||||||
: this.lastSuccessfulSyncAt,
|
: this.lastSuccessfulSyncAt,
|
||||||
@@ -3302,6 +3346,9 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
serverCursor: data.serverCursor.present
|
serverCursor: data.serverCursor.present
|
||||||
? data.serverCursor.value
|
? data.serverCursor.value
|
||||||
: this.serverCursor,
|
: this.serverCursor,
|
||||||
|
settlementsCursor: data.settlementsCursor.present
|
||||||
|
? data.settlementsCursor.value
|
||||||
|
: this.settlementsCursor,
|
||||||
lastSuccessfulSyncAt: data.lastSuccessfulSyncAt.present
|
lastSuccessfulSyncAt: data.lastSuccessfulSyncAt.present
|
||||||
? data.lastSuccessfulSyncAt.value
|
? data.lastSuccessfulSyncAt.value
|
||||||
: this.lastSuccessfulSyncAt,
|
: this.lastSuccessfulSyncAt,
|
||||||
@@ -3319,6 +3366,7 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
return (StringBuffer('RemoteSyncStateRow(')
|
return (StringBuffer('RemoteSyncStateRow(')
|
||||||
..write('deviceId: $deviceId, ')
|
..write('deviceId: $deviceId, ')
|
||||||
..write('serverCursor: $serverCursor, ')
|
..write('serverCursor: $serverCursor, ')
|
||||||
|
..write('settlementsCursor: $settlementsCursor, ')
|
||||||
..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ')
|
..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ')
|
||||||
..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ')
|
..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ')
|
||||||
..write('consecutiveFailureCount: $consecutiveFailureCount')
|
..write('consecutiveFailureCount: $consecutiveFailureCount')
|
||||||
@@ -3330,6 +3378,7 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(
|
||||||
deviceId,
|
deviceId,
|
||||||
serverCursor,
|
serverCursor,
|
||||||
|
settlementsCursor,
|
||||||
lastSuccessfulSyncAt,
|
lastSuccessfulSyncAt,
|
||||||
lastAttemptedSyncAt,
|
lastAttemptedSyncAt,
|
||||||
consecutiveFailureCount,
|
consecutiveFailureCount,
|
||||||
@@ -3340,6 +3389,7 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
(other is RemoteSyncStateRow &&
|
(other is RemoteSyncStateRow &&
|
||||||
other.deviceId == this.deviceId &&
|
other.deviceId == this.deviceId &&
|
||||||
other.serverCursor == this.serverCursor &&
|
other.serverCursor == this.serverCursor &&
|
||||||
|
other.settlementsCursor == this.settlementsCursor &&
|
||||||
other.lastSuccessfulSyncAt == this.lastSuccessfulSyncAt &&
|
other.lastSuccessfulSyncAt == this.lastSuccessfulSyncAt &&
|
||||||
other.lastAttemptedSyncAt == this.lastAttemptedSyncAt &&
|
other.lastAttemptedSyncAt == this.lastAttemptedSyncAt &&
|
||||||
other.consecutiveFailureCount == this.consecutiveFailureCount);
|
other.consecutiveFailureCount == this.consecutiveFailureCount);
|
||||||
@@ -3348,6 +3398,7 @@ class RemoteSyncStateRow extends DataClass
|
|||||||
class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
||||||
final Value<String> deviceId;
|
final Value<String> deviceId;
|
||||||
final Value<String?> serverCursor;
|
final Value<String?> serverCursor;
|
||||||
|
final Value<String?> settlementsCursor;
|
||||||
final Value<DateTime?> lastSuccessfulSyncAt;
|
final Value<DateTime?> lastSuccessfulSyncAt;
|
||||||
final Value<DateTime?> lastAttemptedSyncAt;
|
final Value<DateTime?> lastAttemptedSyncAt;
|
||||||
final Value<int> consecutiveFailureCount;
|
final Value<int> consecutiveFailureCount;
|
||||||
@@ -3355,6 +3406,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
const RemoteSyncStateCompanion({
|
const RemoteSyncStateCompanion({
|
||||||
this.deviceId = const Value.absent(),
|
this.deviceId = const Value.absent(),
|
||||||
this.serverCursor = const Value.absent(),
|
this.serverCursor = const Value.absent(),
|
||||||
|
this.settlementsCursor = const Value.absent(),
|
||||||
this.lastSuccessfulSyncAt = const Value.absent(),
|
this.lastSuccessfulSyncAt = const Value.absent(),
|
||||||
this.lastAttemptedSyncAt = const Value.absent(),
|
this.lastAttemptedSyncAt = const Value.absent(),
|
||||||
this.consecutiveFailureCount = const Value.absent(),
|
this.consecutiveFailureCount = const Value.absent(),
|
||||||
@@ -3363,6 +3415,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
RemoteSyncStateCompanion.insert({
|
RemoteSyncStateCompanion.insert({
|
||||||
required String deviceId,
|
required String deviceId,
|
||||||
this.serverCursor = const Value.absent(),
|
this.serverCursor = const Value.absent(),
|
||||||
|
this.settlementsCursor = const Value.absent(),
|
||||||
this.lastSuccessfulSyncAt = const Value.absent(),
|
this.lastSuccessfulSyncAt = const Value.absent(),
|
||||||
this.lastAttemptedSyncAt = const Value.absent(),
|
this.lastAttemptedSyncAt = const Value.absent(),
|
||||||
this.consecutiveFailureCount = const Value.absent(),
|
this.consecutiveFailureCount = const Value.absent(),
|
||||||
@@ -3371,6 +3424,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
static Insertable<RemoteSyncStateRow> custom({
|
static Insertable<RemoteSyncStateRow> custom({
|
||||||
Expression<String>? deviceId,
|
Expression<String>? deviceId,
|
||||||
Expression<String>? serverCursor,
|
Expression<String>? serverCursor,
|
||||||
|
Expression<String>? settlementsCursor,
|
||||||
Expression<DateTime>? lastSuccessfulSyncAt,
|
Expression<DateTime>? lastSuccessfulSyncAt,
|
||||||
Expression<DateTime>? lastAttemptedSyncAt,
|
Expression<DateTime>? lastAttemptedSyncAt,
|
||||||
Expression<int>? consecutiveFailureCount,
|
Expression<int>? consecutiveFailureCount,
|
||||||
@@ -3379,6 +3433,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
if (deviceId != null) 'device_id': deviceId,
|
if (deviceId != null) 'device_id': deviceId,
|
||||||
if (serverCursor != null) 'server_cursor': serverCursor,
|
if (serverCursor != null) 'server_cursor': serverCursor,
|
||||||
|
if (settlementsCursor != null) 'settlements_cursor': settlementsCursor,
|
||||||
if (lastSuccessfulSyncAt != null)
|
if (lastSuccessfulSyncAt != null)
|
||||||
'last_successful_sync_at': lastSuccessfulSyncAt,
|
'last_successful_sync_at': lastSuccessfulSyncAt,
|
||||||
if (lastAttemptedSyncAt != null)
|
if (lastAttemptedSyncAt != null)
|
||||||
@@ -3392,6 +3447,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
RemoteSyncStateCompanion copyWith({
|
RemoteSyncStateCompanion copyWith({
|
||||||
Value<String>? deviceId,
|
Value<String>? deviceId,
|
||||||
Value<String?>? serverCursor,
|
Value<String?>? serverCursor,
|
||||||
|
Value<String?>? settlementsCursor,
|
||||||
Value<DateTime?>? lastSuccessfulSyncAt,
|
Value<DateTime?>? lastSuccessfulSyncAt,
|
||||||
Value<DateTime?>? lastAttemptedSyncAt,
|
Value<DateTime?>? lastAttemptedSyncAt,
|
||||||
Value<int>? consecutiveFailureCount,
|
Value<int>? consecutiveFailureCount,
|
||||||
@@ -3400,6 +3456,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
return RemoteSyncStateCompanion(
|
return RemoteSyncStateCompanion(
|
||||||
deviceId: deviceId ?? this.deviceId,
|
deviceId: deviceId ?? this.deviceId,
|
||||||
serverCursor: serverCursor ?? this.serverCursor,
|
serverCursor: serverCursor ?? this.serverCursor,
|
||||||
|
settlementsCursor: settlementsCursor ?? this.settlementsCursor,
|
||||||
lastSuccessfulSyncAt: lastSuccessfulSyncAt ?? this.lastSuccessfulSyncAt,
|
lastSuccessfulSyncAt: lastSuccessfulSyncAt ?? this.lastSuccessfulSyncAt,
|
||||||
lastAttemptedSyncAt: lastAttemptedSyncAt ?? this.lastAttemptedSyncAt,
|
lastAttemptedSyncAt: lastAttemptedSyncAt ?? this.lastAttemptedSyncAt,
|
||||||
consecutiveFailureCount:
|
consecutiveFailureCount:
|
||||||
@@ -3417,6 +3474,9 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
if (serverCursor.present) {
|
if (serverCursor.present) {
|
||||||
map['server_cursor'] = Variable<String>(serverCursor.value);
|
map['server_cursor'] = Variable<String>(serverCursor.value);
|
||||||
}
|
}
|
||||||
|
if (settlementsCursor.present) {
|
||||||
|
map['settlements_cursor'] = Variable<String>(settlementsCursor.value);
|
||||||
|
}
|
||||||
if (lastSuccessfulSyncAt.present) {
|
if (lastSuccessfulSyncAt.present) {
|
||||||
map['last_successful_sync_at'] = Variable<DateTime>(
|
map['last_successful_sync_at'] = Variable<DateTime>(
|
||||||
lastSuccessfulSyncAt.value,
|
lastSuccessfulSyncAt.value,
|
||||||
@@ -3443,6 +3503,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
|
|||||||
return (StringBuffer('RemoteSyncStateCompanion(')
|
return (StringBuffer('RemoteSyncStateCompanion(')
|
||||||
..write('deviceId: $deviceId, ')
|
..write('deviceId: $deviceId, ')
|
||||||
..write('serverCursor: $serverCursor, ')
|
..write('serverCursor: $serverCursor, ')
|
||||||
|
..write('settlementsCursor: $settlementsCursor, ')
|
||||||
..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ')
|
..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ')
|
||||||
..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ')
|
..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ')
|
||||||
..write('consecutiveFailureCount: $consecutiveFailureCount, ')
|
..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 {
|
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||||
_$AppDatabase(QueryExecutor e) : super(e);
|
_$AppDatabase(QueryExecutor e) : super(e);
|
||||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||||
@@ -4080,6 +4467,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
late final $ProcessedRemoteCommandsTable processedRemoteCommands =
|
late final $ProcessedRemoteCommandsTable processedRemoteCommands =
|
||||||
$ProcessedRemoteCommandsTable(this);
|
$ProcessedRemoteCommandsTable(this);
|
||||||
late final $TabEventsTable tabEvents = $TabEventsTable(this);
|
late final $TabEventsTable tabEvents = $TabEventsTable(this);
|
||||||
|
late final $StockAdjustmentResultsTable stockAdjustmentResults =
|
||||||
|
$StockAdjustmentResultsTable(this);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
@@ -4095,6 +4484,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
|||||||
remoteSyncState,
|
remoteSyncState,
|
||||||
processedRemoteCommands,
|
processedRemoteCommands,
|
||||||
tabEvents,
|
tabEvents,
|
||||||
|
stockAdjustmentResults,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
|
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
|
||||||
@@ -6355,6 +6745,7 @@ typedef $$RemoteSyncStateTableCreateCompanionBuilder =
|
|||||||
RemoteSyncStateCompanion Function({
|
RemoteSyncStateCompanion Function({
|
||||||
required String deviceId,
|
required String deviceId,
|
||||||
Value<String?> serverCursor,
|
Value<String?> serverCursor,
|
||||||
|
Value<String?> settlementsCursor,
|
||||||
Value<DateTime?> lastSuccessfulSyncAt,
|
Value<DateTime?> lastSuccessfulSyncAt,
|
||||||
Value<DateTime?> lastAttemptedSyncAt,
|
Value<DateTime?> lastAttemptedSyncAt,
|
||||||
Value<int> consecutiveFailureCount,
|
Value<int> consecutiveFailureCount,
|
||||||
@@ -6364,6 +6755,7 @@ typedef $$RemoteSyncStateTableUpdateCompanionBuilder =
|
|||||||
RemoteSyncStateCompanion Function({
|
RemoteSyncStateCompanion Function({
|
||||||
Value<String> deviceId,
|
Value<String> deviceId,
|
||||||
Value<String?> serverCursor,
|
Value<String?> serverCursor,
|
||||||
|
Value<String?> settlementsCursor,
|
||||||
Value<DateTime?> lastSuccessfulSyncAt,
|
Value<DateTime?> lastSuccessfulSyncAt,
|
||||||
Value<DateTime?> lastAttemptedSyncAt,
|
Value<DateTime?> lastAttemptedSyncAt,
|
||||||
Value<int> consecutiveFailureCount,
|
Value<int> consecutiveFailureCount,
|
||||||
@@ -6389,6 +6781,11 @@ class $$RemoteSyncStateTableFilterComposer
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get settlementsCursor => $composableBuilder(
|
||||||
|
column: $table.settlementsCursor,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnFilters<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
|
ColumnFilters<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
|
||||||
column: $table.lastSuccessfulSyncAt,
|
column: $table.lastSuccessfulSyncAt,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
@@ -6424,6 +6821,11 @@ class $$RemoteSyncStateTableOrderingComposer
|
|||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get settlementsCursor => $composableBuilder(
|
||||||
|
column: $table.settlementsCursor,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
|
ColumnOrderings<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
|
||||||
column: $table.lastSuccessfulSyncAt,
|
column: $table.lastSuccessfulSyncAt,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
@@ -6457,6 +6859,11 @@ class $$RemoteSyncStateTableAnnotationComposer
|
|||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get settlementsCursor => $composableBuilder(
|
||||||
|
column: $table.settlementsCursor,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
GeneratedColumn<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
|
GeneratedColumn<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
|
||||||
column: $table.lastSuccessfulSyncAt,
|
column: $table.lastSuccessfulSyncAt,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
@@ -6512,6 +6919,7 @@ class $$RemoteSyncStateTableTableManager
|
|||||||
({
|
({
|
||||||
Value<String> deviceId = const Value.absent(),
|
Value<String> deviceId = const Value.absent(),
|
||||||
Value<String?> serverCursor = const Value.absent(),
|
Value<String?> serverCursor = const Value.absent(),
|
||||||
|
Value<String?> settlementsCursor = const Value.absent(),
|
||||||
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
|
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
|
||||||
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
|
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
|
||||||
Value<int> consecutiveFailureCount = const Value.absent(),
|
Value<int> consecutiveFailureCount = const Value.absent(),
|
||||||
@@ -6519,6 +6927,7 @@ class $$RemoteSyncStateTableTableManager
|
|||||||
}) => RemoteSyncStateCompanion(
|
}) => RemoteSyncStateCompanion(
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
serverCursor: serverCursor,
|
serverCursor: serverCursor,
|
||||||
|
settlementsCursor: settlementsCursor,
|
||||||
lastSuccessfulSyncAt: lastSuccessfulSyncAt,
|
lastSuccessfulSyncAt: lastSuccessfulSyncAt,
|
||||||
lastAttemptedSyncAt: lastAttemptedSyncAt,
|
lastAttemptedSyncAt: lastAttemptedSyncAt,
|
||||||
consecutiveFailureCount: consecutiveFailureCount,
|
consecutiveFailureCount: consecutiveFailureCount,
|
||||||
@@ -6528,6 +6937,7 @@ class $$RemoteSyncStateTableTableManager
|
|||||||
({
|
({
|
||||||
required String deviceId,
|
required String deviceId,
|
||||||
Value<String?> serverCursor = const Value.absent(),
|
Value<String?> serverCursor = const Value.absent(),
|
||||||
|
Value<String?> settlementsCursor = const Value.absent(),
|
||||||
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
|
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
|
||||||
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
|
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
|
||||||
Value<int> consecutiveFailureCount = const Value.absent(),
|
Value<int> consecutiveFailureCount = const Value.absent(),
|
||||||
@@ -6535,6 +6945,7 @@ class $$RemoteSyncStateTableTableManager
|
|||||||
}) => RemoteSyncStateCompanion.insert(
|
}) => RemoteSyncStateCompanion.insert(
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
serverCursor: serverCursor,
|
serverCursor: serverCursor,
|
||||||
|
settlementsCursor: settlementsCursor,
|
||||||
lastSuccessfulSyncAt: lastSuccessfulSyncAt,
|
lastSuccessfulSyncAt: lastSuccessfulSyncAt,
|
||||||
lastAttemptedSyncAt: lastAttemptedSyncAt,
|
lastAttemptedSyncAt: lastAttemptedSyncAt,
|
||||||
consecutiveFailureCount: consecutiveFailureCount,
|
consecutiveFailureCount: consecutiveFailureCount,
|
||||||
@@ -6936,6 +7347,208 @@ typedef $$TabEventsTableProcessedTableManager =
|
|||||||
TabEventRow,
|
TabEventRow,
|
||||||
PrefetchHooks Function()
|
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 {
|
class $AppDatabaseManager {
|
||||||
final _$AppDatabase _db;
|
final _$AppDatabase _db;
|
||||||
@@ -6963,4 +7576,9 @@ class $AppDatabaseManager {
|
|||||||
);
|
);
|
||||||
$$TabEventsTableTableManager get tabEvents =>
|
$$TabEventsTableTableManager get tabEvents =>
|
||||||
$$TabEventsTableTableManager(_db, _db.tabEvents);
|
$$TabEventsTableTableManager(_db, _db.tabEvents);
|
||||||
|
$$StockAdjustmentResultsTableTableManager get stockAdjustmentResults =>
|
||||||
|
$$StockAdjustmentResultsTableTableManager(
|
||||||
|
_db,
|
||||||
|
_db.stockAdjustmentResults,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-34
@@ -1,14 +1,11 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../models/remote_command.dart';
|
||||||
|
import '../models/tab_event.dart';
|
||||||
import 'app_database.dart';
|
import 'app_database.dart';
|
||||||
|
|
||||||
class InsufficientStockException implements Exception {
|
|
||||||
const InsufficientStockException();
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'Not enough stock';
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProductNotFoundException implements Exception {
|
class ProductNotFoundException implements Exception {
|
||||||
final String productId;
|
final String productId;
|
||||||
|
|
||||||
@@ -20,44 +17,129 @@ class ProductNotFoundException implements Exception {
|
|||||||
|
|
||||||
/// Atomic stock updates shared by the product and tab services, so they can
|
/// Atomic stock updates shared by the product and tab services, so they can
|
||||||
/// run inside either one's transaction.
|
/// run inside either one's transaction.
|
||||||
|
///
|
||||||
|
/// Stock may go below zero: the bar keeps serving when the count was off.
|
||||||
extension StockQueries on AppDatabase {
|
extension StockQueries on AppDatabase {
|
||||||
/// Products with infinite stock are only checked for existence.
|
/// Products with infinite stock are only checked for existence.
|
||||||
Future<void> decreaseStock(String productId, int amount) async {
|
Future<void> decreaseStock(String productId, int amount) async {
|
||||||
_validateStockAmount(amount);
|
_validateStockAmount(amount);
|
||||||
|
|
||||||
final updatedRows =
|
if (await changeTrackedStock(productId, -amount) == 0) {
|
||||||
await (update(products)..where(
|
await _findProduct(productId);
|
||||||
(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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> increaseStock(String productId, int amount) async {
|
Future<void> increaseStock(String productId, int amount) async {
|
||||||
_validateStockAmount(amount);
|
_validateStockAmount(amount);
|
||||||
|
|
||||||
final updatedRows =
|
if (await changeTrackedStock(productId, amount) == 0) {
|
||||||
await (update(products)..where(
|
await _findProduct(productId);
|
||||||
(row) => row.id.equals(productId) & row.infiniteStock.not(),
|
}
|
||||||
))
|
}
|
||||||
.write(
|
|
||||||
ProductsCompanion.custom(
|
|
||||||
stockQuantity: products.stockQuantity + Variable(amount),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updatedRows != 1) await _findProduct(productId);
|
/// Adds [delta] to the stock of [productId] when its stock is tracked.
|
||||||
|
/// Returns the number of products changed: 0 when the product has infinite
|
||||||
|
/// stock or no longer exists, which is not an error.
|
||||||
|
Future<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(delta),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues a `stock_set` event, so a stock count or tracking change made on
|
||||||
|
/// this tablet reaches the server. Call it inside the transaction that
|
||||||
|
/// writes the stock.
|
||||||
|
Future<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 {
|
Future<ProductRow> _findProduct(String productId) async {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../utils/app_config.dart';
|
import '../utils/app_config.dart';
|
||||||
|
import 'server_tab.dart';
|
||||||
|
|
||||||
enum CommandType {
|
enum CommandType {
|
||||||
closeTab('close_tab');
|
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 {
|
class SyncResponse {
|
||||||
final List<RemoteCommand> commands;
|
final List<RemoteCommand> commands;
|
||||||
final String? cursor;
|
final String? cursor;
|
||||||
@@ -81,12 +164,38 @@ class SyncResponse {
|
|||||||
final List<String> acknowledgedEventIds;
|
final List<String> acknowledgedEventIds;
|
||||||
final List<RejectedEvent> rejectedEvents;
|
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({
|
const SyncResponse({
|
||||||
required this.commands,
|
required this.commands,
|
||||||
this.cursor,
|
this.cursor,
|
||||||
this.plannedTabNames = const [],
|
this.plannedTabNames = const [],
|
||||||
this.acknowledgedEventIds = const [],
|
this.acknowledgedEventIds = const [],
|
||||||
this.rejectedEvents = 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) {
|
factory SyncResponse.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -108,8 +217,61 @@ class SyncResponse {
|
|||||||
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
|
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
|
||||||
RejectedEvent.fromJson(event as Map<String, 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".
|
/// 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.
|
/// Outbox tab events, oldest first, as their stored JSON objects.
|
||||||
final List<Map<String, dynamic>> events;
|
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({
|
const SyncRequest({
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
this.cursor,
|
this.cursor,
|
||||||
required this.openTabs,
|
required this.openTabs,
|
||||||
this.events = const [],
|
this.events = const [],
|
||||||
|
this.products = const [],
|
||||||
|
this.stockAdjustmentResults = const [],
|
||||||
|
this.settlementsCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -178,6 +353,9 @@ class SyncRequest {
|
|||||||
if (cursor != null) 'cursor': cursor,
|
if (cursor != null) 'cursor': cursor,
|
||||||
'openTabs': openTabs.map((tab) => tab.toJson()).toList(),
|
'openTabs': openTabs.map((tab) => tab.toJson()).toList(),
|
||||||
'events': events,
|
'events': events,
|
||||||
|
'products': products,
|
||||||
|
'stockAdjustmentResults': stockAdjustmentResults,
|
||||||
|
if (settlementsCursor != null) 'settlementsCursor': settlementsCursor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,33 @@ import 'payment_method.dart';
|
|||||||
|
|
||||||
/// Types of [TabEvent] the server understands.
|
/// Types of [TabEvent] the server understands.
|
||||||
enum TabEventType {
|
enum TabEventType {
|
||||||
|
tabOpened('tab_opened'),
|
||||||
|
tabRenamed('tab_renamed'),
|
||||||
|
tabDeleted('tab_deleted'),
|
||||||
lineAdded('line_added'),
|
lineAdded('line_added'),
|
||||||
lineRemoved('line_removed'),
|
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;
|
final String value;
|
||||||
|
|
||||||
const TabEventType(this.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
|
/// Something that happened on a tab. Together the events form the operation
|
||||||
/// statistics. [payload] is the JSON object that is stored in the outbox and
|
/// log the server builds the venue's tabs from, and the tablet replays the
|
||||||
/// sent as is.
|
/// 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 {
|
class TabEvent {
|
||||||
final String id;
|
final String id;
|
||||||
final Map<String, dynamic> payload;
|
final Map<String, dynamic> payload;
|
||||||
@@ -25,6 +40,7 @@ class TabEvent {
|
|||||||
|
|
||||||
/// One settled line in a `settled` event.
|
/// One settled line in a `settled` event.
|
||||||
typedef SettledLine = ({
|
typedef SettledLine = ({
|
||||||
|
String productId,
|
||||||
String productName,
|
String productName,
|
||||||
int quantity,
|
int quantity,
|
||||||
int unitPriceInCents,
|
int unitPriceInCents,
|
||||||
@@ -37,10 +53,46 @@ class TabEventFactory {
|
|||||||
|
|
||||||
TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now;
|
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({
|
TabEvent lineAdded({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required String customerName,
|
required String customerName,
|
||||||
|
required String lineId,
|
||||||
|
required String productId,
|
||||||
required String productName,
|
required String productName,
|
||||||
required int unitPriceInCents,
|
required int unitPriceInCents,
|
||||||
required int quantity,
|
required int quantity,
|
||||||
@@ -49,6 +101,8 @@ class TabEventFactory {
|
|||||||
TabEventType.lineAdded,
|
TabEventType.lineAdded,
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
customerName: customerName,
|
customerName: customerName,
|
||||||
|
lineId: lineId,
|
||||||
|
productId: productId,
|
||||||
productName: productName,
|
productName: productName,
|
||||||
unitPriceInCents: unitPriceInCents,
|
unitPriceInCents: unitPriceInCents,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
@@ -59,6 +113,8 @@ class TabEventFactory {
|
|||||||
TabEvent lineRemoved({
|
TabEvent lineRemoved({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required String customerName,
|
required String customerName,
|
||||||
|
required String lineId,
|
||||||
|
required String productId,
|
||||||
required String productName,
|
required String productName,
|
||||||
required int unitPriceInCents,
|
required int unitPriceInCents,
|
||||||
required int quantity,
|
required int quantity,
|
||||||
@@ -67,6 +123,8 @@ class TabEventFactory {
|
|||||||
TabEventType.lineRemoved,
|
TabEventType.lineRemoved,
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
customerName: customerName,
|
customerName: customerName,
|
||||||
|
lineId: lineId,
|
||||||
|
productId: productId,
|
||||||
productName: productName,
|
productName: productName,
|
||||||
unitPriceInCents: unitPriceInCents,
|
unitPriceInCents: unitPriceInCents,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
@@ -92,6 +150,7 @@ class TabEventFactory {
|
|||||||
'items': [
|
'items': [
|
||||||
for (final item in items)
|
for (final item in items)
|
||||||
{
|
{
|
||||||
|
'productId': item.productId,
|
||||||
'productName': item.productName,
|
'productName': item.productName,
|
||||||
'quantity': item.quantity,
|
'quantity': item.quantity,
|
||||||
'unitPriceInCents': item.unitPriceInCents,
|
'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`,
|
/// The server's payment method names: `cash`, `payconiq`,
|
||||||
/// `payment_pending` or `other`.
|
/// `payment_pending` or `other`.
|
||||||
static String paymentMethodValue(PaymentMethod method) {
|
static String paymentMethodValue(PaymentMethod method) {
|
||||||
@@ -115,6 +191,8 @@ class TabEventFactory {
|
|||||||
TabEventType type, {
|
TabEventType type, {
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required String customerName,
|
required String customerName,
|
||||||
|
required String lineId,
|
||||||
|
required String productId,
|
||||||
required String productName,
|
required String productName,
|
||||||
required int unitPriceInCents,
|
required int unitPriceInCents,
|
||||||
required int quantity,
|
required int quantity,
|
||||||
@@ -128,6 +206,8 @@ class TabEventFactory {
|
|||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
customerName: customerName,
|
customerName: customerName,
|
||||||
fields: {
|
fields: {
|
||||||
|
'lineId': lineId,
|
||||||
|
'productId': productId,
|
||||||
'productName': productName,
|
'productName': productName,
|
||||||
'unitPriceInCents': unitPriceInCents,
|
'unitPriceInCents': unitPriceInCents,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show listEquals;
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
@@ -11,6 +12,7 @@ import '../models/closed_tab.dart';
|
|||||||
import '../models/closed_tab_item.dart';
|
import '../models/closed_tab_item.dart';
|
||||||
import '../models/payment_method.dart';
|
import '../models/payment_method.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
|
import '../models/server_tab.dart';
|
||||||
import '../models/tab_event.dart';
|
import '../models/tab_event.dart';
|
||||||
import '../models/tab_item.dart';
|
import '../models/tab_item.dart';
|
||||||
import '../models/tab_item_purchase.dart';
|
import '../models/tab_item_purchase.dart';
|
||||||
@@ -91,6 +93,16 @@ abstract class BarTabService {
|
|||||||
Future<int> getClosedTabCount({String? customerName});
|
Future<int> getClosedTabCount({String? customerName});
|
||||||
|
|
||||||
Future<List<String>> getDistinctCustomerNames();
|
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 {
|
class DriftBarTabService implements BarTabService {
|
||||||
@@ -226,7 +238,11 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
/// Marks the most recent active purchases as removed, keeping them in the
|
/// Marks the most recent active purchases as removed, keeping them in the
|
||||||
/// audit trail.
|
/// audit trail.
|
||||||
Future<void> _removeLatestPurchases(String tabItemId, int count) async {
|
Future<void> _removeLatestPurchases(
|
||||||
|
String tabItemId,
|
||||||
|
int count, {
|
||||||
|
DateTime? removedAt,
|
||||||
|
}) async {
|
||||||
final latest =
|
final latest =
|
||||||
await (database.select(database.tabItemPurchases)
|
await (database.select(database.tabItemPurchases)
|
||||||
..where(
|
..where(
|
||||||
@@ -243,7 +259,18 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
await (database.update(database.tabItemPurchases)
|
await (database.update(database.tabItemPurchases)
|
||||||
..where((purchase) => purchase.id.isIn(latest.map((row) => row.id))))
|
..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.
|
/// 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 {
|
Future<void> _incrementTabVersion(String tabId) async {
|
||||||
await (database.update(
|
await (database.update(
|
||||||
database.barTabs,
|
database.barTabs,
|
||||||
@@ -300,9 +357,11 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<BarTab>> watchOpenTabs() {
|
Stream<List<BarTab>> watchOpenTabs() {
|
||||||
return _tabsQuery().watch().map(
|
return _tabsQuery()
|
||||||
(rows) => _groupTabs(rows, includeRemoved: false),
|
.watch()
|
||||||
);
|
.map((rows) => _groupTabs(rows, includeRemoved: false))
|
||||||
|
// A sync rewrites the tabs even when nothing changed
|
||||||
|
.distinct(listEquals);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -320,17 +379,28 @@ class DriftBarTabService implements BarTabService {
|
|||||||
@override
|
@override
|
||||||
Future<BarTab> createTab({required String customerName}) async {
|
Future<BarTab> createTab({required String customerName}) async {
|
||||||
final id = _uuid.v4();
|
final id = _uuid.v4();
|
||||||
|
final name = _validateCustomerName(customerName);
|
||||||
|
final openedAt = DateTime.now();
|
||||||
|
|
||||||
await database
|
await database.transaction(() async {
|
||||||
.into(database.barTabs)
|
await database
|
||||||
.insert(
|
.into(database.barTabs)
|
||||||
BarTabsCompanion.insert(
|
.insert(
|
||||||
id: id,
|
BarTabsCompanion.insert(
|
||||||
customerName: _validateCustomerName(customerName),
|
id: id,
|
||||||
openedAt: DateTime.now(),
|
customerName: name,
|
||||||
version: const Value(1),
|
openedAt: openedAt,
|
||||||
),
|
version: const Value(1),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
await _recordEvent(
|
||||||
|
eventFactory.tabOpened(
|
||||||
|
tabId: id,
|
||||||
|
customerName: name,
|
||||||
|
openedAt: openedAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (await getTabById(id))!;
|
return (await getTabById(id))!;
|
||||||
}
|
}
|
||||||
@@ -350,6 +420,9 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
if (updatedRows != 1) throw TabNotFoundException(tabId);
|
if (updatedRows != 1) throw TabNotFoundException(tabId);
|
||||||
|
|
||||||
|
await _recordEvent(
|
||||||
|
eventFactory.tabRenamed(tabId: tabId, customerName: name),
|
||||||
|
);
|
||||||
await _incrementTabVersion(tabId);
|
await _incrementTabVersion(tabId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -361,22 +434,21 @@ class DriftBarTabService implements BarTabService {
|
|||||||
if (tab == null) return;
|
if (tab == null) return;
|
||||||
|
|
||||||
for (final item in tab.items) {
|
for (final item in tab.items) {
|
||||||
await database.increaseStock(item.productId, item.quantity);
|
await _recordLineChange(
|
||||||
await _recordEvent(
|
tabId: tab.id,
|
||||||
eventFactory.lineRemoved(
|
customerName: tab.customerName,
|
||||||
tabId: tab.id,
|
lineId: item.id,
|
||||||
customerName: tab.customerName,
|
productId: item.productId,
|
||||||
productName: item.productName,
|
productName: item.productName,
|
||||||
unitPriceInCents: item.unitPriceInCents,
|
unitPriceInCents: item.unitPriceInCents,
|
||||||
quantity: item.quantity,
|
delta: -item.quantity,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await _recordEvent(
|
||||||
|
eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName),
|
||||||
|
);
|
||||||
|
|
||||||
await _deleteItemsOfTab(tabId);
|
await _deleteTabWithItems(tabId);
|
||||||
await (database.delete(
|
|
||||||
database.barTabs,
|
|
||||||
)..where((row) => row.id.equals(tabId))).go();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,14 +461,17 @@ class DriftBarTabService implements BarTabService {
|
|||||||
final tabRow = await _getTabRow(tabId);
|
final tabRow = await _getTabRow(tabId);
|
||||||
if (tabRow == null) throw TabNotFoundException(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 =
|
final existingItem =
|
||||||
await (database.select(database.tabItems)..where(
|
await (database.select(database.tabItems)
|
||||||
(item) =>
|
..where(
|
||||||
item.tabId.equals(tabId) &
|
(item) =>
|
||||||
item.productId.equals(product.id),
|
item.tabId.equals(tabId) &
|
||||||
))
|
item.productId.equals(product.id),
|
||||||
|
)
|
||||||
|
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||||
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (existingItem != null) {
|
if (existingItem != null) {
|
||||||
@@ -407,14 +482,14 @@ class DriftBarTabService implements BarTabService {
|
|||||||
);
|
);
|
||||||
await _recordPurchase(existingItem.id);
|
await _recordPurchase(existingItem.id);
|
||||||
// The line keeps the price it was first added at
|
// The line keeps the price it was first added at
|
||||||
await _recordEvent(
|
await _recordLineChange(
|
||||||
eventFactory.lineAdded(
|
tabId: tabId,
|
||||||
tabId: tabId,
|
customerName: tabRow.customerName,
|
||||||
customerName: tabRow.customerName,
|
lineId: existingItem.id,
|
||||||
productName: existingItem.productName,
|
productId: existingItem.productId,
|
||||||
unitPriceInCents: existingItem.unitPriceInCents,
|
productName: existingItem.productName,
|
||||||
quantity: 1,
|
unitPriceInCents: existingItem.unitPriceInCents,
|
||||||
),
|
delta: 1,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final tabItemId = _uuid.v4();
|
final tabItemId = _uuid.v4();
|
||||||
@@ -433,14 +508,14 @@ class DriftBarTabService implements BarTabService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await _recordPurchase(tabItemId);
|
await _recordPurchase(tabItemId);
|
||||||
await _recordEvent(
|
await _recordLineChange(
|
||||||
eventFactory.lineAdded(
|
tabId: tabId,
|
||||||
tabId: tabId,
|
customerName: tabRow.customerName,
|
||||||
customerName: tabRow.customerName,
|
lineId: tabItemId,
|
||||||
productName: product.name,
|
productId: product.id,
|
||||||
unitPriceInCents: product.priceInCents,
|
productName: product.name,
|
||||||
quantity: 1,
|
unitPriceInCents: product.priceInCents,
|
||||||
),
|
delta: 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,12 +543,10 @@ class DriftBarTabService implements BarTabService {
|
|||||||
if (actualDelta == 0) return 0;
|
if (actualDelta == 0) return 0;
|
||||||
|
|
||||||
if (actualDelta > 0) {
|
if (actualDelta > 0) {
|
||||||
await database.decreaseStock(item.productId, actualDelta);
|
|
||||||
for (var index = 0; index < actualDelta; index++) {
|
for (var index = 0; index < actualDelta; index++) {
|
||||||
await _recordPurchase(item.id);
|
await _recordPurchase(item.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await database.increaseStock(item.productId, -actualDelta);
|
|
||||||
await _removeLatestPurchases(item.id, -actualDelta);
|
await _removeLatestPurchases(item.id, -actualDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,17 +555,14 @@ class DriftBarTabService implements BarTabService {
|
|||||||
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
||||||
|
|
||||||
final tabRow = await _getTabRow(item.tabId);
|
final tabRow = await _getTabRow(item.tabId);
|
||||||
final lineEvent = actualDelta > 0
|
await _recordLineChange(
|
||||||
? eventFactory.lineAdded
|
tabId: item.tabId,
|
||||||
: eventFactory.lineRemoved;
|
customerName: tabRow?.customerName ?? '',
|
||||||
await _recordEvent(
|
lineId: item.id,
|
||||||
lineEvent(
|
productId: item.productId,
|
||||||
tabId: item.tabId,
|
productName: item.productName,
|
||||||
customerName: tabRow?.customerName ?? '',
|
unitPriceInCents: item.unitPriceInCents,
|
||||||
productName: item.productName,
|
delta: actualDelta,
|
||||||
unitPriceInCents: item.unitPriceInCents,
|
|
||||||
quantity: actualDelta.abs(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await _incrementTabVersion(item.tabId);
|
await _incrementTabVersion(item.tabId);
|
||||||
@@ -524,7 +594,36 @@ class DriftBarTabService implements BarTabService {
|
|||||||
|
|
||||||
if (items.isEmpty) return;
|
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
|
await database
|
||||||
.into(database.closedTabs)
|
.into(database.closedTabs)
|
||||||
@@ -555,33 +654,7 @@ class DriftBarTabService implements BarTabService {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// One settlement, not a removal per line: these drinks were sold
|
if (settledEvent != null) await _recordEvent(settledEvent);
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _deleteItemsOfTab(tabId);
|
await _deleteItemsOfTab(tabId);
|
||||||
await _incrementTabVersion(tabId);
|
await _incrementTabVersion(tabId);
|
||||||
@@ -679,4 +752,319 @@ class DriftBarTabService implements BarTabService {
|
|||||||
final rows = await query.get();
|
final rows = await query.get();
|
||||||
return rows.map((row) => row.read(customerName)!).toList();
|
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),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
|
import '../database/stock_queries.dart';
|
||||||
|
import '../models/tab_event.dart';
|
||||||
|
|
||||||
class DefaultProductSeeder {
|
class DefaultProductSeeder {
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
@@ -46,6 +48,7 @@ class DefaultProductSeeder {
|
|||||||
|
|
||||||
await database.transaction(() async {
|
await database.transaction(() async {
|
||||||
final uuid = const Uuid();
|
final uuid = const Uuid();
|
||||||
|
final events = TabEventFactory();
|
||||||
|
|
||||||
for (final (name, priceInCents, stock, category) in _defaultProducts) {
|
for (final (name, priceInCents, stock, category) in _defaultProducts) {
|
||||||
String? imagePath;
|
String? imagePath;
|
||||||
@@ -54,11 +57,12 @@ class DefaultProductSeeder {
|
|||||||
imagePath = await _copyBundledImage(name, imagesDir);
|
imagePath = await _copyBundledImage(name, imagesDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final id = uuid.v4();
|
||||||
await database
|
await database
|
||||||
.into(database.products)
|
.into(database.products)
|
||||||
.insert(
|
.insert(
|
||||||
ProductsCompanion.insert(
|
ProductsCompanion.insert(
|
||||||
id: uuid.v4(),
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
category: category,
|
category: category,
|
||||||
stockQuantity: stock,
|
stockQuantity: stock,
|
||||||
@@ -67,6 +71,9 @@ class DefaultProductSeeder {
|
|||||||
imagePath: Value(imagePath),
|
imagePath: Value(imagePath),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await database.queueStockSet(
|
||||||
|
events.stockSet(productId: id, quantity: stock),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
import '../database/stock_queries.dart';
|
import '../database/stock_queries.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
|
import '../models/tab_event.dart';
|
||||||
|
|
||||||
export '../database/stock_queries.dart'
|
export '../database/stock_queries.dart' show ProductNotFoundException;
|
||||||
show InsufficientStockException, ProductNotFoundException;
|
|
||||||
|
|
||||||
abstract class ProductService {
|
abstract class ProductService {
|
||||||
/// Active products, re-emitted whenever the products table changes.
|
/// Active products, re-emitted whenever the products table changes.
|
||||||
@@ -37,9 +37,11 @@ abstract class ProductService {
|
|||||||
|
|
||||||
class DriftProductService implements ProductService {
|
class DriftProductService implements ProductService {
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
|
final TabEventFactory eventFactory;
|
||||||
final _uuid = const Uuid();
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
DriftProductService({required this.database});
|
DriftProductService({required this.database, TabEventFactory? eventFactory})
|
||||||
|
: eventFactory = eventFactory ?? TabEventFactory();
|
||||||
|
|
||||||
Product _mapRowToProduct(ProductRow row) {
|
Product _mapRowToProduct(ProductRow row) {
|
||||||
return Product(
|
return Product(
|
||||||
@@ -95,39 +97,69 @@ class DriftProductService implements ProductService {
|
|||||||
required int priceInCents,
|
required int priceInCents,
|
||||||
required String? imagePath,
|
required String? imagePath,
|
||||||
}) async {
|
}) async {
|
||||||
await database
|
final id = _uuid.v4();
|
||||||
.into(database.products)
|
|
||||||
.insert(
|
await database.transaction(() async {
|
||||||
ProductsCompanion.insert(
|
await database
|
||||||
id: _uuid.v4(),
|
.into(database.products)
|
||||||
name: name,
|
.insert(
|
||||||
category: category,
|
ProductsCompanion.insert(
|
||||||
stockQuantity: stockQuantity,
|
id: id,
|
||||||
lowStockThreshold: lowStockThreshold,
|
name: name,
|
||||||
infiniteStock: Value(infiniteStock),
|
category: category,
|
||||||
priceInCents: priceInCents,
|
stockQuantity: stockQuantity,
|
||||||
imagePath: Value(imagePath),
|
lowStockThreshold: lowStockThreshold,
|
||||||
),
|
infiniteStock: Value(infiniteStock),
|
||||||
|
priceInCents: priceInCents,
|
||||||
|
imagePath: Value(imagePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The server starts a product it doesn't know as untracked
|
||||||
|
if (!infiniteStock) {
|
||||||
|
await database.queueStockSet(
|
||||||
|
eventFactory.stockSet(productId: id, quantity: stockQuantity),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A changed stock or tracking is queued for the server as a count.
|
||||||
@override
|
@override
|
||||||
Future<void> updateProduct(Product product) async {
|
Future<void> updateProduct(Product product) async {
|
||||||
final query = database.update(database.products)
|
await database.transaction(() async {
|
||||||
..where((row) => row.id.equals(product.id));
|
final current = await (database.select(
|
||||||
|
database.products,
|
||||||
|
)..where((row) => row.id.equals(product.id))).getSingleOrNull();
|
||||||
|
|
||||||
await query.write(
|
await (database.update(
|
||||||
ProductsCompanion(
|
database.products,
|
||||||
name: Value(product.name),
|
)..where((row) => row.id.equals(product.id))).write(
|
||||||
category: Value(product.category),
|
ProductsCompanion(
|
||||||
stockQuantity: Value(product.stockQuantity),
|
name: Value(product.name),
|
||||||
lowStockThreshold: Value(product.lowStockThreshold),
|
category: Value(product.category),
|
||||||
infiniteStock: Value(product.infiniteStock),
|
stockQuantity: Value(product.stockQuantity),
|
||||||
priceInCents: Value(product.priceInCents),
|
lowStockThreshold: Value(product.lowStockThreshold),
|
||||||
imagePath: Value(product.imagePath),
|
infiniteStock: Value(product.infiniteStock),
|
||||||
active: Value(product.active),
|
priceInCents: Value(product.priceInCents),
|
||||||
),
|
imagePath: Value(product.imagePath),
|
||||||
);
|
active: Value(product.active),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (current == null) return;
|
||||||
|
final stockChanged = product.infiniteStock
|
||||||
|
? !current.infiniteStock
|
||||||
|
: current.infiniteStock ||
|
||||||
|
current.stockQuantity != product.stockQuantity;
|
||||||
|
if (stockChanged) {
|
||||||
|
await database.queueStockSet(
|
||||||
|
eventFactory.stockSet(
|
||||||
|
productId: product.id,
|
||||||
|
quantity: product.infiniteStock ? null : product.stockQuantity,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only hidden, since open tabs and history still refer to it.
|
/// Only hidden, since open tabs and history still refer to it.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
|
import '../database/stock_queries.dart';
|
||||||
import '../models/bar_tab.dart';
|
import '../models/bar_tab.dart';
|
||||||
import '../models/payment_method.dart';
|
import '../models/payment_method.dart';
|
||||||
import '../models/remote_command.dart';
|
import '../models/remote_command.dart';
|
||||||
@@ -49,6 +50,13 @@ class RemoteSyncService {
|
|||||||
/// Most outbox events sent per sync; a larger backlog drains over syncs.
|
/// Most outbox events sent per sync; a larger backlog drains over syncs.
|
||||||
static const maxEventsPerSync = 500;
|
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(
|
static const _pollingInterval = Duration(
|
||||||
seconds: kSyncPollingIntervalSeconds,
|
seconds: kSyncPollingIntervalSeconds,
|
||||||
);
|
);
|
||||||
@@ -66,7 +74,7 @@ class RemoteSyncService {
|
|||||||
bool _paused = false;
|
bool _paused = false;
|
||||||
int _consecutiveFailures = 0;
|
int _consecutiveFailures = 0;
|
||||||
|
|
||||||
StreamSubscription<List<BarTab>>? _tabChanges;
|
StreamSubscription<Set<String>>? _tabChanges;
|
||||||
Timer? _changeTimer;
|
Timer? _changeTimer;
|
||||||
DateTime? _firstPendingChange;
|
DateTime? _firstPendingChange;
|
||||||
bool _syncQueued = false;
|
bool _syncQueued = false;
|
||||||
@@ -98,11 +106,8 @@ class RemoteSyncService {
|
|||||||
|
|
||||||
void startPeriodicSync() {
|
void startPeriodicSync() {
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
// The first emission is the current state, not a change
|
unawaited(pruneStockAdjustmentResults());
|
||||||
_tabChanges ??= barTabService
|
_tabChanges ??= _watchNewOutboxEvents();
|
||||||
.watchOpenTabs()
|
|
||||||
.skip(1)
|
|
||||||
.listen((_) => _onTabsChanged());
|
|
||||||
_scheduleNext(_pollingInterval);
|
_scheduleNext(_pollingInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +131,27 @@ class RemoteSyncService {
|
|||||||
_paused = false;
|
_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
|
/// 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
|
/// 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]
|
/// a row) becomes one sync, sent once things are quiet for [changeDebounce]
|
||||||
@@ -183,9 +209,11 @@ class RemoteSyncService {
|
|||||||
Duration nextDelay;
|
Duration nextDelay;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _doSync(credentials);
|
final appliedStock = await _doSync(credentials);
|
||||||
_consecutiveFailures = 0;
|
_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);
|
_emit(RemoteSyncEventType.syncCompleted);
|
||||||
} on UnauthorizedException {
|
} on UnauthorizedException {
|
||||||
debugPrint('RemoteSyncService: token revoked');
|
debugPrint('RemoteSyncService: token revoked');
|
||||||
@@ -229,11 +257,20 @@ class RemoteSyncService {
|
|||||||
return retryAfter ?? _backoff();
|
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();
|
final state = await _getSyncState();
|
||||||
// Read before the snapshot, so the snapshot is never older than the events
|
// One read, so the snapshot and the stock match the events that are sent
|
||||||
final outbox = await _pendingEvents();
|
final (outbox, openTabs, products, stockResults) = await database
|
||||||
final openTabs = await barTabService.getOpenTabs();
|
.transaction(
|
||||||
|
() async => (
|
||||||
|
await _pendingEvents(),
|
||||||
|
await barTabService.getOpenTabs(),
|
||||||
|
await database.select(database.products).get(),
|
||||||
|
await _pendingStockResults(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final response = await apiClient.sync(
|
final response = await apiClient.sync(
|
||||||
token: credentials.token,
|
token: credentials.token,
|
||||||
@@ -245,22 +282,60 @@ class RemoteSyncService {
|
|||||||
for (final row in outbox)
|
for (final row in outbox)
|
||||||
jsonDecode(row.payload) as Map<String, dynamic>,
|
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 _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) {
|
for (final command in response.commands) {
|
||||||
await _processCommand(command, credentials);
|
await _processCommand(command, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
final openNames = openTabs.map((tab) => tab.customerName).toSet();
|
// A server that holds the tabs creates planned tabs itself; doing it
|
||||||
for (final name in response.plannedTabNames) {
|
// here too would open them once per tablet
|
||||||
if (name.trim().isEmpty || !openNames.add(name)) continue;
|
if (serverTabs == null) {
|
||||||
await barTabService.createTab(customerName: name);
|
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() {
|
Future<List<TabEventRow>> _pendingEvents() {
|
||||||
@@ -295,6 +370,134 @@ class RemoteSyncService {
|
|||||||
)..where((row) => row.id.isIn(ids))).go();
|
)..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) {
|
SnapshotTab _toSnapshot(BarTab tab) {
|
||||||
return SnapshotTab(
|
return SnapshotTab(
|
||||||
id: tab.id,
|
id: tab.id,
|
||||||
@@ -404,7 +607,13 @@ class RemoteSyncService {
|
|||||||
)..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull();
|
)..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 now = DateTime.now();
|
||||||
final existing = await _getSyncState();
|
final existing = await _getSyncState();
|
||||||
|
|
||||||
@@ -414,6 +623,9 @@ class RemoteSyncService {
|
|||||||
RemoteSyncStateCompanion.insert(
|
RemoteSyncStateCompanion.insert(
|
||||||
deviceId: _syncStateId,
|
deviceId: _syncStateId,
|
||||||
serverCursor: Value(cursor ?? existing?.serverCursor),
|
serverCursor: Value(cursor ?? existing?.serverCursor),
|
||||||
|
settlementsCursor: Value(
|
||||||
|
settlementsCursor ?? existing?.settlementsCursor,
|
||||||
|
),
|
||||||
lastSuccessfulSyncAt: Value(
|
lastSuccessfulSyncAt: Value(
|
||||||
success ? now : existing?.lastSuccessfulSyncAt,
|
success ? now : existing?.lastSuccessfulSyncAt,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import '../models/payment_method.dart';
|
|||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/tab_item.dart';
|
import '../models/tab_item.dart';
|
||||||
import '../services/bar_tab_service.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
|
/// 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
|
/// 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 {
|
Future<T> _guard<T>(String action, Future<T> Function() body) async {
|
||||||
try {
|
try {
|
||||||
return await body();
|
return await body();
|
||||||
} on InsufficientStockException {
|
|
||||||
rethrow;
|
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
debugPrint('BarScreenViewModel: $action error: $e');
|
debugPrint('BarScreenViewModel: $action error: $e');
|
||||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../../l10n/app_localizations_helpers.dart';
|
import '../../l10n/app_localizations_helpers.dart';
|
||||||
import '../../models/product.dart';
|
import '../../models/product.dart';
|
||||||
import '../../models/remote_command.dart';
|
import '../../models/remote_command.dart';
|
||||||
import '../../services/product_service.dart';
|
|
||||||
import '../../utils/app_updater.dart';
|
import '../../utils/app_updater.dart';
|
||||||
import '../../viewmodels/admin_pin_view_model.dart';
|
import '../../viewmodels/admin_pin_view_model.dart';
|
||||||
import '../../viewmodels/bar_screen_view_model.dart';
|
import '../../viewmodels/bar_screen_view_model.dart';
|
||||||
@@ -85,15 +84,9 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
await viewModel.addProductToSelectedTab(product);
|
await viewModel.addProductToSelectedTab(product);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(
|
).showSnackBar(SnackBar(content: Text(l10n.couldNotAddProductToTab)));
|
||||||
e is InsufficientStockException
|
|
||||||
? l10n.productOutOfStock
|
|
||||||
: l10n.couldNotAddProductToTab,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +101,6 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
final viewModel = context.watch<BarScreenViewModel>();
|
final viewModel = context.watch<BarScreenViewModel>();
|
||||||
final productsViewModel = context.watch<ProductListViewModel>();
|
final productsViewModel = context.watch<ProductListViewModel>();
|
||||||
final settings = context.watch<SettingsViewModel>().settings;
|
final settings = context.watch<SettingsViewModel>().settings;
|
||||||
final stockByProductId = {
|
|
||||||
for (final product in productsViewModel.products)
|
|
||||||
if (!product.infiniteStock) product.id: product.stockQuantity,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -206,7 +195,6 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
tabs: viewModel.tabs,
|
tabs: viewModel.tabs,
|
||||||
selectedTab: viewModel.selectedTab,
|
selectedTab: viewModel.selectedTab,
|
||||||
selectedTabId: viewModel.selectedTabId,
|
selectedTabId: viewModel.selectedTabId,
|
||||||
stockByProductId: stockByProductId,
|
|
||||||
showSearch: _showSearch,
|
showSearch: _showSearch,
|
||||||
onTabSelected: viewModel.selectTab,
|
onTabSelected: viewModel.selectTab,
|
||||||
onItemQuantityChanged: viewModel.changeItemQuantity,
|
onItemQuantityChanged: viewModel.changeItemQuantity,
|
||||||
|
|||||||
@@ -3,19 +3,16 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/bar_tab.dart';
|
import '../../models/bar_tab.dart';
|
||||||
import '../../models/tab_item.dart';
|
import '../../models/tab_item.dart';
|
||||||
import '../../services/product_service.dart';
|
|
||||||
import '../../utils/money.dart';
|
import '../../utils/money.dart';
|
||||||
|
|
||||||
class SelectedTabDetails extends StatelessWidget {
|
class SelectedTabDetails extends StatelessWidget {
|
||||||
final BarTab tab;
|
final BarTab tab;
|
||||||
final Map<String, int> stockByProductId;
|
|
||||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||||
final VoidCallback onCloseTabPressed;
|
final VoidCallback onCloseTabPressed;
|
||||||
|
|
||||||
const SelectedTabDetails({
|
const SelectedTabDetails({
|
||||||
super.key,
|
super.key,
|
||||||
required this.tab,
|
required this.tab,
|
||||||
required this.stockByProductId,
|
|
||||||
required this.onItemQuantityChanged,
|
required this.onItemQuantityChanged,
|
||||||
required this.onCloseTabPressed,
|
required this.onCloseTabPressed,
|
||||||
});
|
});
|
||||||
@@ -55,7 +52,6 @@ class SelectedTabDetails extends StatelessWidget {
|
|||||||
|
|
||||||
return TabItemRow(
|
return TabItemRow(
|
||||||
item: item,
|
item: item,
|
||||||
stockByProductId: stockByProductId,
|
|
||||||
onQuantityChanged: onItemQuantityChanged,
|
onQuantityChanged: onItemQuantityChanged,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -108,13 +104,11 @@ class SelectedTabDetails extends StatelessWidget {
|
|||||||
|
|
||||||
class TabItemRow extends StatelessWidget {
|
class TabItemRow extends StatelessWidget {
|
||||||
final TabItem item;
|
final TabItem item;
|
||||||
final Map<String, int> stockByProductId;
|
|
||||||
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
|
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
|
||||||
|
|
||||||
const TabItemRow({
|
const TabItemRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.stockByProductId,
|
|
||||||
required this.onQuantityChanged,
|
required this.onQuantityChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,23 +119,15 @@ class TabItemRow extends StatelessWidget {
|
|||||||
await onQuantityChanged(item, delta);
|
await onQuantityChanged(item, delta);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(
|
).showSnackBar(SnackBar(content: Text(l10n.couldNotUpdateQuantity)));
|
||||||
e is InsufficientStockException
|
|
||||||
? l10n.productOutOfStock
|
|
||||||
: l10n.couldNotUpdateQuantity,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final availableStock = stockByProductId[item.productId];
|
|
||||||
final canIncrease = availableStock == null || availableStock > 0;
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
@@ -188,9 +174,7 @@ class TabItemRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: canIncrease
|
onPressed: () => _changeQuantity(context, 1),
|
||||||
? () => _changeQuantity(context, 1)
|
|
||||||
: null,
|
|
||||||
icon: const Icon(Icons.add_rounded, size: 18),
|
icon: const Icon(Icons.add_rounded, size: 18),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class TabPanel extends StatefulWidget {
|
|||||||
final List<BarTab> tabs;
|
final List<BarTab> tabs;
|
||||||
final BarTab? selectedTab;
|
final BarTab? selectedTab;
|
||||||
final String? selectedTabId;
|
final String? selectedTabId;
|
||||||
final Map<String, int> stockByProductId;
|
|
||||||
final bool showSearch;
|
final bool showSearch;
|
||||||
final ValueChanged<String> onTabSelected;
|
final ValueChanged<String> onTabSelected;
|
||||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||||
@@ -52,7 +51,6 @@ class TabPanel extends StatefulWidget {
|
|||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedTab,
|
required this.selectedTab,
|
||||||
required this.selectedTabId,
|
required this.selectedTabId,
|
||||||
required this.stockByProductId,
|
|
||||||
required this.showSearch,
|
required this.showSearch,
|
||||||
required this.onTabSelected,
|
required this.onTabSelected,
|
||||||
required this.onItemQuantityChanged,
|
required this.onItemQuantityChanged,
|
||||||
@@ -409,8 +407,6 @@ class _TabPanelState extends State<TabPanel> {
|
|||||||
)
|
)
|
||||||
: SelectedTabDetails(
|
: SelectedTabDetails(
|
||||||
tab: widget.selectedTab!,
|
tab: widget.selectedTab!,
|
||||||
stockByProductId:
|
|
||||||
widget.stockByProductId,
|
|
||||||
onItemQuantityChanged:
|
onItemQuantityChanged:
|
||||||
widget.onItemQuantityChanged,
|
widget.onItemQuantityChanged,
|
||||||
onCloseTabPressed:
|
onCloseTabPressed:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
|
||||||
import '../../models/product.dart';
|
import '../../models/product.dart';
|
||||||
|
|
||||||
class ProductTile extends StatelessWidget {
|
class ProductTile extends StatelessWidget {
|
||||||
@@ -24,8 +23,6 @@ class ProductTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
|
|
||||||
final outOfStock = product.isOutOfStock;
|
final outOfStock = product.isOutOfStock;
|
||||||
final lowStock = !outOfStock && product.isLowStock;
|
final lowStock = !outOfStock && product.isLowStock;
|
||||||
|
|
||||||
@@ -50,13 +47,14 @@ class ProductTile extends StatelessWidget {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: theme.cardTheme.color ?? scheme.surface,
|
color: theme.cardTheme.color ?? scheme.surface,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: enabled && !outOfStock ? onTap : null,
|
// Never blocked by stock: the count may simply be off
|
||||||
|
onTap: enabled ? onTap : null,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4),
|
opacity: enabled ? 1 : 0.4,
|
||||||
child: hasImage
|
child: hasImage
|
||||||
? Image.file(
|
? Image.file(
|
||||||
File(product.imagePath!),
|
File(product.imagePath!),
|
||||||
@@ -90,28 +88,6 @@ class ProductTile extends StatelessWidget {
|
|||||||
right: 10,
|
right: 10,
|
||||||
child: _StockBadge(product: product),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (product.isOutOfStock) {
|
final out = product.isOutOfStock;
|
||||||
return const SizedBox.shrink();
|
final low = !out && product.isLowStock;
|
||||||
}
|
|
||||||
|
|
||||||
final low = product.isLowStock;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: low
|
color: out
|
||||||
|
? scheme.error.withValues(alpha: 0.9)
|
||||||
|
: low
|
||||||
? Colors.amber.withValues(alpha: 0.9)
|
? Colors.amber.withValues(alpha: 0.9)
|
||||||
: scheme.primary.withValues(alpha: 0.85),
|
: scheme.primary.withValues(alpha: 0.85),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
@@ -159,7 +134,11 @@ class _StockBadge extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: low ? Colors.black : scheme.onPrimary,
|
color: out
|
||||||
|
? scheme.onError
|
||||||
|
: low
|
||||||
|
? Colors.black
|
||||||
|
: scheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: kooltap
|
|||||||
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.3.0+0
|
version: 1.3.1+0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:kooltap/models/bar_tab.dart';
|
import 'package:kooltap/models/bar_tab.dart';
|
||||||
import 'package:kooltap/models/tab_item.dart';
|
import 'package:kooltap/models/tab_item.dart';
|
||||||
import 'package:kooltap/services/bar_tab_service.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:kooltap/viewmodels/bar_screen_view_model.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ void main() {
|
|||||||
expect(viewModel.isLoading, false);
|
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(
|
const item = TabItem(
|
||||||
id: 'item-1',
|
id: 'item-1',
|
||||||
tabId: 'tab-1',
|
tabId: 'tab-1',
|
||||||
@@ -99,11 +98,11 @@ void main() {
|
|||||||
|
|
||||||
when(
|
when(
|
||||||
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
|
() => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1),
|
||||||
).thenAnswer((_) async => throw const InsufficientStockException());
|
).thenAnswer((_) async => throw const TabNotFoundException('tab-1'));
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
viewModel.changeItemQuantity(item, 1),
|
viewModel.changeItemQuantity(item, 1),
|
||||||
throwsA(isA<InsufficientStockException>()),
|
throwsA(isA<TabNotFoundException>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(viewModel.loadError, isNull);
|
expect(viewModel.loadError, isNull);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ void main() {
|
|||||||
expect(storedProduct.stockQuantity, 99);
|
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 tab = await service.createTab(customerName: 'John');
|
||||||
final product = createTestProduct();
|
final product = createTestProduct();
|
||||||
|
|
||||||
@@ -234,13 +234,14 @@ void main() {
|
|||||||
..where((row) => row.id.equals(product.id)))
|
..where((row) => row.id.equals(product.id)))
|
||||||
.write(const ProductsCompanion(stockQuantity: Value(0)));
|
.write(const ProductsCompanion(stockQuantity: Value(0)));
|
||||||
|
|
||||||
await expectLater(
|
await service.addProductToTab(tabId: tab.id, product: product);
|
||||||
service.addProductToTab(tabId: tab.id, product: product),
|
|
||||||
throwsA(isA<InsufficientStockException>()),
|
|
||||||
);
|
|
||||||
|
|
||||||
final updatedTab = await service.getTabById(tab.id);
|
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(afterAdjust.version, afterAdd.version + 1);
|
||||||
expect(afterRename.version, afterAdjust.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', () {
|
group('watchOpenTabs', () {
|
||||||
|
|||||||
@@ -94,4 +94,12 @@ void main() {
|
|||||||
expect(await database.select(database.tabEvents).get(), isEmpty);
|
expect(await database.select(database.tabEvents).get(), isEmpty);
|
||||||
expect((await database.select(database.barTabs).get()), hasLength(1));
|
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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws when not enough stock', () async {
|
test('goes below zero instead of refusing', () async {
|
||||||
await database
|
await database
|
||||||
.into(database.products)
|
.into(database.products)
|
||||||
.insert(
|
.insert(
|
||||||
@@ -237,10 +237,10 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
await service.decreaseStock('low-stock', 5);
|
||||||
() => service.decreaseStock('low-stock', 5),
|
|
||||||
throwsA(isA<InsufficientStockException>()),
|
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
|
await database
|
||||||
.into(database.products)
|
.into(database.products)
|
||||||
.insert(
|
.insert(
|
||||||
@@ -310,16 +310,13 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expectLater(
|
await Future.wait([
|
||||||
Future.wait([
|
service.decreaseStock('concurrent-stock', 1),
|
||||||
service.decreaseStock('concurrent-stock', 1),
|
service.decreaseStock('concurrent-stock', 1),
|
||||||
service.decreaseStock('concurrent-stock', 1),
|
]);
|
||||||
]),
|
|
||||||
throwsA(isA<InsufficientStockException>()),
|
|
||||||
);
|
|
||||||
|
|
||||||
final product = await service.getProductById('concurrent-stock');
|
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
+267
-19
@@ -4,10 +4,12 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
|
|||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kooltap/database/app_database.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/payment_method.dart';
|
||||||
import 'package:kooltap/models/product.dart';
|
import 'package:kooltap/models/product.dart';
|
||||||
import 'package:kooltap/models/tab_event.dart';
|
import 'package:kooltap/models/tab_event.dart';
|
||||||
import 'package:kooltap/services/bar_tab_service.dart';
|
import 'package:kooltap/services/bar_tab_service.dart';
|
||||||
|
import 'package:kooltap/services/product_service.dart';
|
||||||
|
|
||||||
const _cola = Product(
|
const _cola = Product(
|
||||||
id: 'prod-1',
|
id: 'prod-1',
|
||||||
@@ -59,8 +61,15 @@ void main() {
|
|||||||
return (await service.getTabById(tabId))!.items.single.id;
|
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 {
|
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 service.addProductToTab(tabId: tab.id, product: _cola);
|
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||||
await database.delete(database.tabEvents).go();
|
await database.delete(database.tabEvents).go();
|
||||||
@@ -71,13 +80,15 @@ void main() {
|
|||||||
|
|
||||||
group('line events', () {
|
group('line events', () {
|
||||||
test('adding a product writes line_added with the common fields', () async {
|
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);
|
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||||
|
|
||||||
final event = (await outbox()).single;
|
final event = (await outbox()).single;
|
||||||
expect(event['type'], 'line_added');
|
expect(event['type'], 'line_added');
|
||||||
expect(event['tabId'], tab.id);
|
expect(event['tabId'], tab.id);
|
||||||
expect(event['customerName'], 'Jan');
|
expect(event['customerName'], 'Jan');
|
||||||
|
expect(event['productId'], 'prod-1');
|
||||||
|
expect(event['lineId'], await lineIdOf(tab.id));
|
||||||
expect(event['productName'], 'Cola');
|
expect(event['productName'], 'Cola');
|
||||||
expect(event['unitPriceInCents'], 250);
|
expect(event['unitPriceInCents'], 250);
|
||||||
expect(event['quantity'], 1);
|
expect(event['quantity'], 1);
|
||||||
@@ -89,7 +100,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adding to an existing line keeps that line\'s price', () async {
|
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, product: _cola);
|
||||||
await service.addProductToTab(
|
await service.addProductToTab(
|
||||||
tabId: tab.id,
|
tabId: tab.id,
|
||||||
@@ -108,7 +119,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('changing a quantity writes the difference', () async {
|
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);
|
||||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||||
final lineId = await lineIdOf(tab.id);
|
final lineId = await lineIdOf(tab.id);
|
||||||
@@ -137,7 +148,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a change that does nothing writes no event', () async {
|
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);
|
await service.addProductToTab(tabId: tab.id, product: _cola);
|
||||||
final lineId = await lineIdOf(tab.id);
|
final lineId = await lineIdOf(tab.id);
|
||||||
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1);
|
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1);
|
||||||
@@ -149,25 +160,38 @@ void main() {
|
|||||||
expect(await outbox(), isEmpty);
|
expect(await outbox(), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleting a tab writes line_removed for each line', () async {
|
test(
|
||||||
final tab = await tabWithTwoColas();
|
'deleting a tab writes line_removed for each line, then tab_deleted',
|
||||||
|
() async {
|
||||||
|
final tab = await tabWithTwoColas();
|
||||||
|
|
||||||
await service.deleteTab(tab.id);
|
await service.deleteTab(tab.id);
|
||||||
|
|
||||||
final event = (await outbox()).single;
|
final events = await outbox();
|
||||||
expect(event['type'], 'line_removed');
|
expect(events.map((event) => event['type']), [
|
||||||
expect(event['quantity'], 2);
|
'line_removed',
|
||||||
expect(event['customerName'], 'Jan');
|
'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 {
|
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');
|
await service.renameTab(tabId: tab.id, customerName: 'Janneke');
|
||||||
expect(await outbox(), isEmpty);
|
|
||||||
|
|
||||||
await service.addProductToTab(tabId: tab.id, product: _cola);
|
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 {
|
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', () {
|
group('settled events', () {
|
||||||
test('settling writes one settled event and no removals', () async {
|
test('settling writes one settled event and no removals', () async {
|
||||||
final tab = await tabWithTwoColas();
|
final tab = await tabWithTwoColas();
|
||||||
@@ -193,8 +240,16 @@ void main() {
|
|||||||
expect(event['paymentMethod'], 'payconiq');
|
expect(event['paymentMethod'], 'payconiq');
|
||||||
expect(event.containsKey('commandId'), false);
|
expect(event.containsKey('commandId'), false);
|
||||||
expect(event['items'], [
|
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 {
|
test('lines that were taken back are not settled', () async {
|
||||||
@@ -211,7 +266,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('settling an empty tab writes nothing', () async {
|
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);
|
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', () {
|
test('occurredAt is the UTC time it happened', () {
|
||||||
final factory = TabEventFactory(
|
final factory = TabEventFactory(
|
||||||
now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120),
|
now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120),
|
||||||
@@ -252,6 +471,8 @@ void main() {
|
|||||||
final event = factory.lineAdded(
|
final event = factory.lineAdded(
|
||||||
tabId: 'a1b2',
|
tabId: 'a1b2',
|
||||||
customerName: 'Jan',
|
customerName: 'Jan',
|
||||||
|
lineId: 'line-1',
|
||||||
|
productId: 'prod-1',
|
||||||
productName: 'Cola',
|
productName: 'Cola',
|
||||||
unitPriceInCents: 250,
|
unitPriceInCents: 250,
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
@@ -262,6 +483,8 @@ void main() {
|
|||||||
() => factory.lineRemoved(
|
() => factory.lineRemoved(
|
||||||
tabId: 'a1b2',
|
tabId: 'a1b2',
|
||||||
customerName: 'Jan',
|
customerName: 'Jan',
|
||||||
|
lineId: 'line-1',
|
||||||
|
productId: 'prod-1',
|
||||||
productName: 'Cola',
|
productName: 'Cola',
|
||||||
unitPriceInCents: 250,
|
unitPriceInCents: 250,
|
||||||
quantity: 0,
|
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'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-68
@@ -249,27 +249,24 @@ void main() {
|
|||||||
final tabId = tabIds[random.nextInt(tabIds.length)];
|
final tabId = tabIds[random.nextInt(tabIds.length)];
|
||||||
final product = random.nextBool() ? _beer : _wine;
|
final product = random.nextBool() ? _beer : _wine;
|
||||||
|
|
||||||
try {
|
// Running out is part of the scenario: the stock goes negative
|
||||||
switch (random.nextInt(10)) {
|
switch (random.nextInt(10)) {
|
||||||
case < 6:
|
case < 6:
|
||||||
await tabs.addProductToTab(tabId: tabId, product: product);
|
await tabs.addProductToTab(tabId: tabId, product: product);
|
||||||
case < 8:
|
case < 8:
|
||||||
final tab = (await tabs.getTabById(tabId))!;
|
final tab = (await tabs.getTabById(tabId))!;
|
||||||
if (tab.items.isEmpty) continue;
|
if (tab.items.isEmpty) continue;
|
||||||
final item = tab.items[random.nextInt(tab.items.length)];
|
final item = tab.items[random.nextInt(tab.items.length)];
|
||||||
await tabs.adjustTabItemQuantity(
|
await tabs.adjustTabItemQuantity(
|
||||||
tabItemId: item.id,
|
tabItemId: item.id,
|
||||||
delta: random.nextInt(5) - 2,
|
delta: random.nextInt(5) - 2,
|
||||||
);
|
);
|
||||||
case 8:
|
case 8:
|
||||||
await tabs.closeTab(tabId);
|
await tabs.closeTab(tabId);
|
||||||
default:
|
default:
|
||||||
await tabs.deleteTab(tabId);
|
await tabs.deleteTab(tabId);
|
||||||
tabIds.remove(tabId);
|
tabIds.remove(tabId);
|
||||||
tabIds.add((await tabs.createTab(customerName: 'New')).id);
|
tabIds.add((await tabs.createTab(customerName: 'New')).id);
|
||||||
}
|
|
||||||
} on InsufficientStockException {
|
|
||||||
// Running out is part of the scenario.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final product in [_beer, _wine]) {
|
for (final product in [_beer, _wine]) {
|
||||||
@@ -278,65 +275,48 @@ void main() {
|
|||||||
await quantityOnOpenTabs(product.id) +
|
await quantityOnOpenTabs(product.id) +
|
||||||
await soldInHistory(product.id);
|
await soldInHistory(product.id);
|
||||||
expect(accounted, product.stockQuantity, reason: 'step $step');
|
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');
|
final tab = await tabs.createTab(customerName: 'John');
|
||||||
var sold = 0;
|
|
||||||
|
|
||||||
for (var i = 0; i < _wine.stockQuantity + 5; i++) {
|
for (var i = 0; i < _wine.stockQuantity + 5; i++) {
|
||||||
try {
|
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||||
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'), -5);
|
||||||
expect(await stockOf('wine'), 0);
|
expect((await tabs.getTabById(tab.id))!.totalInCents, 25 * 450);
|
||||||
expect((await tabs.getTabById(tab.id))!.totalInCents, 20 * 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));
|
await products.updateProduct(_wine.copyWith(stockQuantity: 1));
|
||||||
final first = await tabs.createTab(customerName: 'A');
|
final first = await tabs.createTab(customerName: 'A');
|
||||||
final second = await tabs.createTab(customerName: 'B');
|
final second = await tabs.createTab(customerName: 'B');
|
||||||
|
|
||||||
final results = await Future.wait([
|
await Future.wait([
|
||||||
for (final tab in [first, second])
|
for (final tab in [first, second])
|
||||||
tabs
|
tabs.addProductToTab(tabId: tab.id, product: _wine),
|
||||||
.addProductToTab(tabId: tab.id, product: _wine)
|
|
||||||
.then((_) => true)
|
|
||||||
.catchError(
|
|
||||||
(_) => false,
|
|
||||||
test: (e) => e is InsufficientStockException,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results.where((ok) => ok), hasLength(1));
|
expect(await stockOf('wine'), -1);
|
||||||
expect(await stockOf('wine'), 0);
|
expect(await quantityOnOpenTabs('wine'), 2);
|
||||||
expect(await quantityOnOpenTabs('wine'), 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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));
|
await products.updateProduct(_wine.copyWith(stockQuantity: 2));
|
||||||
final tab = await tabs.createTab(customerName: 'John');
|
final tab = await tabs.createTab(customerName: 'John');
|
||||||
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
await tabs.addProductToTab(tabId: tab.id, product: _wine);
|
||||||
|
|
||||||
await expectLater(
|
final applied = await tabs.adjustTabItemQuantity(
|
||||||
tabs.adjustTabItemQuantity(
|
tabItemId: await itemId(tab.id, 'wine'),
|
||||||
tabItemId: await itemId(tab.id, 'wine'),
|
delta: 5,
|
||||||
delta: 5,
|
|
||||||
),
|
|
||||||
throwsA(isA<InsufficientStockException>()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await stockOf('wine'), 1);
|
expect(applied, 5);
|
||||||
expect((await tabs.getTabById(tab.id))!.items.single.quantity, 1);
|
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 {
|
test('lowering past zero only returns what was on the tab', () async {
|
||||||
@@ -372,19 +352,5 @@ void main() {
|
|||||||
|
|
||||||
expect(await stockOf('water'), 0);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ void main() {
|
|||||||
tabs: tabs,
|
tabs: tabs,
|
||||||
selectedTab: selectedTab,
|
selectedTab: selectedTab,
|
||||||
selectedTabId: selectedTab?.id,
|
selectedTabId: selectedTab?.id,
|
||||||
stockByProductId: const {},
|
|
||||||
showSearch: showSearch,
|
showSearch: showSearch,
|
||||||
onTabSelected: (_) {},
|
onTabSelected: (_) {},
|
||||||
onItemQuantityChanged: (_, _) async {},
|
onItemQuantityChanged: (_, _) async {},
|
||||||
|
|||||||
Reference in New Issue
Block a user