import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../database/app_database.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'; import 'product_service.dart'; abstract class BarTabService { 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, }); 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. Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, }); 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, status: row.status, openedAt: row.openedAt, closedAt: row.closedAt, 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, ); } Future> _getItemsForTab(String tabId) async { final query = database.select(database.tabItems) ..where((item) => item.tabId.equals(tabId)) ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]); final rows = await query.get(); final items = []; for (final row in rows) { if (row.quantity <= 0) continue; final purchases = await _getPurchaseRecords(row); items.add(_mapItemRow(row, purchases)); } return items; } Future> _getItemsForHistory(String tabId) async { final query = database.select(database.tabItems) ..where((item) => item.tabId.equals(tabId)) ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]); final rows = await query.get(); final items = []; for (final row in rows) { final purchases = await _getPurchaseRecords(row); if (purchases.isEmpty) continue; items.add(_mapItemRow(row, purchases)); } return items; } Future> _getPurchaseRows(String tabItemId) async { final query = database.select(database.tabItemPurchases) ..where((purchase) => purchase.tabItemId.equals(tabItemId)) ..orderBy([ (purchase) => OrderingTerm.asc(purchase.purchasedAt), (purchase) => OrderingTerm.asc(purchase.id), ]); return query.get(); } Future> _getPurchaseRecords(TabItemRow item) async { final rows = await _getPurchaseRows(item.id); final purchases = rows.map(_mapPurchaseRow).toList(); final activeCount = purchases .where((purchase) => !purchase.isRemoved) .length; final missingActivePurchases = item.quantity - activeCount; if (missingActivePurchases <= 0) return purchases; return [ ...purchases, for (var index = 0; index < missingActivePurchases; index++) TabItemPurchase( id: 'legacy-${item.id}-$index', tabItemId: item.id, purchasedAt: item.createdAt, ), ]; } Future> _ensurePurchaseRows(TabItemRow item) async { final rows = await _getPurchaseRows(item.id); final activeCount = rows .where((purchase) => purchase.removedAt == null) .length; final missingActivePurchases = item.quantity - activeCount; for (var index = 0; index < missingActivePurchases; index++) { await _recordPurchase(tabItemId: item.id, purchasedAt: item.createdAt); } if (missingActivePurchases <= 0) return rows; return _getPurchaseRows(item.id); } Future _recordPurchase({ required String tabItemId, DateTime? purchasedAt, }) async { await database .into(database.tabItemPurchases) .insert( TabItemPurchasesCompanion.insert( id: _uuid.v4(), tabItemId: tabItemId, purchasedAt: purchasedAt ?? DateTime.now(), ), ); } Future _decreaseProductStock(String productId, int amount) async { if (amount <= 0) { throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); } final updatedRows = await database.customUpdate( 'UPDATE products ' 'SET stock_quantity = stock_quantity - ? ' 'WHERE id = ? AND stock_quantity >= ?', variables: [ Variable.withInt(amount), Variable.withString(productId), Variable.withInt(amount), ], updates: {database.products}, ); if (updatedRows == 1) return; final product = await (database.select( database.products, )..where((row) => row.id.equals(productId))).getSingleOrNull(); if (product == null) { throw Exception('Product not found'); } throw const InsufficientStockException(); } Future _increaseProductStock(String productId, int amount) async { if (amount <= 0) { throw ArgumentError.value(amount, 'amount', 'Must be greater than zero'); } final updatedRows = await database.customUpdate( 'UPDATE products ' 'SET stock_quantity = stock_quantity + ? ' 'WHERE id = ?', variables: [Variable.withInt(amount), Variable.withString(productId)], updates: {database.products}, ); if (updatedRows == 1) return; throw Exception('Product not found'); } @override Future> getOpenTabs({bool includeRemoved = false}) async { final query = database.select(database.barTabs) ..where((tab) => tab.status.equals('open')) ..orderBy([(tab) => OrderingTerm.desc(tab.openedAt)]); final tabRows = await query.get(); final tabs = []; for (final tabRow in tabRows) { final items = includeRemoved ? await _getItemsForHistory(tabRow.id) : await _getItemsForTab(tabRow.id); tabs.add(_mapTabRow(tabRow, items)); } return tabs; } @override Future getTabById(String id) async { final query = database.select(database.barTabs) ..where((tab) => tab.id.equals(id)); final tabRow = await query.getSingleOrNull(); if (tabRow == null) { return null; } final items = await _getItemsForTab(tabRow.id); return _mapTabRow(tabRow, items); } @override Future createTab({required String customerName}) async { final id = _uuid.v4(); await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: id, customerName: customerName, status: const Value('open'), openedAt: DateTime.now(), ), ); final tab = await getTabById(id); if (tab == null) { throw Exception('Could not create tab.'); } return tab; } @override Future renameTab({ required String tabId, required String customerName, }) async { final name = customerName.trim(); if (name.isEmpty) { throw ArgumentError.value( customerName, 'customerName', 'Must not be empty', ); } final updatedRows = await (database.update(database.barTabs) ..where((tab) => tab.id.equals(tabId))) .write(BarTabsCompanion(customerName: Value(name))); if (updatedRows != 1) { throw StateError('Tab not found.'); } } @override Future> deleteTab(String tabId) async { return database.transaction(() async { final tab = await (database.select( database.barTabs, )..where((row) => row.id.equals(tabId))).getSingleOrNull(); if (tab == null) return []; final items = await _getItemsForTab(tabId); for (final item in items) { await _increaseProductStock(item.productId, item.quantity); } await (database.delete( database.tabItems, )..where((item) => item.tabId.equals(tabId))).go(); final deletedRows = await (database.delete( database.barTabs, )..where((row) => row.id.equals(tabId))).go(); if (deletedRows != 1) { throw StateError('Tab was changed before it could be deleted'); } return items; }); } @override Future addProductToTab({ required String tabId, required Product product, }) async { await database.transaction(() async { await _decreaseProductStock(product.id, 1); final existingItemQuery = database.select(database.tabItems) ..where( (item) => item.tabId.equals(tabId) & item.productId.equals(product.id), ); final existingItem = await existingItemQuery.getSingleOrNull(); if (existingItem != null) { final updateQuery = database.update(database.tabItems) ..where((item) => item.id.equals(existingItem.id)); await updateQuery.write( TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); await _recordPurchase(tabItemId: existingItem.id); return; } 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: tabItemId); }); } @override Future adjustTabItemQuantity({ required String tabItemId, required int delta, }) async { return database.transaction(() async { final item = await (database.select( database.tabItems, )..where((row) => row.id.equals(tabItemId))).getSingleOrNull(); if (item == null || delta == 0 || (item.quantity == 0 && delta < 0)) { return 0; } final requestedQuantity = item.quantity + delta; final actualDelta = requestedQuantity <= 0 ? -item.quantity : delta; if (actualDelta > 0) { await _decreaseProductStock(item.productId, actualDelta); for (var index = 0; index < actualDelta; index++) { await _recordPurchase(tabItemId: item.id); } } else { await _increaseProductStock(item.productId, -actualDelta); final purchaseRows = await _ensurePurchaseRows(item); final activePurchaseRows = purchaseRows .where((purchase) => purchase.removedAt == null) .toList() ..sort((a, b) => b.purchasedAt.compareTo(a.purchasedAt)); final removedAt = DateTime.now(); for (final purchase in activePurchaseRows.take(-actualDelta)) { await (database.update(database.tabItemPurchases) ..where((row) => row.id.equals(purchase.id))) .write(TabItemPurchasesCompanion(removedAt: Value(removedAt))); } } if (requestedQuantity <= 0) { final updatedRows = await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(const TabItemsCompanion(quantity: Value(0))); if (updatedRows != 1) { throw StateError( 'Tab item was changed before its quantity was updated', ); } } else { final updatedRows = await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(requestedQuantity))); if (updatedRows != 1) { throw StateError( 'Tab item was changed before its quantity was updated', ); } } return actualDelta; }); } @override Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, }) async { await database.transaction(() async { final tabQuery = database.select(database.barTabs) ..where((tab) => tab.id.equals(tabId)); final tabRow = await tabQuery.getSingleOrNull(); if (tabRow == null) { return; } final items = await _getItemsForHistory(tabId); 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.value), ), ); for (final item in items) { for (final purchase in item.purchases) { await database .into(database.closedTabItems) .insert( 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), ), ); } } final deleteQuery = database.delete(database.tabItems) ..where((item) => item.tabId.equals(tabId)); await deleteQuery.go(); }); } @override Future> getClosedTabs() async { final count = await getClosedTabCount(); if (count == 0) return []; return getClosedTabsPaginated(limit: count, offset: 0); } @override Future> getClosedTabsPaginated({ int limit = 20, int offset = 0, String? customerName, }) async { var query = database.select(database.closedTabs) ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]) ..limit(limit, offset: offset); if (customerName != null && customerName.isNotEmpty) { query = query..where((tab) => tab.customerName.equals(customerName)); } final closedTabRows = await query.get(); final closedTabs = []; for (final row in closedTabRows) { final itemsQuery = database.select(database.closedTabItems) ..where((item) => item.closedTabId.equals(row.id)) ..orderBy([ (item) => OrderingTerm.desc(item.purchasedAt), (item) => OrderingTerm.desc(item.id), ]); final itemRows = await itemsQuery.get(); closedTabs.add( ClosedTab( id: row.id, originalTabId: row.originalTabId, customerName: row.customerName, closedAt: row.closedAt, paymentMethod: PaymentMethod.fromValue(row.paymentMethod), items: itemRows.map(_mapClosedItemRow).toList(), ), ); } return closedTabs; } @override Future getClosedTabCount({String? customerName}) async { final countExpr = database.closedTabs.id.count(); var query = database.selectOnly(database.closedTabs) ..addColumns([countExpr]); if (customerName != null && customerName.isNotEmpty) { query = query ..where(database.closedTabs.customerName.equals(customerName)); } final row = await query.getSingle(); return row.read(countExpr) ?? 0; } @override Future> getDistinctCustomerNames() async { final rows = await database.select(database.closedTabs).get(); return rows.map((row) => row.customerName).toSet().toList()..sort(); } }