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

This commit is contained in:
2026-10-19 23:23:36 +02:00
parent fa38ac7617
commit 874f68b4b1
11 changed files with 577 additions and 2 deletions
+11 -2
View File
@@ -44,8 +44,17 @@ Sync semantics:
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
- The first ack for a command wins; later acks are accepted as no-ops.
- `plannedTabNames` only contains planned customers the device has not opened a tab for yet. Once a sync
reports an open tab with exactly that name, the customer is marked delivered and no longer sent.
The customer (and their email, used for reminders) is kept.
reports an open tab with that name (ignoring case and extra spaces), the customer is marked delivered
and no longer sent. The customer (and their email, used for reminders) is kept.
- `events` (optional) reports what happened on tabs since the last sync, for sales statistics. Each event
has a terminal-generated UUID `id`, a `type`, `tabId`, `customerName` and `occurredAt` (ISO-8601, UTC):
- `line_added` / `line_removed`: `productName`, `unitPriceInCents`, `quantity` (always positive)
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`),
optional `commandId` (the remote close that caused it) and `items` (the settled lines)
The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync)
and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its
outbox. An invalid event never fails the sync. At most 1000 events per sync.
### Tabs
`GET /venues/:venueId/tabs` - List open tabs
+166
View File
@@ -0,0 +1,166 @@
## KoolTap Flutter App — Report Tab Events for Sales Statistics
The server can now store **what happens on tabs**: lines added, lines removed and settlements. This is used
for sales statistics (sales per night, per product, per hour, cash vs Payconiq). Add an **event outbox** to
the app: every change to a tab writes an event to a local table, the sync sends pending events to the
server, and the app deletes them once the server confirms them.
Nothing else about the sync changes: `openTabs`, commands, acks and `plannedTabNames` keep working exactly
as they do now.
### Existing Files (read these first)
- `lib/models/remote_command.dart` — `SyncResponse`, `RemoteCommand`, `SnapshotTab` and the sync request
- `lib/services/remote_sync_service.dart` — the sync loop (`_performSync()` or similar)
- `lib/services/bar_tab_service.dart` — creates and changes tabs, adds/removes lines, settles tabs
- `lib/models/bar_tab.dart` — the `BarTab` model and its lines
- `lib/database/app_database.dart` — the Drift database schema and migrations
- The code that processes a `close_tab` remote command (search for `close_tab`)
Before writing code, find **every** place where a tab's lines or balance change: adding a product, changing
a line's quantity, removing a line, changing a price, settling (locally and through a remote `close_tab`
command) and deleting a tab. List them in your plan.
### The Events
Every event is a JSON object with these common fields:
| Field | Type | Notes |
|---|---|---|
| `id` | string (UUID v4) | Generated by the app when the event is created. Never reuse. |
| `type` | string | `line_added`, `line_removed` or `settled` |
| `tabId` | string | The same id the app sends as `SnapshotTab.id` |
| `customerName` | string | The tab's customer name at that moment |
| `occurredAt` | string | `DateTime.now().toUtc().toIso8601String()` at the moment it happened |
`line_added` and `line_removed` add:
| Field | Type | Notes |
|---|---|---|
| `productName` | string | As shown on the tab |
| `unitPriceInCents` | int | Price per piece at that moment |
| `quantity` | int | How many were added or removed. **Always positive** (≥ 1) |
`settled` adds:
| Field | Type | Notes |
|---|---|---|
| `amountInCents` | int | The amount that was settled |
| `paymentMethod` | string | `cash`, `payconiq`, `payment_pending` or `other`. Map the app's own payment options to these. |
| `commandId` | string (UUID), optional | The remote `close_tab` command id, when the settlement came from the dashboard |
| `items` | list, optional | The lines that were settled: `[{ "productName", "quantity", "unitPriceInCents" }]` |
Examples:
```json
{ "id": "6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e", "type": "line_added", "tabId": "a1b2", "customerName": "Jan",
"occurredAt": "2026-10-24T21:41:03.120Z", "productName": "Cola", "unitPriceInCents": 250, "quantity": 2 }
{ "id": "7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "type": "settled", "tabId": "a1b2", "customerName": "Jan",
"occurredAt": "2026-10-24T23:55:00.000Z", "amountInCents": 500, "paymentMethod": "payconiq",
"items": [{ "productName": "Cola", "quantity": 2, "unitPriceInCents": 250 }] }
```
### When to Write Which Event
- **Product added to a tab** → `line_added` with the quantity added.
- **Quantity of a line increased** (e.g. 2 → 5) → `line_added` with `quantity: 3`.
- **Quantity of a line decreased or a line removed** (e.g. 5 → 1) → `line_removed` with `quantity: 4`.
- **Price of a line changed** → `line_removed` for the old line (old price) and `line_added` for the new one.
- **Tab settled** (paid; the tab stays open with a zero balance) → **one** `settled` event with the amount,
payment method and the settled lines. Do **not** also write `line_removed` events for the lines that the
settlement clears: those drinks were sold, not taken back.
- **Remote `close_tab` command processed** → the same `settled` event, with `paymentMethod` from the command
and `commandId` set to the command's id. Write it only when the settlement actually happened (not when the
command fails or conflicts), and only once, even if the same command is received again.
- **Tab deleted while it still has lines** → `line_removed` for each remaining line (it was a mistake).
- **Customer renamed** → no event; later events just use the new name.
### 1. Outbox Table (Drift)
In `lib/database/app_database.dart` add a table, bump `schemaVersion` and add a migration step that
creates it for existing installs (existing data must stay):
```dart
class TabEvents extends Table {
TextColumn get id => text()(); // the event's UUID
TextColumn get payload => text()(); // the event as JSON
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
```
### 2. Write Events Together with the Change
In `BarTabService` (and wherever else tabs change), insert the event **in the same database transaction**
as the change itself. That way a crash can never record a sale without the event, or an event without the
sale. Put the JSON building in one small helper (e.g. `TabEventFactory` or methods on the service) instead
of building maps in many places.
### 3. Send Events in the Sync
In the sync request, add an `events` field with the **oldest 500** events from the outbox (ordered by
`createdAt`), as a list of the stored JSON objects:
```json
{ "deviceId": "…", "openTabs": [ … ], "events": [ { … }, { … } ] }
```
Read the events **before** building `openTabs`, so the snapshot is never older than the events.
### 4. Handle the Response
Add to `SyncResponse` (both optional, default to empty lists so an older server still works):
```dart
final List<String> acknowledgedEventIds; // json['acknowledgedEventIds']
final List<RejectedEvent> rejectedEvents; // json['rejectedEvents']: [{ "id": String?, "reason": String }]
```
After a successful sync:
- Delete every event in `acknowledgedEventIds` from the outbox.
- Delete every event in `rejectedEvents` that has an id too, and log `id` + `reason` (debug log is fine).
Re-sending won't help: it's a bug in the app that should be fixed.
- Events created while the sync was in flight are not in either list; they stay and go with the next sync.
If the sync request fails (network, 5xx), delete nothing; the same events are sent again next time. The
server stores each event id only once, so re-sending is safe.
If the outbox has more than 500 events (e.g. after a long time offline), they drain 500 per sync; no
special handling needed.
### Important Notes
- Generate ids with the `uuid` package (`const Uuid().v4()`), already used in the app if available; add it
otherwise.
- Always send `occurredAt` in UTC with the `Z` suffix.
- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`.
- Don't change how tabs, versions, `openTabs`, commands or planned tabs work.
- Keep the outbox in the local database, never only in memory: events must survive an app restart and a
tablet that is offline for a whole evening.
### Testing
Unit tests:
- Adding a product, changing a quantity up and down, and removing a line write the right events with the
right quantities.
- Settling writes one `settled` event and no `line_removed` events.
- A processed `close_tab` command writes a `settled` event with its `commandId` and payment method, once.
- The sync sends at most 500 events, oldest first, and deletes exactly the acknowledged and rejected ids.
- A failed sync deletes nothing.
Manual check against the server:
1. Add a few drinks to a tab, remove one, and settle it with Payconiq.
2. After the next sync the outbox is empty.
3. On the server: `SELECT type, "productName", "quantityDelta", "amountInCents", "paymentMethod" FROM "TabEvent" ORDER BY "occurredAt";`
shows the additions, the removal (negative `quantityDelta`) and the settlement.
4. Turn off wifi, add drinks, turn it back on: the events arrive with their original `occurredAt`.
### Files to Modify
- `lib/database/app_database.dart` — `TabEvents` table, schema version and migration
- `lib/services/bar_tab_service.dart` — write events with every change to a tab's lines and on settlement
- The remote `close_tab` handling — write the `settled` event with `commandId`
- `lib/models/remote_command.dart` — `events` in the sync request, `acknowledgedEventIds` and `rejectedEvents` in `SyncResponse`
- `lib/services/remote_sync_service.dart` — send outbox events, delete acknowledged and rejected ones