feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+480 -92
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
@@ -11,6 +12,7 @@ import '../models/closed_tab.dart';
import '../models/closed_tab_item.dart';
import '../models/payment_method.dart';
import '../models/product.dart';
import '../models/server_tab.dart';
import '../models/tab_event.dart';
import '../models/tab_item.dart';
import '../models/tab_item_purchase.dart';
@@ -91,6 +93,16 @@ abstract class BarTabService {
Future<int> getClosedTabCount({String? customerName});
Future<List<String>> getDistinctCustomerNames();
/// Replaces the open tabs with the server's [tabs], the venue's source of
/// truth, and replays the changes the server hasn't applied yet (the
/// events still in the outbox) on top. Stock is left alone: the local
/// changes already counted it.
Future<void> applyServerState(List<ServerTab> tabs);
/// Adds settlements made anywhere in the venue to the history. Settlements
/// already in it (such as this tablet's own) are skipped.
Future<void> recordSettlements(List<ServerSettlement> settlements);
}
class DriftBarTabService implements BarTabService {
@@ -226,7 +238,11 @@ class DriftBarTabService implements BarTabService {
/// Marks the most recent active purchases as removed, keeping them in the
/// audit trail.
Future<void> _removeLatestPurchases(String tabItemId, int count) async {
Future<void> _removeLatestPurchases(
String tabItemId,
int count, {
DateTime? removedAt,
}) async {
final latest =
await (database.select(database.tabItemPurchases)
..where(
@@ -243,7 +259,18 @@ class DriftBarTabService implements BarTabService {
await (database.update(database.tabItemPurchases)
..where((purchase) => purchase.id.isIn(latest.map((row) => row.id))))
.write(TabItemPurchasesCompanion(removedAt: Value(DateTime.now())));
.write(
TabItemPurchasesCompanion(
removedAt: Value(removedAt ?? DateTime.now()),
),
);
}
Future<void> _deleteTabWithItems(String tabId) async {
await _deleteItemsOfTab(tabId);
await (database.delete(
database.barTabs,
)..where((row) => row.id.equals(tabId))).go();
}
/// Foreign keys aren't enforced, so purchases are deleted explicitly.
@@ -276,6 +303,36 @@ class DriftBarTabService implements BarTabService {
);
}
/// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0)
/// event and moves the stock of [productId], the product the line was
/// created from, the other way. Every line event goes through here, so a
/// line event and its stock change can never drift apart.
Future<void> _recordLineChange({
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int delta,
}) async {
final lineEvent = delta > 0
? eventFactory.lineAdded
: eventFactory.lineRemoved;
await _recordEvent(
lineEvent(
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: delta.abs(),
),
);
await database.changeTrackedStock(productId, -delta);
}
Future<void> _incrementTabVersion(String tabId) async {
await (database.update(
database.barTabs,
@@ -300,9 +357,11 @@ class DriftBarTabService implements BarTabService {
@override
Stream<List<BarTab>> watchOpenTabs() {
return _tabsQuery().watch().map(
(rows) => _groupTabs(rows, includeRemoved: false),
);
return _tabsQuery()
.watch()
.map((rows) => _groupTabs(rows, includeRemoved: false))
// A sync rewrites the tabs even when nothing changed
.distinct(listEquals);
}
@override
@@ -320,17 +379,28 @@ class DriftBarTabService implements BarTabService {
@override
Future<BarTab> createTab({required String customerName}) async {
final id = _uuid.v4();
final name = _validateCustomerName(customerName);
final openedAt = DateTime.now();
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: id,
customerName: _validateCustomerName(customerName),
openedAt: DateTime.now(),
version: const Value(1),
),
);
await database.transaction(() async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: id,
customerName: name,
openedAt: openedAt,
version: const Value(1),
),
);
await _recordEvent(
eventFactory.tabOpened(
tabId: id,
customerName: name,
openedAt: openedAt,
),
);
});
return (await getTabById(id))!;
}
@@ -350,6 +420,9 @@ class DriftBarTabService implements BarTabService {
if (updatedRows != 1) throw TabNotFoundException(tabId);
await _recordEvent(
eventFactory.tabRenamed(tabId: tabId, customerName: name),
);
await _incrementTabVersion(tabId);
});
}
@@ -361,22 +434,21 @@ class DriftBarTabService implements BarTabService {
if (tab == null) return;
for (final item in tab.items) {
await database.increaseStock(item.productId, item.quantity);
await _recordEvent(
eventFactory.lineRemoved(
tabId: tab.id,
customerName: tab.customerName,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
),
await _recordLineChange(
tabId: tab.id,
customerName: tab.customerName,
lineId: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
delta: -item.quantity,
);
}
await _recordEvent(
eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName),
);
await _deleteItemsOfTab(tabId);
await (database.delete(
database.barTabs,
)..where((row) => row.id.equals(tabId))).go();
await _deleteTabWithItems(tabId);
});
}
@@ -389,14 +461,17 @@ class DriftBarTabService implements BarTabService {
final tabRow = await _getTabRow(tabId);
if (tabRow == null) throw TabNotFoundException(tabId);
await database.decreaseStock(product.id, 1);
// A merge on the server can leave two lines of one product at
// different prices; new drinks go on the oldest
final existingItem =
await (database.select(database.tabItems)..where(
(item) =>
item.tabId.equals(tabId) &
item.productId.equals(product.id),
))
await (database.select(database.tabItems)
..where(
(item) =>
item.tabId.equals(tabId) &
item.productId.equals(product.id),
)
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
..limit(1))
.getSingleOrNull();
if (existingItem != null) {
@@ -407,14 +482,14 @@ class DriftBarTabService implements BarTabService {
);
await _recordPurchase(existingItem.id);
// The line keeps the price it was first added at
await _recordEvent(
eventFactory.lineAdded(
tabId: tabId,
customerName: tabRow.customerName,
productName: existingItem.productName,
unitPriceInCents: existingItem.unitPriceInCents,
quantity: 1,
),
await _recordLineChange(
tabId: tabId,
customerName: tabRow.customerName,
lineId: existingItem.id,
productId: existingItem.productId,
productName: existingItem.productName,
unitPriceInCents: existingItem.unitPriceInCents,
delta: 1,
);
} else {
final tabItemId = _uuid.v4();
@@ -433,14 +508,14 @@ class DriftBarTabService implements BarTabService {
),
);
await _recordPurchase(tabItemId);
await _recordEvent(
eventFactory.lineAdded(
tabId: tabId,
customerName: tabRow.customerName,
productName: product.name,
unitPriceInCents: product.priceInCents,
quantity: 1,
),
await _recordLineChange(
tabId: tabId,
customerName: tabRow.customerName,
lineId: tabItemId,
productId: product.id,
productName: product.name,
unitPriceInCents: product.priceInCents,
delta: 1,
);
}
@@ -468,12 +543,10 @@ class DriftBarTabService implements BarTabService {
if (actualDelta == 0) return 0;
if (actualDelta > 0) {
await database.decreaseStock(item.productId, actualDelta);
for (var index = 0; index < actualDelta; index++) {
await _recordPurchase(item.id);
}
} else {
await database.increaseStock(item.productId, -actualDelta);
await _removeLatestPurchases(item.id, -actualDelta);
}
@@ -482,17 +555,14 @@ class DriftBarTabService implements BarTabService {
.write(TabItemsCompanion(quantity: Value(newQuantity)));
final tabRow = await _getTabRow(item.tabId);
final lineEvent = actualDelta > 0
? eventFactory.lineAdded
: eventFactory.lineRemoved;
await _recordEvent(
lineEvent(
tabId: item.tabId,
customerName: tabRow?.customerName ?? '',
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: actualDelta.abs(),
),
await _recordLineChange(
tabId: item.tabId,
customerName: tabRow?.customerName ?? '',
lineId: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
delta: actualDelta,
);
await _incrementTabVersion(item.tabId);
@@ -524,7 +594,36 @@ class DriftBarTabService implements BarTabService {
if (items.isEmpty) return;
final closedTabId = _uuid.v4();
// One settlement, not a removal per line: these drinks were sold
final settledItems = [
for (final item in items)
if (item.quantity > 0) item,
];
final settledEvent = settledItems.isEmpty
? null
: eventFactory.settled(
tabId: tabId,
customerName: tabRow.customerName,
amountInCents: settledItems.fold(
0,
(total, item) => total + item.lineTotalInCents,
),
paymentMethod: paymentMethod,
commandId: commandId,
items: [
for (final item in settledItems)
(
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPriceInCents: item.unitPriceInCents,
),
],
);
// The server reports this settlement back under the event's id, so it
// isn't added to history twice
final closedTabId = settledEvent?.id ?? _uuid.v4();
await database
.into(database.closedTabs)
@@ -555,33 +654,7 @@ class DriftBarTabService implements BarTabService {
]);
});
// One settlement, not a removal per line: these drinks were sold
final settledItems = [
for (final item in items)
if (item.quantity > 0) item,
];
if (settledItems.isNotEmpty) {
await _recordEvent(
eventFactory.settled(
tabId: tabId,
customerName: tabRow.customerName,
amountInCents: settledItems.fold(
0,
(total, item) => total + item.lineTotalInCents,
),
paymentMethod: paymentMethod,
commandId: commandId,
items: [
for (final item in settledItems)
(
productName: item.productName,
quantity: item.quantity,
unitPriceInCents: item.unitPriceInCents,
),
],
),
);
}
if (settledEvent != null) await _recordEvent(settledEvent);
await _deleteItemsOfTab(tabId);
await _incrementTabVersion(tabId);
@@ -679,4 +752,319 @@ class DriftBarTabService implements BarTabService {
final rows = await query.get();
return rows.map((row) => row.read(customerName)!).toList();
}
@override
Future<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),
),
]);
});
}
});
}
}