Files
kooltab/lib/services/bar_tab_service.dart

622 lines
17 KiB
Dart

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<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<List<TabItem>> deleteTab(String tabId);
Future<void> addProductToTab({
required String tabId,
required Product product,
});
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.
Future<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
});
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,
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<List<TabItem>> _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 = <TabItem>[];
for (final row in rows) {
if (row.quantity <= 0) continue;
final purchases = await _getPurchaseRecords(row);
items.add(_mapItemRow(row, purchases));
}
return items;
}
Future<List<TabItem>> _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 = <TabItem>[];
for (final row in rows) {
final purchases = await _getPurchaseRecords(row);
if (purchases.isEmpty) continue;
items.add(_mapItemRow(row, purchases));
}
return items;
}
Future<List<TabItemPurchaseRow>> _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<List<TabItemPurchase>> _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<List<TabItemPurchaseRow>> _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<void> _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<void> _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<void> _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<List<BarTab>> 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 = <BarTab>[];
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<BarTab?> 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<BarTab> 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<void> 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<List<TabItem>> 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 <TabItem>[];
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<void> 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<int> 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<void> 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<List<ClosedTab>> getClosedTabs() async {
final count = await getClosedTabCount();
if (count == 0) return [];
return getClosedTabsPaginated(limit: count, offset: 0);
}
@override
Future<List<ClosedTab>> 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 = <ClosedTab>[];
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<ClosedTabItem>(_mapClosedItemRow).toList(),
),
);
}
return closedTabs;
}
@override
Future<int> 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<List<String>> getDistinctCustomerNames() async {
final rows = await database.select(database.closedTabs).get();
return rows.map((row) => row.customerName).toSet().toList()..sort();
}
}