622 lines
17 KiB
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();
|
|
}
|
|
}
|