Files
kooltab/lib/services/bar_tab_service.dart
T
2026-10-08 04:12:36 +02:00

588 lines
17 KiB
Dart

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<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});
Future<void> renameTab({required String tabId, required String customerName});
/// Deletes an open tab and returns its items to inventory.
Future<void> deleteTab(String tabId);
Future<void> 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<int> 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<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
int? expectedVersion,
});
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();
}
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<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,
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<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));
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<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();
}
Future<void> _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<void> _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<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();
}
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),
);
}
@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}) 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<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 _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) {
await database.increaseStock(item.productId, item.quantity);
}
await _deleteItemsOfTab(tabId);
await (database.delete(
database.barTabs,
)..where((row) => row.id.equals(tabId))).go();
});
}
@override
Future<void> 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<int> 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<void> 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<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),
(item) => OrderingTerm.desc(item.id),
]))
.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();
}
}