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:
@@ -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),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user