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> watchOpenTabs(); /// With [includeRemoved], items whose purchases were all undone are kept /// so history can show them. Future> getOpenTabs({bool includeRemoved = false}); Future getTabById(String id); Future createTab({required String customerName, String? email}); Future renameTab({required String tabId, required String customerName}); /// Deletes an open tab and returns its items to inventory. Future deleteTab(String tabId); /// Rings up one [product] as part of order [orderId]; a new order when /// null. Future 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 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 closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, int? expectedVersion, String? commandId, }); Future> getClosedTabs(); Future> getClosedTabsPaginated({ int limit = 20, int offset = 0, String? customerName, }); Future getClosedTabCount({String? customerName}); Future> 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 applyServerState(List tabs); /// Adds settlements made anywhere in the venue to the history. Settlements /// already in it (such as this tablet's own) are skipped. Future recordSettlements(List 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 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 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 _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('"tab_items"."rowid"')), OrderingTerm.asc(database.tabItemPurchases.purchasedAt), OrderingTerm.asc( const CustomExpression('"tab_item_purchases"."rowid"'), ), ]); return query; } List _groupTabs( List rows, { required bool includeRemoved, }) { final tabRows = {}; final itemRowsByTab = >{}; final purchasesByItem = >{}; 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 _getTabRow(String tabId) { return (database.select( database.barTabs, )..where((tab) => tab.id.equals(tabId))).getSingleOrNull(); } /// [id] is the one the server gives the unit: `:`. Future _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> _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 _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 _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 _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 _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 _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 _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> watchOpenTabs() { return _tabsQuery() .watch() .map((rows) => _groupTabs(rows, includeRemoved: false)) // A sync rewrites the tabs even when nothing changed .distinct(listEquals); } @override Future> getOpenTabs({bool includeRemoved = false}) async { final rows = await _tabsQuery().get(); return _groupTabs(rows, includeRemoved: includeRemoved); } @override Future getTabById(String id) async { final rows = await _tabsQuery(tabId: id).get(); return _groupTabs(rows, includeRemoved: false).firstOrNull; } @override Future 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 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 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 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 _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 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 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> _withItems(List 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 = >{}; 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> getClosedTabs() async { return _withItems(await _closedTabsQuery().get()); } @override Future> 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 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> 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 applyServerState(List 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 = {}; 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('rowid')), ])) .get(); for (final row in outbox) { await _replayEvent(jsonDecode(row.payload) as Map); } }); } Future _insertServerTab( ServerTab tab, Map 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 _replayEvent(Map 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? ?? []) { await _replaySettledLine(tabId, item as Map); } // 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 _replayLineAdded( Map 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 _replaySettledLine( String tabId, Map 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 _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 _findLine( String tabId, Map 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 first(Expression 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 _setLineQuantity(String lineId, int quantity) async { await (database.update(database.tabItems) ..where((row) => row.id.equals(lineId))) .write(TabItemsCompanion(quantity: Value(quantity))); } @override Future recordSettlements(List 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), ), ]); }); } }); } }