feat: New Open Tab dialog with search and optional customer email. Drinks are now grouped per order. Payments trigger cha-ching and money rain.

This commit is contained in:
2026-10-28 02:11:32 +01:00
parent 1aee0541c4
commit 29ceb51177
41 changed files with 2771 additions and 812 deletions
+165 -40
View File
@@ -50,23 +50,29 @@ abstract class BarTabService {
Future<BarTab?> getTabById(String id);
Future<BarTab> createTab({required String customerName});
Future<BarTab> createTab({required String customerName, String? email});
Future<void> renameTab({required String tabId, required String customerName});
/// Deletes an open tab and returns its items to inventory.
Future<void> deleteTab(String tabId);
/// Rings up one [product] as part of order [orderId]; a new order when
/// null.
Future<void> addProductToTab({
required String tabId,
required Product product,
String? orderId,
});
/// Returns the delta that was actually applied, which is smaller than
/// [delta] when it would push the quantity below zero.
/// Adds drinks to [order] (a new order when null), or takes drinks of
/// [order] off (when null: the latest of the line). Returns the delta that
/// was actually applied, which is smaller than [delta] when there are fewer
/// drinks to take off.
Future<int> adjustTabItemQuantity({
required String tabItemId,
required int delta,
OrderRef? order,
});
/// Archives the tab's current items into history and clears them.
@@ -119,6 +125,7 @@ class DriftBarTabService implements BarTabService {
tabItemId: row.tabItemId,
purchasedAt: row.purchasedAt,
removedAt: row.removedAt,
orderId: row.orderId,
);
}
@@ -138,6 +145,7 @@ class DriftBarTabService implements BarTabService {
return BarTab(
id: row.id,
customerName: row.customerName,
email: row.email,
openedAt: row.openedAt,
version: row.version,
items: items,
@@ -154,6 +162,7 @@ class DriftBarTabService implements BarTabService {
unitPriceInCents: row.unitPriceInCents,
purchasedAt: row.purchasedAt,
removedAt: row.removedAt,
orderId: row.orderId,
);
}
@@ -173,11 +182,16 @@ class DriftBarTabService implements BarTabService {
if (tabId != null) query.where(database.barTabs.id.equals(tabId));
// Times are stored to the second: drinks rung up within one second keep
// the order they were added in (rowid), so a tab's rows never swap
query.orderBy([
OrderingTerm.desc(database.barTabs.openedAt),
OrderingTerm.asc(database.tabItems.createdAt),
OrderingTerm.asc(const CustomExpression<int>('"tab_items"."rowid"')),
OrderingTerm.asc(database.tabItemPurchases.purchasedAt),
OrderingTerm.asc(database.tabItemPurchases.id),
OrderingTerm.asc(
const CustomExpression<int>('"tab_item_purchases"."rowid"'),
),
]);
return query;
@@ -224,31 +238,63 @@ class DriftBarTabService implements BarTabService {
)..where((tab) => tab.id.equals(tabId))).getSingleOrNull();
}
Future<void> _recordPurchase(String tabItemId) async {
/// [id] is the one the server gives the unit: `<event id>:<index>`.
Future<void> _recordPurchase(
String tabItemId,
String orderId, {
required String id,
}) async {
await database
.into(database.tabItemPurchases)
.insert(
TabItemPurchasesCompanion.insert(
id: _uuid.v4(),
id: id,
tabItemId: tabItemId,
purchasedAt: DateTime.now(),
orderId: Value(orderId),
),
);
}
/// Marks the most recent active purchases as removed, keeping them in the
/// audit trail.
/// The active purchases of [tabItemId] a removal may take off: those of
/// [order] when given, as the server does; else all of them (events from
/// before orders).
Future<List<TabItemPurchaseRow>> _removablePurchases(
String tabItemId,
OrderRef? order,
) {
return (database.select(database.tabItemPurchases)..where(
(purchase) =>
purchase.tabItemId.equals(tabItemId) &
purchase.removedAt.isNull() &
switch (order) {
null => const Constant(true),
(orderId: null) => purchase.orderId.isNull(),
(orderId: final String id) => purchase.orderId.equals(id),
},
))
.get();
}
/// Marks the most recent active purchases (of [order], see
/// [_removablePurchases]) as removed, keeping them in the audit trail.
Future<void> _removeLatestPurchases(
String tabItemId,
int count, {
DateTime? removedAt,
OrderRef? order,
}) async {
final latest =
await (database.select(database.tabItemPurchases)
..where(
(purchase) =>
purchase.tabItemId.equals(tabItemId) &
purchase.removedAt.isNull(),
purchase.removedAt.isNull() &
switch (order) {
null => const Constant(true),
(orderId: null) => purchase.orderId.isNull(),
(orderId: final String id) => purchase.orderId.equals(id),
},
)
..orderBy([
(purchase) => OrderingTerm.desc(purchase.purchasedAt),
@@ -303,11 +349,13 @@ class DriftBarTabService implements BarTabService {
);
}
/// Returns the id of the event.
///
/// 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({
Future<String> _recordLineChange({
required String tabId,
required String customerName,
required String lineId,
@@ -315,22 +363,32 @@ class DriftBarTabService implements BarTabService {
required String productName,
required int unitPriceInCents,
required int delta,
required OrderRef? order,
}) 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(),
),
);
final event = delta > 0
? eventFactory.lineAdded(
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: delta,
orderId: order?.orderId,
)
: eventFactory.lineRemoved(
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: -delta,
order: order,
);
await _recordEvent(event);
await database.changeTrackedStock(productId, -delta);
return event.id;
}
Future<void> _incrementTabVersion(String tabId) async {
@@ -377,9 +435,16 @@ class DriftBarTabService implements BarTabService {
}
@override
Future<BarTab> createTab({required String customerName}) async {
Future<BarTab> createTab({
required String customerName,
String? email,
}) async {
final id = _uuid.v4();
final name = _validateCustomerName(customerName);
final trimmedEmail = email?.trim();
final cleanEmail = trimmedEmail == null || trimmedEmail.isEmpty
? null
: trimmedEmail;
final openedAt = DateTime.now();
await database.transaction(() async {
@@ -389,6 +454,7 @@ class DriftBarTabService implements BarTabService {
BarTabsCompanion.insert(
id: id,
customerName: name,
email: Value(cleanEmail),
openedAt: openedAt,
version: const Value(1),
),
@@ -398,6 +464,7 @@ class DriftBarTabService implements BarTabService {
tabId: id,
customerName: name,
openedAt: openedAt,
email: cleanEmail,
),
);
});
@@ -434,6 +501,7 @@ class DriftBarTabService implements BarTabService {
if (tab == null) return;
for (final item in tab.items) {
// Without an order: everything on the line, whatever its order
await _recordLineChange(
tabId: tab.id,
customerName: tab.customerName,
@@ -442,6 +510,7 @@ class DriftBarTabService implements BarTabService {
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
delta: -item.quantity,
order: null,
);
}
await _recordEvent(
@@ -456,6 +525,7 @@ class DriftBarTabService implements BarTabService {
Future<void> addProductToTab({
required String tabId,
required Product product,
String? orderId,
}) async {
await database.transaction(() async {
final tabRow = await _getTabRow(tabId);
@@ -467,6 +537,7 @@ class DriftBarTabService implements BarTabService {
productName: product.name,
unitPriceInCents: product.priceInCents,
quantity: 1,
orderId: orderId ?? _uuid.v4(),
);
await _incrementTabVersion(tabId);
});
@@ -485,6 +556,7 @@ class DriftBarTabService implements BarTabService {
required String productName,
required int unitPriceInCents,
required int quantity,
required String orderId,
}) async {
final existingItem =
await (database.select(database.tabItems)
@@ -524,10 +596,7 @@ class DriftBarTabService implements BarTabService {
);
}
for (var index = 0; index < quantity; index++) {
await _recordPurchase(lineId);
}
await _recordLineChange(
final eventId = await _recordLineChange(
tabId: tabRow.id,
customerName: tabRow.customerName,
lineId: lineId,
@@ -535,7 +604,13 @@ class DriftBarTabService implements BarTabService {
productName: productName,
unitPriceInCents: unitPriceInCents,
delta: quantity,
order: (orderId: orderId),
);
// The ids the server gives these units, so they are recognised after a
// sync
for (var index = 0; index < quantity; index++) {
await _recordPurchase(lineId, orderId, id: '$eventId:$index');
}
}
/// Taking drinks off changes [tabItemId] itself. Adding is ringing up new
@@ -546,6 +621,7 @@ class DriftBarTabService implements BarTabService {
Future<int> adjustTabItemQuantity({
required String tabItemId,
required int delta,
OrderRef? order,
}) async {
if (delta == 0) return 0;
@@ -570,17 +646,23 @@ class DriftBarTabService implements BarTabService {
productName: product?.name ?? item.productName,
unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents,
quantity: delta,
orderId: order?.orderId ?? _uuid.v4(),
);
await _incrementTabVersion(item.tabId);
return delta;
}
final newQuantity = max(item.quantity + delta, 0);
final actualDelta = newQuantity - item.quantity;
// Only the drinks of the order can be taken off; without an order the
// line's quantity counts, as before orders
final available = order == null
? item.quantity
: (await _removablePurchases(item.id, order)).length;
final actualDelta = -min(-delta, available);
final newQuantity = item.quantity + actualDelta;
if (actualDelta == 0) return 0;
await _removeLatestPurchases(item.id, -actualDelta);
await _removeLatestPurchases(item.id, -actualDelta, order: order);
await (database.update(database.tabItems)
..where((row) => row.id.equals(tabItemId)))
.write(TabItemsCompanion(quantity: Value(newQuantity)));
@@ -594,6 +676,7 @@ class DriftBarTabService implements BarTabService {
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
delta: actualDelta,
order: order,
);
await _incrementTabVersion(item.tabId);
@@ -681,6 +764,7 @@ class DriftBarTabService implements BarTabService {
unitPriceInCents: item.unitPriceInCents,
purchasedAt: Value(purchase.purchasedAt),
removedAt: Value(purchase.removedAt),
orderId: Value(purchase.orderId),
),
]);
});
@@ -703,7 +787,9 @@ class DriftBarTabService implements BarTabService {
)
..orderBy([
(item) => OrderingTerm.desc(item.purchasedAt),
(item) => OrderingTerm.desc(item.id),
// Newest first also within one second: the order they were
// stored in, reversed
(item) => OrderingTerm.desc(const CustomExpression('rowid')),
]))
.get();
@@ -787,12 +873,25 @@ class DriftBarTabService implements BarTabService {
@override
Future<void> applyServerState(List<ServerTab> tabs) async {
await database.transaction(() async {
// A server without orders sends units back without one: they keep the
// order they were rung up in here, so the tab doesn't regroup
final knownOrders = {
for (final purchase in await (database.select(
database.tabItemPurchases,
)..where((row) => row.orderId.isNotNull())).get())
purchase.id: purchase.orderId!,
};
// A server that doesn't know emails yet would wipe the ones entered
// here
final knownEmails = <String, String>{};
for (final tab in await database.select(database.barTabs).get()) {
if (tab.email case final email?) knownEmails[tab.id] = email;
await _deleteTabWithItems(tab.id);
}
for (final tab in tabs) {
await _insertServerTab(tab);
await _insertServerTab(tab, knownOrders, knownEmails[tab.id]);
}
// Read here, so changes made while the sync was in flight are included
@@ -808,13 +907,18 @@ class DriftBarTabService implements BarTabService {
});
}
Future<void> _insertServerTab(ServerTab tab) async {
Future<void> _insertServerTab(
ServerTab tab,
Map<String, String> knownOrders,
String? knownEmail,
) async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: tab.id,
customerName: tab.customerName,
email: Value(tab.email ?? knownEmail),
openedAt: tab.openedAt,
version: Value(tab.version),
),
@@ -848,6 +952,7 @@ class DriftBarTabService implements BarTabService {
tabItemId: line.id,
purchasedAt: unit.addedAt,
removedAt: Value(unit.removedAt),
orderId: Value(unit.orderId ?? knownOrders[unit.id]),
),
]);
});
@@ -871,7 +976,12 @@ class DriftBarTabService implements BarTabService {
final openedAt = DateTime.tryParse(
event['openedAt'] as String? ?? '',
)?.toLocal();
await _ensureTab(tabId, customerName, openedAt ?? occurredAt);
await _ensureTab(
tabId,
customerName,
openedAt ?? occurredAt,
email: event['email'] as String?,
);
case TabEventType.tabRenamed:
await (database.update(database.barTabs)
..where((tab) => tab.id.equals(tabId)))
@@ -885,9 +995,20 @@ class DriftBarTabService implements BarTabService {
final quantity = event['quantity'] as int? ?? 0;
if (line == null || quantity <= 0) return;
final removed = min(quantity, line.quantity);
// Events from before orders have no `orderId`: the latest of the line
final OrderRef? order = event.containsKey('orderId')
? (orderId: event['orderId'] as String?)
: null;
final removable = await _removablePurchases(line.id, order);
final removed = min(quantity, removable.length);
if (removed == 0) return;
await _setLineQuantity(line.id, line.quantity - removed);
await _removeLatestPurchases(line.id, removed, removedAt: occurredAt);
await _removeLatestPurchases(
line.id,
removed,
removedAt: occurredAt,
order: order,
);
case TabEventType.settled:
for (final item in event['items'] as List<dynamic>? ?? []) {
await _replaySettledLine(tabId, item as Map<String, dynamic>);
@@ -947,6 +1068,7 @@ class DriftBarTabService implements BarTabService {
id: '${event['id']}:$index',
tabItemId: line!.id,
purchasedAt: occurredAt,
orderId: Value(event['orderId'] as String?),
),
]);
});
@@ -998,14 +1120,16 @@ class DriftBarTabService implements BarTabService {
Future<void> _ensureTab(
String tabId,
String customerName,
DateTime openedAt,
) async {
DateTime openedAt, {
String? email,
}) async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: tabId,
customerName: customerName,
email: Value(email),
openedAt: openedAt,
version: const Value(1),
),
@@ -1096,6 +1220,7 @@ class DriftBarTabService implements BarTabService {
unitPriceInCents: line.unitPriceInCents,
purchasedAt: Value(unit.addedAt),
removedAt: Value(unit.removedAt),
orderId: Value(unit.orderId),
),
]);
});