import 'dart:math'; import 'package:drift/drift.dart'; 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/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}); Future renameTab({required String tabId, required String customerName}); /// Deletes an open tab and returns its items to inventory. Future deleteTab(String tabId); Future addProductToTab({ required String tabId, required Product product, }); /// Returns the delta that was actually applied, which is smaller than /// [delta] when it would push the quantity below zero. Future adjustTabItemQuantity({ required String tabItemId, required int delta, }); /// 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. Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, int? expectedVersion, }); Future> getClosedTabs(); Future> getClosedTabsPaginated({ int limit = 20, int offset = 0, String? customerName, }); Future getClosedTabCount({String? customerName}); Future> getDistinctCustomerNames(); } class DriftBarTabService implements BarTabService { final AppDatabase database; final _uuid = const Uuid(); DriftBarTabService({required this.database}); TabItemPurchase _mapPurchaseRow(TabItemPurchaseRow row) { return TabItemPurchase( id: row.id, tabItemId: row.tabItemId, purchasedAt: row.purchasedAt, removedAt: row.removedAt, ); } 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, 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, ); } /// 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)); query.orderBy([ OrderingTerm.desc(database.barTabs.openedAt), OrderingTerm.asc(database.tabItems.createdAt), OrderingTerm.asc(database.tabItemPurchases.purchasedAt), OrderingTerm.asc(database.tabItemPurchases.id), ]); 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(); } Future _recordPurchase(String tabItemId) async { await database .into(database.tabItemPurchases) .insert( TabItemPurchasesCompanion.insert( id: _uuid.v4(), tabItemId: tabItemId, purchasedAt: DateTime.now(), ), ); } /// Marks the most recent active purchases as removed, keeping them in the /// audit trail. Future _removeLatestPurchases(String tabItemId, int count) async { final latest = await (database.select(database.tabItemPurchases) ..where( (purchase) => purchase.tabItemId.equals(tabItemId) & purchase.removedAt.isNull(), ) ..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(DateTime.now()))); } /// 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(); } 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), ); } @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}) async { final id = _uuid.v4(); await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: id, customerName: _validateCustomerName(customerName), openedAt: DateTime.now(), version: const Value(1), ), ); 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 _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) { await database.increaseStock(item.productId, item.quantity); } await _deleteItemsOfTab(tabId); await (database.delete( database.barTabs, )..where((row) => row.id.equals(tabId))).go(); }); } @override Future addProductToTab({ required String tabId, required Product product, }) async { await database.transaction(() async { await database.decreaseStock(product.id, 1); final existingItem = await (database.select(database.tabItems)..where( (item) => item.tabId.equals(tabId) & item.productId.equals(product.id), )) .getSingleOrNull(); if (existingItem != null) { await (database.update( database.tabItems, )..where((item) => item.id.equals(existingItem.id))).write( TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); await _recordPurchase(existingItem.id); } else { final tabItemId = _uuid.v4(); await database .into(database.tabItems) .insert( TabItemsCompanion.insert( id: tabItemId, tabId: tabId, productId: product.id, productName: product.name, quantity: 1, unitPriceInCents: product.priceInCents, createdAt: DateTime.now(), ), ); await _recordPurchase(tabItemId); } await _incrementTabVersion(tabId); }); } @override Future adjustTabItemQuantity({ required String tabItemId, required int delta, }) 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; final newQuantity = max(item.quantity + delta, 0); final actualDelta = newQuantity - item.quantity; if (actualDelta == 0) return 0; if (actualDelta > 0) { await database.decreaseStock(item.productId, actualDelta); for (var index = 0; index < actualDelta; index++) { await _recordPurchase(item.id); } } else { await database.increaseStock(item.productId, -actualDelta); await _removeLatestPurchases(item.id, -actualDelta); } await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(newQuantity))); await _incrementTabVersion(item.tabId); return actualDelta; }); } @override Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, int? expectedVersion, }) 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; final closedTabId = _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), ), ]); }); 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), (item) => OrderingTerm.desc(item.id), ])) .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(); } }