1231 lines
39 KiB
Dart
1231 lines
39 KiB
Dart
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';
|
|
import '../database/stock_queries.dart';
|
|
import '../models/bar_tab.dart';
|
|
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';
|
|
|
|
class TabNotFoundException implements Exception {
|
|
final String tabId;
|
|
|
|
const TabNotFoundException(this.tabId);
|
|
|
|
@override
|
|
String toString() => 'Tab not found: $tabId';
|
|
}
|
|
|
|
class TabVersionConflictException implements Exception {
|
|
final int expectedVersion;
|
|
final int actualVersion;
|
|
|
|
const TabVersionConflictException({
|
|
required this.expectedVersion,
|
|
required this.actualVersion,
|
|
});
|
|
|
|
@override
|
|
String toString() =>
|
|
'Tab is at version $actualVersion, expected $expectedVersion';
|
|
}
|
|
|
|
abstract class BarTabService {
|
|
/// Open tabs with their visible items, re-emitted on every change.
|
|
Stream<List<BarTab>> watchOpenTabs();
|
|
|
|
/// With [includeRemoved], items whose purchases were all undone are kept
|
|
/// so history can show them.
|
|
Future<List<BarTab>> getOpenTabs({bool includeRemoved = false});
|
|
|
|
Future<BarTab?> getTabById(String id);
|
|
|
|
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,
|
|
});
|
|
|
|
/// 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.
|
|
/// The tab itself stays open under the same customer name.
|
|
///
|
|
/// Throws [TabVersionConflictException] when [expectedVersion] is given and
|
|
/// the tab changed since. [commandId] is the remote command that requested
|
|
/// the settlement, if any.
|
|
Future<void> closeTab(
|
|
String tabId, {
|
|
PaymentMethod paymentMethod = PaymentMethod.cash,
|
|
int? expectedVersion,
|
|
String? commandId,
|
|
});
|
|
|
|
Future<List<ClosedTab>> getClosedTabs();
|
|
|
|
Future<List<ClosedTab>> getClosedTabsPaginated({
|
|
int limit = 20,
|
|
int offset = 0,
|
|
String? customerName,
|
|
});
|
|
|
|
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 {
|
|
final AppDatabase database;
|
|
final TabEventFactory eventFactory;
|
|
final _uuid = const Uuid();
|
|
|
|
DriftBarTabService({required this.database, TabEventFactory? eventFactory})
|
|
: eventFactory = eventFactory ?? TabEventFactory();
|
|
|
|
TabItemPurchase _mapPurchaseRow(TabItemPurchaseRow row) {
|
|
return TabItemPurchase(
|
|
id: row.id,
|
|
tabItemId: row.tabItemId,
|
|
purchasedAt: row.purchasedAt,
|
|
removedAt: row.removedAt,
|
|
orderId: row.orderId,
|
|
);
|
|
}
|
|
|
|
TabItem _mapItemRow(TabItemRow row, List<TabItemPurchase> purchases) {
|
|
return TabItem(
|
|
id: row.id,
|
|
tabId: row.tabId,
|
|
productId: row.productId,
|
|
productName: row.productName,
|
|
quantity: row.quantity,
|
|
unitPriceInCents: row.unitPriceInCents,
|
|
purchases: purchases,
|
|
);
|
|
}
|
|
|
|
BarTab _mapTabRow(BarTabRow row, List<TabItem> items) {
|
|
return BarTab(
|
|
id: row.id,
|
|
customerName: row.customerName,
|
|
email: row.email,
|
|
openedAt: row.openedAt,
|
|
version: row.version,
|
|
items: items,
|
|
);
|
|
}
|
|
|
|
ClosedTabItem _mapClosedItemRow(ClosedTabItemRow row) {
|
|
return ClosedTabItem(
|
|
id: row.id,
|
|
closedTabId: row.closedTabId,
|
|
productId: row.productId,
|
|
productName: row.productName,
|
|
quantity: row.quantity,
|
|
unitPriceInCents: row.unitPriceInCents,
|
|
purchasedAt: row.purchasedAt,
|
|
removedAt: row.removedAt,
|
|
orderId: row.orderId,
|
|
);
|
|
}
|
|
|
|
/// Tabs, items and purchases in a single query, so loading N tabs doesn't
|
|
/// cost N * items extra round trips.
|
|
JoinedSelectStatement<HasResultSet, dynamic> _tabsQuery({String? tabId}) {
|
|
final query = database.select(database.barTabs).join([
|
|
leftOuterJoin(
|
|
database.tabItems,
|
|
database.tabItems.tabId.equalsExp(database.barTabs.id),
|
|
),
|
|
leftOuterJoin(
|
|
database.tabItemPurchases,
|
|
database.tabItemPurchases.tabItemId.equalsExp(database.tabItems.id),
|
|
),
|
|
]);
|
|
|
|
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(
|
|
const CustomExpression<int>('"tab_item_purchases"."rowid"'),
|
|
),
|
|
]);
|
|
|
|
return query;
|
|
}
|
|
|
|
List<BarTab> _groupTabs(
|
|
List<TypedResult> rows, {
|
|
required bool includeRemoved,
|
|
}) {
|
|
final tabRows = <String, BarTabRow>{};
|
|
final itemRowsByTab = <String, Map<String, TabItemRow>>{};
|
|
final purchasesByItem = <String, List<TabItemPurchase>>{};
|
|
|
|
for (final row in rows) {
|
|
final tab = row.readTable(database.barTabs);
|
|
tabRows[tab.id] = tab;
|
|
final itemRows = itemRowsByTab.putIfAbsent(tab.id, () => {});
|
|
|
|
final item = row.readTableOrNull(database.tabItems);
|
|
if (item == null) continue;
|
|
|
|
itemRows[item.id] = item;
|
|
final purchases = purchasesByItem.putIfAbsent(item.id, () => []);
|
|
|
|
final purchase = row.readTableOrNull(database.tabItemPurchases);
|
|
if (purchase != null) purchases.add(_mapPurchaseRow(purchase));
|
|
}
|
|
|
|
return [
|
|
for (final tab in tabRows.values)
|
|
_mapTabRow(tab, [
|
|
for (final item in itemRowsByTab[tab.id]!.values)
|
|
if (includeRemoved
|
|
? purchasesByItem[item.id]!.isNotEmpty
|
|
: item.quantity > 0)
|
|
_mapItemRow(item, purchasesByItem[item.id]!),
|
|
]),
|
|
];
|
|
}
|
|
|
|
Future<BarTabRow?> _getTabRow(String tabId) {
|
|
return (database.select(
|
|
database.barTabs,
|
|
)..where((tab) => tab.id.equals(tabId))).getSingleOrNull();
|
|
}
|
|
|
|
/// [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: id,
|
|
tabItemId: tabItemId,
|
|
purchasedAt: DateTime.now(),
|
|
orderId: Value(orderId),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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() &
|
|
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),
|
|
(purchase) => OrderingTerm.desc(purchase.id),
|
|
])
|
|
..limit(count))
|
|
.get();
|
|
|
|
await (database.update(database.tabItemPurchases)
|
|
..where((purchase) => purchase.id.isIn(latest.map((row) => row.id))))
|
|
.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.
|
|
Future<void> _deleteItemsOfTab(String tabId) async {
|
|
final itemIds = database.selectOnly(database.tabItems)
|
|
..addColumns([database.tabItems.id])
|
|
..where(database.tabItems.tabId.equals(tabId));
|
|
|
|
await (database.delete(
|
|
database.tabItemPurchases,
|
|
)..where((purchase) => purchase.tabItemId.isInQuery(itemIds))).go();
|
|
|
|
await (database.delete(
|
|
database.tabItems,
|
|
)..where((item) => item.tabId.equals(tabId))).go();
|
|
}
|
|
|
|
/// Queues [event] in the outbox. Call it inside the transaction that makes
|
|
/// the change, so the change and its event are stored together or not at
|
|
/// all.
|
|
Future<void> _recordEvent(TabEvent event) async {
|
|
await database
|
|
.into(database.tabEvents)
|
|
.insert(
|
|
TabEventsCompanion.insert(
|
|
id: event.id,
|
|
payload: jsonEncode(event.payload),
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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<String> _recordLineChange({
|
|
required String tabId,
|
|
required String customerName,
|
|
required String lineId,
|
|
required String productId,
|
|
required String productName,
|
|
required int unitPriceInCents,
|
|
required int delta,
|
|
required OrderRef? order,
|
|
}) async {
|
|
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 {
|
|
await (database.update(
|
|
database.barTabs,
|
|
)..where((tab) => tab.id.equals(tabId))).write(
|
|
BarTabsCompanion.custom(
|
|
version: database.barTabs.version + const Constant(1),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _validateCustomerName(String customerName) {
|
|
final name = customerName.trim();
|
|
if (name.isEmpty) {
|
|
throw ArgumentError.value(
|
|
customerName,
|
|
'customerName',
|
|
'Must not be empty',
|
|
);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
@override
|
|
Stream<List<BarTab>> watchOpenTabs() {
|
|
return _tabsQuery()
|
|
.watch()
|
|
.map((rows) => _groupTabs(rows, includeRemoved: false))
|
|
// A sync rewrites the tabs even when nothing changed
|
|
.distinct(listEquals);
|
|
}
|
|
|
|
@override
|
|
Future<List<BarTab>> getOpenTabs({bool includeRemoved = false}) async {
|
|
final rows = await _tabsQuery().get();
|
|
return _groupTabs(rows, includeRemoved: includeRemoved);
|
|
}
|
|
|
|
@override
|
|
Future<BarTab?> getTabById(String id) async {
|
|
final rows = await _tabsQuery(tabId: id).get();
|
|
return _groupTabs(rows, includeRemoved: false).firstOrNull;
|
|
}
|
|
|
|
@override
|
|
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 {
|
|
await database
|
|
.into(database.barTabs)
|
|
.insert(
|
|
BarTabsCompanion.insert(
|
|
id: id,
|
|
customerName: name,
|
|
email: Value(cleanEmail),
|
|
openedAt: openedAt,
|
|
version: const Value(1),
|
|
),
|
|
);
|
|
await _recordEvent(
|
|
eventFactory.tabOpened(
|
|
tabId: id,
|
|
customerName: name,
|
|
openedAt: openedAt,
|
|
email: cleanEmail,
|
|
),
|
|
);
|
|
});
|
|
|
|
return (await getTabById(id))!;
|
|
}
|
|
|
|
@override
|
|
Future<void> renameTab({
|
|
required String tabId,
|
|
required String customerName,
|
|
}) async {
|
|
final name = _validateCustomerName(customerName);
|
|
|
|
await database.transaction(() async {
|
|
final updatedRows =
|
|
await (database.update(database.barTabs)
|
|
..where((tab) => tab.id.equals(tabId)))
|
|
.write(BarTabsCompanion(customerName: Value(name)));
|
|
|
|
if (updatedRows != 1) throw TabNotFoundException(tabId);
|
|
|
|
await _recordEvent(
|
|
eventFactory.tabRenamed(tabId: tabId, customerName: name),
|
|
);
|
|
await _incrementTabVersion(tabId);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteTab(String tabId) async {
|
|
await database.transaction(() async {
|
|
final tab = await getTabById(tabId);
|
|
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,
|
|
lineId: item.id,
|
|
productId: item.productId,
|
|
productName: item.productName,
|
|
unitPriceInCents: item.unitPriceInCents,
|
|
delta: -item.quantity,
|
|
order: null,
|
|
);
|
|
}
|
|
await _recordEvent(
|
|
eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName),
|
|
);
|
|
|
|
await _deleteTabWithItems(tabId);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> addProductToTab({
|
|
required String tabId,
|
|
required Product product,
|
|
String? orderId,
|
|
}) async {
|
|
await database.transaction(() async {
|
|
final tabRow = await _getTabRow(tabId);
|
|
if (tabRow == null) throw TabNotFoundException(tabId);
|
|
|
|
await _addDrinks(
|
|
tabRow,
|
|
productId: product.id,
|
|
productName: product.name,
|
|
unitPriceInCents: product.priceInCents,
|
|
quantity: 1,
|
|
orderId: orderId ?? _uuid.v4(),
|
|
);
|
|
await _incrementTabVersion(tabId);
|
|
});
|
|
}
|
|
|
|
/// Puts [quantity] drinks on the tab, charged [unitPriceInCents]: on the
|
|
/// line of that product, name and price, or a new line when there is none.
|
|
/// Every new drink goes through here, so it is always charged the current
|
|
/// price: after a price or name change on the website the drinks already
|
|
/// on the tab keep what they were rung up at, and new ones start a new
|
|
/// line. Of several matching lines (a merge on the server) the oldest gets
|
|
/// them.
|
|
Future<void> _addDrinks(
|
|
BarTabRow tabRow, {
|
|
required String productId,
|
|
required String productName,
|
|
required int unitPriceInCents,
|
|
required int quantity,
|
|
required String orderId,
|
|
}) async {
|
|
final existingItem =
|
|
await (database.select(database.tabItems)
|
|
..where(
|
|
(item) =>
|
|
item.tabId.equals(tabRow.id) &
|
|
item.productId.equals(productId) &
|
|
item.unitPriceInCents.equals(unitPriceInCents) &
|
|
item.productName.equals(productName),
|
|
)
|
|
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
|
|
final String lineId;
|
|
if (existingItem != null) {
|
|
lineId = existingItem.id;
|
|
await (database.update(
|
|
database.tabItems,
|
|
)..where((item) => item.id.equals(lineId))).write(
|
|
TabItemsCompanion(quantity: Value(existingItem.quantity + quantity)),
|
|
);
|
|
} else {
|
|
lineId = _uuid.v4();
|
|
await database
|
|
.into(database.tabItems)
|
|
.insert(
|
|
TabItemsCompanion.insert(
|
|
id: lineId,
|
|
tabId: tabRow.id,
|
|
productId: productId,
|
|
productName: productName,
|
|
quantity: quantity,
|
|
unitPriceInCents: unitPriceInCents,
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
}
|
|
|
|
final eventId = await _recordLineChange(
|
|
tabId: tabRow.id,
|
|
customerName: tabRow.customerName,
|
|
lineId: lineId,
|
|
productId: productId,
|
|
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
|
|
/// drinks of its product, at the current price: on this line while its
|
|
/// price and name are still current, else on the line at the current price
|
|
/// (see [_addDrinks]). Returns how many were added (positive) or taken off.
|
|
@override
|
|
Future<int> adjustTabItemQuantity({
|
|
required String tabItemId,
|
|
required int delta,
|
|
OrderRef? order,
|
|
}) async {
|
|
if (delta == 0) return 0;
|
|
|
|
return database.transaction(() async {
|
|
final item = await (database.select(
|
|
database.tabItems,
|
|
)..where((row) => row.id.equals(tabItemId))).getSingleOrNull();
|
|
|
|
if (item == null) return 0;
|
|
|
|
if (delta > 0) {
|
|
final tabRow = await _getTabRow(item.tabId);
|
|
if (tabRow == null) return 0;
|
|
|
|
// Hidden products still count: the drink is on the tab already
|
|
final product = await (database.select(
|
|
database.products,
|
|
)..where((row) => row.id.equals(item.productId))).getSingleOrNull();
|
|
await _addDrinks(
|
|
tabRow,
|
|
productId: item.productId,
|
|
productName: product?.name ?? item.productName,
|
|
unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents,
|
|
quantity: delta,
|
|
orderId: order?.orderId ?? _uuid.v4(),
|
|
);
|
|
await _incrementTabVersion(item.tabId);
|
|
return delta;
|
|
}
|
|
|
|
// 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, order: order);
|
|
await (database.update(database.tabItems)
|
|
..where((row) => row.id.equals(tabItemId)))
|
|
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
|
|
|
final tabRow = await _getTabRow(item.tabId);
|
|
await _recordLineChange(
|
|
tabId: item.tabId,
|
|
customerName: tabRow?.customerName ?? '',
|
|
lineId: item.id,
|
|
productId: item.productId,
|
|
productName: item.productName,
|
|
unitPriceInCents: item.unitPriceInCents,
|
|
delta: actualDelta,
|
|
order: order,
|
|
);
|
|
|
|
await _incrementTabVersion(item.tabId);
|
|
|
|
return actualDelta;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> closeTab(
|
|
String tabId, {
|
|
PaymentMethod paymentMethod = PaymentMethod.cash,
|
|
int? expectedVersion,
|
|
String? commandId,
|
|
}) async {
|
|
await database.transaction(() async {
|
|
final tabRow = await _getTabRow(tabId);
|
|
if (tabRow == null) throw TabNotFoundException(tabId);
|
|
|
|
if (expectedVersion != null && tabRow.version != expectedVersion) {
|
|
throw TabVersionConflictException(
|
|
expectedVersion: expectedVersion,
|
|
actualVersion: tabRow.version,
|
|
);
|
|
}
|
|
|
|
final rows = await _tabsQuery(tabId: tabId).get();
|
|
final items = _groupTabs(rows, includeRemoved: true).first.items;
|
|
|
|
if (items.isEmpty) return;
|
|
|
|
// 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)
|
|
.insert(
|
|
ClosedTabsCompanion.insert(
|
|
id: closedTabId,
|
|
originalTabId: tabId,
|
|
customerName: tabRow.customerName,
|
|
closedAt: DateTime.now(),
|
|
paymentMethod: Value(paymentMethod),
|
|
),
|
|
);
|
|
|
|
await database.batch((batch) {
|
|
batch.insertAll(database.closedTabItems, [
|
|
for (final item in items)
|
|
for (final purchase in item.purchases)
|
|
ClosedTabItemsCompanion.insert(
|
|
id: _uuid.v4(),
|
|
closedTabId: closedTabId,
|
|
productId: item.productId,
|
|
productName: item.productName,
|
|
quantity: 1,
|
|
unitPriceInCents: item.unitPriceInCents,
|
|
purchasedAt: Value(purchase.purchasedAt),
|
|
removedAt: Value(purchase.removedAt),
|
|
orderId: Value(purchase.orderId),
|
|
),
|
|
]);
|
|
});
|
|
|
|
if (settledEvent != null) await _recordEvent(settledEvent);
|
|
|
|
await _deleteItemsOfTab(tabId);
|
|
await _incrementTabVersion(tabId);
|
|
});
|
|
}
|
|
|
|
/// Loads the items for every row in one query instead of one per tab.
|
|
Future<List<ClosedTab>> _withItems(List<ClosedTabsRow> tabRows) async {
|
|
if (tabRows.isEmpty) return [];
|
|
|
|
final itemRows =
|
|
await (database.select(database.closedTabItems)
|
|
..where(
|
|
(item) => item.closedTabId.isIn(tabRows.map((row) => row.id)),
|
|
)
|
|
..orderBy([
|
|
(item) => OrderingTerm.desc(item.purchasedAt),
|
|
// Newest first also within one second: the order they were
|
|
// stored in, reversed
|
|
(item) => OrderingTerm.desc(const CustomExpression('rowid')),
|
|
]))
|
|
.get();
|
|
|
|
final itemsByTab = <String, List<ClosedTabItem>>{};
|
|
for (final row in itemRows) {
|
|
itemsByTab
|
|
.putIfAbsent(row.closedTabId, () => [])
|
|
.add(_mapClosedItemRow(row));
|
|
}
|
|
|
|
return [
|
|
for (final row in tabRows)
|
|
ClosedTab(
|
|
id: row.id,
|
|
originalTabId: row.originalTabId,
|
|
customerName: row.customerName,
|
|
closedAt: row.closedAt,
|
|
paymentMethod: row.paymentMethod,
|
|
items: itemsByTab[row.id] ?? [],
|
|
),
|
|
];
|
|
}
|
|
|
|
SimpleSelectStatement<$ClosedTabsTable, ClosedTabsRow> _closedTabsQuery({
|
|
String? customerName,
|
|
}) {
|
|
final query = database.select(database.closedTabs)
|
|
..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]);
|
|
|
|
if (customerName != null && customerName.isNotEmpty) {
|
|
query.where((tab) => tab.customerName.equals(customerName));
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
@override
|
|
Future<List<ClosedTab>> getClosedTabs() async {
|
|
return _withItems(await _closedTabsQuery().get());
|
|
}
|
|
|
|
@override
|
|
Future<List<ClosedTab>> getClosedTabsPaginated({
|
|
int limit = 20,
|
|
int offset = 0,
|
|
String? customerName,
|
|
}) async {
|
|
final query = _closedTabsQuery(customerName: customerName)
|
|
..limit(limit, offset: offset);
|
|
|
|
return _withItems(await query.get());
|
|
}
|
|
|
|
@override
|
|
Future<int> getClosedTabCount({String? customerName}) async {
|
|
final countExpr = database.closedTabs.id.count();
|
|
|
|
final query = database.selectOnly(database.closedTabs)
|
|
..addColumns([countExpr]);
|
|
|
|
if (customerName != null && customerName.isNotEmpty) {
|
|
query.where(database.closedTabs.customerName.equals(customerName));
|
|
}
|
|
|
|
final row = await query.getSingle();
|
|
return row.read(countExpr) ?? 0;
|
|
}
|
|
|
|
@override
|
|
Future<List<String>> getDistinctCustomerNames() async {
|
|
final customerName = database.closedTabs.customerName;
|
|
|
|
final query = database.selectOnly(database.closedTabs, distinct: true)
|
|
..addColumns([customerName])
|
|
..orderBy([OrderingTerm.asc(customerName)]);
|
|
|
|
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 {
|
|
// 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, knownOrders, knownEmails[tab.id]);
|
|
}
|
|
|
|
// 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,
|
|
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),
|
|
),
|
|
);
|
|
|
|
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),
|
|
orderId: Value(unit.orderId ?? knownOrders[unit.id]),
|
|
),
|
|
]);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
email: event['email'] as String?,
|
|
);
|
|
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;
|
|
|
|
// 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,
|
|
order: order,
|
|
);
|
|
case TabEventType.settled:
|
|
for (final item in event['items'] as List<dynamic>? ?? []) {
|
|
await _replaySettledLine(tabId, item as Map<String, dynamic>);
|
|
}
|
|
// Stock and products are rebased separately, see StockQueries and
|
|
// CatalogQueries
|
|
case TabEventType.stockSet:
|
|
case TabEventType.productCreated:
|
|
case TabEventType.productUpdated:
|
|
case TabEventType.productArchived:
|
|
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,
|
|
orderId: Value(event['orderId'] as String?),
|
|
),
|
|
]);
|
|
});
|
|
}
|
|
|
|
/// 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, {
|
|
String? email,
|
|
}) async {
|
|
await database
|
|
.into(database.barTabs)
|
|
.insert(
|
|
BarTabsCompanion.insert(
|
|
id: tabId,
|
|
customerName: customerName,
|
|
email: Value(email),
|
|
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),
|
|
orderId: Value(unit.orderId),
|
|
),
|
|
]);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|