feat: make server owner of tabs
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
@@ -11,6 +12,7 @@ import '../models/closed_tab.dart';
|
||||
import '../models/closed_tab_item.dart';
|
||||
import '../models/payment_method.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/server_tab.dart';
|
||||
import '../models/tab_event.dart';
|
||||
import '../models/tab_item.dart';
|
||||
import '../models/tab_item_purchase.dart';
|
||||
@@ -91,6 +93,16 @@ abstract class BarTabService {
|
||||
Future<int> getClosedTabCount({String? customerName});
|
||||
|
||||
Future<List<String>> getDistinctCustomerNames();
|
||||
|
||||
/// Replaces the open tabs with the server's [tabs], the venue's source of
|
||||
/// truth, and replays the changes the server hasn't applied yet (the
|
||||
/// events still in the outbox) on top. Stock is left alone: the local
|
||||
/// changes already counted it.
|
||||
Future<void> applyServerState(List<ServerTab> tabs);
|
||||
|
||||
/// Adds settlements made anywhere in the venue to the history. Settlements
|
||||
/// already in it (such as this tablet's own) are skipped.
|
||||
Future<void> recordSettlements(List<ServerSettlement> settlements);
|
||||
}
|
||||
|
||||
class DriftBarTabService implements BarTabService {
|
||||
@@ -226,7 +238,11 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
/// Marks the most recent active purchases as removed, keeping them in the
|
||||
/// audit trail.
|
||||
Future<void> _removeLatestPurchases(String tabItemId, int count) async {
|
||||
Future<void> _removeLatestPurchases(
|
||||
String tabItemId,
|
||||
int count, {
|
||||
DateTime? removedAt,
|
||||
}) async {
|
||||
final latest =
|
||||
await (database.select(database.tabItemPurchases)
|
||||
..where(
|
||||
@@ -243,7 +259,18 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
await (database.update(database.tabItemPurchases)
|
||||
..where((purchase) => purchase.id.isIn(latest.map((row) => row.id))))
|
||||
.write(TabItemPurchasesCompanion(removedAt: Value(DateTime.now())));
|
||||
.write(
|
||||
TabItemPurchasesCompanion(
|
||||
removedAt: Value(removedAt ?? DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteTabWithItems(String tabId) async {
|
||||
await _deleteItemsOfTab(tabId);
|
||||
await (database.delete(
|
||||
database.barTabs,
|
||||
)..where((row) => row.id.equals(tabId))).go();
|
||||
}
|
||||
|
||||
/// Foreign keys aren't enforced, so purchases are deleted explicitly.
|
||||
@@ -276,6 +303,36 @@ class DriftBarTabService implements BarTabService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0)
|
||||
/// event and moves the stock of [productId], the product the line was
|
||||
/// created from, the other way. Every line event goes through here, so a
|
||||
/// line event and its stock change can never drift apart.
|
||||
Future<void> _recordLineChange({
|
||||
required String tabId,
|
||||
required String customerName,
|
||||
required String lineId,
|
||||
required String productId,
|
||||
required String productName,
|
||||
required int unitPriceInCents,
|
||||
required int delta,
|
||||
}) async {
|
||||
final lineEvent = delta > 0
|
||||
? eventFactory.lineAdded
|
||||
: eventFactory.lineRemoved;
|
||||
await _recordEvent(
|
||||
lineEvent(
|
||||
tabId: tabId,
|
||||
customerName: customerName,
|
||||
lineId: lineId,
|
||||
productId: productId,
|
||||
productName: productName,
|
||||
unitPriceInCents: unitPriceInCents,
|
||||
quantity: delta.abs(),
|
||||
),
|
||||
);
|
||||
await database.changeTrackedStock(productId, -delta);
|
||||
}
|
||||
|
||||
Future<void> _incrementTabVersion(String tabId) async {
|
||||
await (database.update(
|
||||
database.barTabs,
|
||||
@@ -300,9 +357,11 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
@override
|
||||
Stream<List<BarTab>> watchOpenTabs() {
|
||||
return _tabsQuery().watch().map(
|
||||
(rows) => _groupTabs(rows, includeRemoved: false),
|
||||
);
|
||||
return _tabsQuery()
|
||||
.watch()
|
||||
.map((rows) => _groupTabs(rows, includeRemoved: false))
|
||||
// A sync rewrites the tabs even when nothing changed
|
||||
.distinct(listEquals);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -320,17 +379,28 @@ class DriftBarTabService implements BarTabService {
|
||||
@override
|
||||
Future<BarTab> createTab({required String customerName}) async {
|
||||
final id = _uuid.v4();
|
||||
final name = _validateCustomerName(customerName);
|
||||
final openedAt = DateTime.now();
|
||||
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: id,
|
||||
customerName: _validateCustomerName(customerName),
|
||||
openedAt: DateTime.now(),
|
||||
version: const Value(1),
|
||||
),
|
||||
);
|
||||
await database.transaction(() async {
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: id,
|
||||
customerName: name,
|
||||
openedAt: openedAt,
|
||||
version: const Value(1),
|
||||
),
|
||||
);
|
||||
await _recordEvent(
|
||||
eventFactory.tabOpened(
|
||||
tabId: id,
|
||||
customerName: name,
|
||||
openedAt: openedAt,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return (await getTabById(id))!;
|
||||
}
|
||||
@@ -350,6 +420,9 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
if (updatedRows != 1) throw TabNotFoundException(tabId);
|
||||
|
||||
await _recordEvent(
|
||||
eventFactory.tabRenamed(tabId: tabId, customerName: name),
|
||||
);
|
||||
await _incrementTabVersion(tabId);
|
||||
});
|
||||
}
|
||||
@@ -361,22 +434,21 @@ class DriftBarTabService implements BarTabService {
|
||||
if (tab == null) return;
|
||||
|
||||
for (final item in tab.items) {
|
||||
await database.increaseStock(item.productId, item.quantity);
|
||||
await _recordEvent(
|
||||
eventFactory.lineRemoved(
|
||||
tabId: tab.id,
|
||||
customerName: tab.customerName,
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
quantity: item.quantity,
|
||||
),
|
||||
await _recordLineChange(
|
||||
tabId: tab.id,
|
||||
customerName: tab.customerName,
|
||||
lineId: item.id,
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
delta: -item.quantity,
|
||||
);
|
||||
}
|
||||
await _recordEvent(
|
||||
eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName),
|
||||
);
|
||||
|
||||
await _deleteItemsOfTab(tabId);
|
||||
await (database.delete(
|
||||
database.barTabs,
|
||||
)..where((row) => row.id.equals(tabId))).go();
|
||||
await _deleteTabWithItems(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,14 +461,17 @@ class DriftBarTabService implements BarTabService {
|
||||
final tabRow = await _getTabRow(tabId);
|
||||
if (tabRow == null) throw TabNotFoundException(tabId);
|
||||
|
||||
await database.decreaseStock(product.id, 1);
|
||||
|
||||
// A merge on the server can leave two lines of one product at
|
||||
// different prices; new drinks go on the oldest
|
||||
final existingItem =
|
||||
await (database.select(database.tabItems)..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabId) &
|
||||
item.productId.equals(product.id),
|
||||
))
|
||||
await (database.select(database.tabItems)
|
||||
..where(
|
||||
(item) =>
|
||||
item.tabId.equals(tabId) &
|
||||
item.productId.equals(product.id),
|
||||
)
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (existingItem != null) {
|
||||
@@ -407,14 +482,14 @@ class DriftBarTabService implements BarTabService {
|
||||
);
|
||||
await _recordPurchase(existingItem.id);
|
||||
// The line keeps the price it was first added at
|
||||
await _recordEvent(
|
||||
eventFactory.lineAdded(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
productName: existingItem.productName,
|
||||
unitPriceInCents: existingItem.unitPriceInCents,
|
||||
quantity: 1,
|
||||
),
|
||||
await _recordLineChange(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: existingItem.id,
|
||||
productId: existingItem.productId,
|
||||
productName: existingItem.productName,
|
||||
unitPriceInCents: existingItem.unitPriceInCents,
|
||||
delta: 1,
|
||||
);
|
||||
} else {
|
||||
final tabItemId = _uuid.v4();
|
||||
@@ -433,14 +508,14 @@ class DriftBarTabService implements BarTabService {
|
||||
),
|
||||
);
|
||||
await _recordPurchase(tabItemId);
|
||||
await _recordEvent(
|
||||
eventFactory.lineAdded(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
quantity: 1,
|
||||
),
|
||||
await _recordLineChange(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
lineId: tabItemId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
delta: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,12 +543,10 @@ class DriftBarTabService implements BarTabService {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -482,17 +555,14 @@ class DriftBarTabService implements BarTabService {
|
||||
.write(TabItemsCompanion(quantity: Value(newQuantity)));
|
||||
|
||||
final tabRow = await _getTabRow(item.tabId);
|
||||
final lineEvent = actualDelta > 0
|
||||
? eventFactory.lineAdded
|
||||
: eventFactory.lineRemoved;
|
||||
await _recordEvent(
|
||||
lineEvent(
|
||||
tabId: item.tabId,
|
||||
customerName: tabRow?.customerName ?? '',
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
quantity: actualDelta.abs(),
|
||||
),
|
||||
await _recordLineChange(
|
||||
tabId: item.tabId,
|
||||
customerName: tabRow?.customerName ?? '',
|
||||
lineId: item.id,
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
delta: actualDelta,
|
||||
);
|
||||
|
||||
await _incrementTabVersion(item.tabId);
|
||||
@@ -524,7 +594,36 @@ class DriftBarTabService implements BarTabService {
|
||||
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final closedTabId = _uuid.v4();
|
||||
// One settlement, not a removal per line: these drinks were sold
|
||||
final settledItems = [
|
||||
for (final item in items)
|
||||
if (item.quantity > 0) item,
|
||||
];
|
||||
final settledEvent = settledItems.isEmpty
|
||||
? null
|
||||
: eventFactory.settled(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
amountInCents: settledItems.fold(
|
||||
0,
|
||||
(total, item) => total + item.lineTotalInCents,
|
||||
),
|
||||
paymentMethod: paymentMethod,
|
||||
commandId: commandId,
|
||||
items: [
|
||||
for (final item in settledItems)
|
||||
(
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// The server reports this settlement back under the event's id, so it
|
||||
// isn't added to history twice
|
||||
final closedTabId = settledEvent?.id ?? _uuid.v4();
|
||||
|
||||
await database
|
||||
.into(database.closedTabs)
|
||||
@@ -555,33 +654,7 @@ class DriftBarTabService implements BarTabService {
|
||||
]);
|
||||
});
|
||||
|
||||
// One settlement, not a removal per line: these drinks were sold
|
||||
final settledItems = [
|
||||
for (final item in items)
|
||||
if (item.quantity > 0) item,
|
||||
];
|
||||
if (settledItems.isNotEmpty) {
|
||||
await _recordEvent(
|
||||
eventFactory.settled(
|
||||
tabId: tabId,
|
||||
customerName: tabRow.customerName,
|
||||
amountInCents: settledItems.fold(
|
||||
0,
|
||||
(total, item) => total + item.lineTotalInCents,
|
||||
),
|
||||
paymentMethod: paymentMethod,
|
||||
commandId: commandId,
|
||||
items: [
|
||||
for (final item in settledItems)
|
||||
(
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (settledEvent != null) await _recordEvent(settledEvent);
|
||||
|
||||
await _deleteItemsOfTab(tabId);
|
||||
await _incrementTabVersion(tabId);
|
||||
@@ -679,4 +752,319 @@ class DriftBarTabService implements BarTabService {
|
||||
final rows = await query.get();
|
||||
return rows.map((row) => row.read(customerName)!).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyServerState(List<ServerTab> tabs) async {
|
||||
await database.transaction(() async {
|
||||
for (final tab in await database.select(database.barTabs).get()) {
|
||||
await _deleteTabWithItems(tab.id);
|
||||
}
|
||||
|
||||
for (final tab in tabs) {
|
||||
await _insertServerTab(tab);
|
||||
}
|
||||
|
||||
// Read here, so changes made while the sync was in flight are included
|
||||
final outbox =
|
||||
await (database.select(database.tabEvents)..orderBy([
|
||||
(row) => OrderingTerm.asc(row.createdAt),
|
||||
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
|
||||
]))
|
||||
.get();
|
||||
for (final row in outbox) {
|
||||
await _replayEvent(jsonDecode(row.payload) as Map<String, dynamic>);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _insertServerTab(ServerTab tab) async {
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
openedAt: tab.openedAt,
|
||||
version: Value(tab.version),
|
||||
),
|
||||
);
|
||||
|
||||
for (final line in tab.lines) {
|
||||
final createdAt = line.units.isEmpty
|
||||
? tab.openedAt
|
||||
: line.units
|
||||
.map((unit) => unit.addedAt)
|
||||
.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
|
||||
await database
|
||||
.into(database.tabItems)
|
||||
.insert(
|
||||
TabItemsCompanion.insert(
|
||||
id: line.id,
|
||||
tabId: tab.id,
|
||||
productId: line.productId,
|
||||
productName: line.productName,
|
||||
quantity: line.quantity,
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
);
|
||||
await database.batch((batch) {
|
||||
batch.insertAll(database.tabItemPurchases, [
|
||||
for (final unit in line.units)
|
||||
TabItemPurchasesCompanion.insert(
|
||||
id: unit.id,
|
||||
tabItemId: line.id,
|
||||
purchasedAt: unit.addedAt,
|
||||
removedAt: Value(unit.removedAt),
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an outbox event to the local tables the way the server will:
|
||||
/// without writing events and without touching stock. Unknown types and
|
||||
/// events about tabs that are gone are skipped.
|
||||
Future<void> _replayEvent(Map<String, dynamic> event) async {
|
||||
final tabId = event['tabId'] as String?;
|
||||
if (tabId == null) return;
|
||||
|
||||
final customerName = event['customerName'] as String? ?? '';
|
||||
final occurredAt =
|
||||
DateTime.tryParse(event['occurredAt'] as String? ?? '')?.toLocal() ??
|
||||
DateTime.now();
|
||||
|
||||
switch (TabEventType.fromValue(event['type'] as String? ?? '')) {
|
||||
case TabEventType.tabOpened:
|
||||
final openedAt = DateTime.tryParse(
|
||||
event['openedAt'] as String? ?? '',
|
||||
)?.toLocal();
|
||||
await _ensureTab(tabId, customerName, openedAt ?? occurredAt);
|
||||
case TabEventType.tabRenamed:
|
||||
await (database.update(database.barTabs)
|
||||
..where((tab) => tab.id.equals(tabId)))
|
||||
.write(BarTabsCompanion(customerName: Value(customerName)));
|
||||
case TabEventType.tabDeleted:
|
||||
await _deleteTabWithItems(tabId);
|
||||
case TabEventType.lineAdded:
|
||||
await _replayLineAdded(event, tabId, customerName, occurredAt);
|
||||
case TabEventType.lineRemoved:
|
||||
final line = await _findLine(tabId, event);
|
||||
final quantity = event['quantity'] as int? ?? 0;
|
||||
if (line == null || quantity <= 0) return;
|
||||
|
||||
final removed = min(quantity, line.quantity);
|
||||
await _setLineQuantity(line.id, line.quantity - removed);
|
||||
await _removeLatestPurchases(line.id, removed, removedAt: occurredAt);
|
||||
case TabEventType.settled:
|
||||
for (final item in event['items'] as List<dynamic>? ?? []) {
|
||||
await _replaySettledLine(tabId, item as Map<String, dynamic>);
|
||||
}
|
||||
// Stock is rebased separately, see StockQueries.rebaseStock
|
||||
case TabEventType.stockSet:
|
||||
case null:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// A drink put on a tab that was deleted elsewhere brings the tab back, so
|
||||
/// no sale is lost.
|
||||
Future<void> _replayLineAdded(
|
||||
Map<String, dynamic> event,
|
||||
String tabId,
|
||||
String customerName,
|
||||
DateTime occurredAt,
|
||||
) async {
|
||||
final quantity = event['quantity'] as int? ?? 0;
|
||||
if (quantity <= 0) return;
|
||||
|
||||
await _ensureTab(tabId, customerName, occurredAt);
|
||||
|
||||
var line = await _findLine(tabId, event);
|
||||
if (line == null) {
|
||||
final lineId = event['lineId'] as String?;
|
||||
final idTaken =
|
||||
lineId == null ||
|
||||
await (database.select(
|
||||
database.tabItems,
|
||||
)..where((item) => item.id.equals(lineId))).getSingleOrNull() !=
|
||||
null;
|
||||
|
||||
line = TabItemRow(
|
||||
id: idTaken ? _uuid.v4() : lineId,
|
||||
tabId: tabId,
|
||||
productId: event['productId'] as String? ?? '',
|
||||
productName: event['productName'] as String? ?? '',
|
||||
quantity: 0,
|
||||
unitPriceInCents: event['unitPriceInCents'] as int? ?? 0,
|
||||
createdAt: occurredAt,
|
||||
);
|
||||
await database.into(database.tabItems).insert(line);
|
||||
}
|
||||
|
||||
await _setLineQuantity(line.id, line.quantity + quantity);
|
||||
await database.batch((batch) {
|
||||
batch.insertAll(database.tabItemPurchases, [
|
||||
for (var index = 0; index < quantity; index++)
|
||||
TabItemPurchasesCompanion.insert(
|
||||
// The same id the server gives this unit
|
||||
id: '${event['id']}:$index',
|
||||
tabItemId: line!.id,
|
||||
purchasedAt: occurredAt,
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/// Takes the settled pieces off the line, oldest first: drinks added
|
||||
/// elsewhere after the settlement stay on the tab.
|
||||
Future<void> _replaySettledLine(
|
||||
String tabId,
|
||||
Map<String, dynamic> item,
|
||||
) async {
|
||||
final line = await _findLine(tabId, item);
|
||||
final quantity = item['quantity'] as int? ?? 0;
|
||||
if (line == null || quantity <= 0) return;
|
||||
|
||||
final settled = min(quantity, line.quantity);
|
||||
final oldest =
|
||||
await (database.select(database.tabItemPurchases)
|
||||
..where(
|
||||
(purchase) =>
|
||||
purchase.tabItemId.equals(line.id) &
|
||||
purchase.removedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(purchase) => OrderingTerm.asc(purchase.purchasedAt),
|
||||
(purchase) => OrderingTerm.asc(purchase.id),
|
||||
])
|
||||
..limit(settled))
|
||||
.get();
|
||||
|
||||
// Undone drinks went into the history with the settlement
|
||||
await (database.delete(database.tabItemPurchases)..where(
|
||||
(purchase) =>
|
||||
purchase.id.isIn(oldest.map((row) => row.id)) |
|
||||
(purchase.tabItemId.equals(line.id) &
|
||||
purchase.removedAt.isNotNull()),
|
||||
))
|
||||
.go();
|
||||
|
||||
if (line.quantity == settled) {
|
||||
await (database.delete(
|
||||
database.tabItems,
|
||||
)..where((row) => row.id.equals(line.id))).go();
|
||||
} else {
|
||||
await _setLineQuantity(line.id, line.quantity - settled);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureTab(
|
||||
String tabId,
|
||||
String customerName,
|
||||
DateTime openedAt,
|
||||
) async {
|
||||
await database
|
||||
.into(database.barTabs)
|
||||
.insert(
|
||||
BarTabsCompanion.insert(
|
||||
id: tabId,
|
||||
customerName: customerName,
|
||||
openedAt: openedAt,
|
||||
version: const Value(1),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
/// The line [event] is about: the same line id, else the same product at
|
||||
/// the same price, else the same name at the same price.
|
||||
Future<TabItemRow?> _findLine(
|
||||
String tabId,
|
||||
Map<String, dynamic> event,
|
||||
) async {
|
||||
final lineId = event['lineId'] as String?;
|
||||
final productId = event['productId'] as String?;
|
||||
final productName = event['productName'] as String?;
|
||||
final price = event['unitPriceInCents'] as int?;
|
||||
|
||||
Future<TabItemRow?> first(Expression<bool> Function($TabItemsTable) where) {
|
||||
return (database.select(database.tabItems)
|
||||
..where((item) => item.tabId.equals(tabId) & where(item))
|
||||
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
if (lineId != null) {
|
||||
final line = await first((item) => item.id.equals(lineId));
|
||||
if (line != null) return line;
|
||||
}
|
||||
if (price == null) return null;
|
||||
if (productId != null && productId.isNotEmpty) {
|
||||
final line = await first(
|
||||
(item) =>
|
||||
item.productId.equals(productId) &
|
||||
item.unitPriceInCents.equals(price),
|
||||
);
|
||||
if (line != null) return line;
|
||||
}
|
||||
if (productName == null) return null;
|
||||
return first(
|
||||
(item) =>
|
||||
item.productName.equals(productName) &
|
||||
item.unitPriceInCents.equals(price),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setLineQuantity(String lineId, int quantity) async {
|
||||
await (database.update(database.tabItems)
|
||||
..where((row) => row.id.equals(lineId)))
|
||||
.write(TabItemsCompanion(quantity: Value(quantity)));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> recordSettlements(List<ServerSettlement> settlements) async {
|
||||
if (settlements.isEmpty) return;
|
||||
|
||||
await database.transaction(() async {
|
||||
for (final settlement in settlements) {
|
||||
final known = await (database.select(
|
||||
database.closedTabs,
|
||||
)..where((row) => row.id.equals(settlement.id))).getSingleOrNull();
|
||||
// Already in the history, e.g. settled on this tablet
|
||||
if (known != null) continue;
|
||||
|
||||
await database
|
||||
.into(database.closedTabs)
|
||||
.insert(
|
||||
ClosedTabsCompanion.insert(
|
||||
id: settlement.id,
|
||||
originalTabId: settlement.tabId,
|
||||
customerName: settlement.customerName,
|
||||
closedAt: settlement.settledAt,
|
||||
paymentMethod: Value(settlement.paymentMethod),
|
||||
),
|
||||
);
|
||||
|
||||
await database.batch((batch) {
|
||||
batch.insertAll(database.closedTabItems, [
|
||||
for (final line in settlement.lines)
|
||||
for (final unit in line.units)
|
||||
ClosedTabItemsCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
closedTabId: settlement.id,
|
||||
productId: line.productId,
|
||||
productName: line.productName,
|
||||
quantity: 1,
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
purchasedAt: Value(unit.addedAt),
|
||||
removedAt: Value(unit.removedAt),
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/tab_event.dart';
|
||||
|
||||
class DefaultProductSeeder {
|
||||
final AppDatabase database;
|
||||
@@ -46,6 +48,7 @@ class DefaultProductSeeder {
|
||||
|
||||
await database.transaction(() async {
|
||||
final uuid = const Uuid();
|
||||
final events = TabEventFactory();
|
||||
|
||||
for (final (name, priceInCents, stock, category) in _defaultProducts) {
|
||||
String? imagePath;
|
||||
@@ -54,11 +57,12 @@ class DefaultProductSeeder {
|
||||
imagePath = await _copyBundledImage(name, imagesDir);
|
||||
}
|
||||
|
||||
final id = uuid.v4();
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: uuid.v4(),
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
stockQuantity: stock,
|
||||
@@ -67,6 +71,9 @@ class DefaultProductSeeder {
|
||||
imagePath: Value(imagePath),
|
||||
),
|
||||
);
|
||||
await database.queueStockSet(
|
||||
events.stockSet(productId: id, quantity: stock),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e, stack) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import 'package:uuid/uuid.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/tab_event.dart';
|
||||
|
||||
export '../database/stock_queries.dart'
|
||||
show InsufficientStockException, ProductNotFoundException;
|
||||
export '../database/stock_queries.dart' show ProductNotFoundException;
|
||||
|
||||
abstract class ProductService {
|
||||
/// Active products, re-emitted whenever the products table changes.
|
||||
@@ -37,9 +37,11 @@ abstract class ProductService {
|
||||
|
||||
class DriftProductService implements ProductService {
|
||||
final AppDatabase database;
|
||||
final TabEventFactory eventFactory;
|
||||
final _uuid = const Uuid();
|
||||
|
||||
DriftProductService({required this.database});
|
||||
DriftProductService({required this.database, TabEventFactory? eventFactory})
|
||||
: eventFactory = eventFactory ?? TabEventFactory();
|
||||
|
||||
Product _mapRowToProduct(ProductRow row) {
|
||||
return Product(
|
||||
@@ -95,39 +97,69 @@ class DriftProductService implements ProductService {
|
||||
required int priceInCents,
|
||||
required String? imagePath,
|
||||
}) async {
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
name: name,
|
||||
category: category,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: Value(infiniteStock),
|
||||
priceInCents: priceInCents,
|
||||
imagePath: Value(imagePath),
|
||||
),
|
||||
final id = _uuid.v4();
|
||||
|
||||
await database.transaction(() async {
|
||||
await database
|
||||
.into(database.products)
|
||||
.insert(
|
||||
ProductsCompanion.insert(
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
stockQuantity: stockQuantity,
|
||||
lowStockThreshold: lowStockThreshold,
|
||||
infiniteStock: Value(infiniteStock),
|
||||
priceInCents: priceInCents,
|
||||
imagePath: Value(imagePath),
|
||||
),
|
||||
);
|
||||
// The server starts a product it doesn't know as untracked
|
||||
if (!infiniteStock) {
|
||||
await database.queueStockSet(
|
||||
eventFactory.stockSet(productId: id, quantity: stockQuantity),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A changed stock or tracking is queued for the server as a count.
|
||||
@override
|
||||
Future<void> updateProduct(Product product) async {
|
||||
final query = database.update(database.products)
|
||||
..where((row) => row.id.equals(product.id));
|
||||
await database.transaction(() async {
|
||||
final current = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(product.id))).getSingleOrNull();
|
||||
|
||||
await query.write(
|
||||
ProductsCompanion(
|
||||
name: Value(product.name),
|
||||
category: Value(product.category),
|
||||
stockQuantity: Value(product.stockQuantity),
|
||||
lowStockThreshold: Value(product.lowStockThreshold),
|
||||
infiniteStock: Value(product.infiniteStock),
|
||||
priceInCents: Value(product.priceInCents),
|
||||
imagePath: Value(product.imagePath),
|
||||
active: Value(product.active),
|
||||
),
|
||||
);
|
||||
await (database.update(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(product.id))).write(
|
||||
ProductsCompanion(
|
||||
name: Value(product.name),
|
||||
category: Value(product.category),
|
||||
stockQuantity: Value(product.stockQuantity),
|
||||
lowStockThreshold: Value(product.lowStockThreshold),
|
||||
infiniteStock: Value(product.infiniteStock),
|
||||
priceInCents: Value(product.priceInCents),
|
||||
imagePath: Value(product.imagePath),
|
||||
active: Value(product.active),
|
||||
),
|
||||
);
|
||||
|
||||
if (current == null) return;
|
||||
final stockChanged = product.infiniteStock
|
||||
? !current.infiniteStock
|
||||
: current.infiniteStock ||
|
||||
current.stockQuantity != product.stockQuantity;
|
||||
if (stockChanged) {
|
||||
await database.queueStockSet(
|
||||
eventFactory.stockSet(
|
||||
productId: product.id,
|
||||
quantity: product.infiniteStock ? null : product.stockQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Only hidden, since open tabs and history still refer to it.
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../database/stock_queries.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/payment_method.dart';
|
||||
import '../models/remote_command.dart';
|
||||
@@ -49,6 +50,13 @@ class RemoteSyncService {
|
||||
/// Most outbox events sent per sync; a larger backlog drains over syncs.
|
||||
static const maxEventsPerSync = 500;
|
||||
|
||||
/// Most stock adjustment outcomes sent per sync.
|
||||
static const maxStockResultsPerSync = 500;
|
||||
|
||||
/// How long a handled stock adjustment is remembered after the server
|
||||
/// acknowledged its outcome.
|
||||
static const stockResultRetention = Duration(days: 30);
|
||||
|
||||
static const _pollingInterval = Duration(
|
||||
seconds: kSyncPollingIntervalSeconds,
|
||||
);
|
||||
@@ -66,7 +74,7 @@ class RemoteSyncService {
|
||||
bool _paused = false;
|
||||
int _consecutiveFailures = 0;
|
||||
|
||||
StreamSubscription<List<BarTab>>? _tabChanges;
|
||||
StreamSubscription<Set<String>>? _tabChanges;
|
||||
Timer? _changeTimer;
|
||||
DateTime? _firstPendingChange;
|
||||
bool _syncQueued = false;
|
||||
@@ -98,11 +106,8 @@ class RemoteSyncService {
|
||||
|
||||
void startPeriodicSync() {
|
||||
_isRunning = true;
|
||||
// The first emission is the current state, not a change
|
||||
_tabChanges ??= barTabService
|
||||
.watchOpenTabs()
|
||||
.skip(1)
|
||||
.listen((_) => _onTabsChanged());
|
||||
unawaited(pruneStockAdjustmentResults());
|
||||
_tabChanges ??= _watchNewOutboxEvents();
|
||||
_scheduleNext(_pollingInterval);
|
||||
}
|
||||
|
||||
@@ -126,6 +131,27 @@ class RemoteSyncService {
|
||||
_paused = false;
|
||||
}
|
||||
|
||||
/// Every local tab change queues an event, so a new event in the outbox
|
||||
/// means a change to push. Watching the tabs instead would also fire on
|
||||
/// the server state a sync writes, and sync again forever.
|
||||
StreamSubscription<Set<String>> _watchNewOutboxEvents() {
|
||||
Set<String>? known;
|
||||
|
||||
final ids = database.selectOnly(database.tabEvents)
|
||||
..addColumns([database.tabEvents.id]);
|
||||
|
||||
return ids
|
||||
.map((row) => row.read(database.tabEvents.id)!)
|
||||
.watch()
|
||||
.map((rows) => rows.toSet())
|
||||
.listen((current) {
|
||||
// The first emission is the current state, not a change
|
||||
final isNew = known != null && !current.every(known!.contains);
|
||||
known = current;
|
||||
if (isNew) _onTabsChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// Pushes tab changes to the server soon after they happen instead of
|
||||
/// waiting for the next poll. A burst of changes (several drinks rung up in
|
||||
/// a row) becomes one sync, sent once things are quiet for [changeDebounce]
|
||||
@@ -183,9 +209,11 @@ class RemoteSyncService {
|
||||
Duration nextDelay;
|
||||
|
||||
try {
|
||||
await _doSync(credentials);
|
||||
final appliedStock = await _doSync(credentials);
|
||||
_consecutiveFailures = 0;
|
||||
nextDelay = _pollingInterval;
|
||||
// Show the new stock on the dashboard within seconds. The follow-up
|
||||
// skips the adjustments it already applied, so this doesn't loop.
|
||||
nextDelay = appliedStock ? Duration.zero : _pollingInterval;
|
||||
_emit(RemoteSyncEventType.syncCompleted);
|
||||
} on UnauthorizedException {
|
||||
debugPrint('RemoteSyncService: token revoked');
|
||||
@@ -229,11 +257,20 @@ class RemoteSyncService {
|
||||
return retryAfter ?? _backoff();
|
||||
}
|
||||
|
||||
Future<void> _doSync(_Credentials credentials) async {
|
||||
/// Returns whether any stock adjustment was applied, which only happens
|
||||
/// with a server that doesn't own the stock.
|
||||
Future<bool> _doSync(_Credentials credentials) async {
|
||||
final state = await _getSyncState();
|
||||
// Read before the snapshot, so the snapshot is never older than the events
|
||||
final outbox = await _pendingEvents();
|
||||
final openTabs = await barTabService.getOpenTabs();
|
||||
// One read, so the snapshot and the stock match the events that are sent
|
||||
final (outbox, openTabs, products, stockResults) = await database
|
||||
.transaction(
|
||||
() async => (
|
||||
await _pendingEvents(),
|
||||
await barTabService.getOpenTabs(),
|
||||
await database.select(database.products).get(),
|
||||
await _pendingStockResults(),
|
||||
),
|
||||
);
|
||||
|
||||
final response = await apiClient.sync(
|
||||
token: credentials.token,
|
||||
@@ -245,22 +282,60 @@ class RemoteSyncService {
|
||||
for (final row in outbox)
|
||||
jsonDecode(row.payload) as Map<String, dynamic>,
|
||||
],
|
||||
products: products.map(_toCatalogEntry).toList(),
|
||||
stockAdjustmentResults: [
|
||||
for (final row in stockResults)
|
||||
jsonDecode(row.payload) as Map<String, dynamic>,
|
||||
],
|
||||
settlementsCursor: state?.settlementsCursor,
|
||||
),
|
||||
);
|
||||
|
||||
await _clearSentEvents(response);
|
||||
await _markStockResultsReceived(response);
|
||||
await barTabService.recordSettlements(response.settlements);
|
||||
|
||||
final serverTabs = response.tabs;
|
||||
if (serverTabs != null) {
|
||||
// After the acked events left the outbox: what is left still has to
|
||||
// reach the server and is replayed on top
|
||||
await barTabService.applyServerState(serverTabs);
|
||||
}
|
||||
|
||||
// Same rebase for the stock: the server counts every tablet's sales
|
||||
final serverStock = response.stock;
|
||||
if (serverStock != null) await database.rebaseStock(serverStock);
|
||||
|
||||
for (final command in response.commands) {
|
||||
await _processCommand(command, credentials);
|
||||
}
|
||||
|
||||
final openNames = openTabs.map((tab) => tab.customerName).toSet();
|
||||
for (final name in response.plannedTabNames) {
|
||||
if (name.trim().isEmpty || !openNames.add(name)) continue;
|
||||
await barTabService.createTab(customerName: name);
|
||||
// A server that holds the tabs creates planned tabs itself; doing it
|
||||
// here too would open them once per tablet
|
||||
if (serverTabs == null) {
|
||||
final openNames = openTabs.map((tab) => tab.customerName).toSet();
|
||||
for (final name in response.plannedTabNames) {
|
||||
if (name.trim().isEmpty || !openNames.add(name)) continue;
|
||||
await barTabService.createTab(customerName: name);
|
||||
}
|
||||
}
|
||||
|
||||
await _updateSyncState(success: true, cursor: response.cursor);
|
||||
// A server that owns the stock applies the dashboard's adjustments
|
||||
// itself; doing it here too would apply a delivery once per tablet
|
||||
var appliedStock = false;
|
||||
if (serverStock == null) {
|
||||
for (final adjustment in response.stockAdjustments) {
|
||||
if (await _applyStockAdjustment(adjustment)) appliedStock = true;
|
||||
}
|
||||
}
|
||||
|
||||
await _updateSyncState(
|
||||
success: true,
|
||||
cursor: response.cursor,
|
||||
settlementsCursor: response.settlementsCursor,
|
||||
);
|
||||
|
||||
return appliedStock;
|
||||
}
|
||||
|
||||
Future<List<TabEventRow>> _pendingEvents() {
|
||||
@@ -295,6 +370,134 @@ class RemoteSyncService {
|
||||
)..where((row) => row.id.isIn(ids))).go();
|
||||
}
|
||||
|
||||
Future<List<StockAdjustmentResultRow>> _pendingStockResults() {
|
||||
return (database.select(database.stockAdjustmentResults)
|
||||
..where((row) => row.acknowledged.not())
|
||||
..orderBy([
|
||||
(row) => OrderingTerm.asc(row.createdAt),
|
||||
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
|
||||
])
|
||||
..limit(maxStockResultsPerSync))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Received outcomes stay as the record that their adjustment was handled;
|
||||
/// [pruneStockAdjustmentResults] removes them later.
|
||||
Future<void> _markStockResultsReceived(SyncResponse response) async {
|
||||
for (final rejected in response.rejectedStockAdjustmentResults) {
|
||||
debugPrint(
|
||||
'RemoteSyncService: stock result ${rejected.id} rejected: '
|
||||
'${rejected.reason}',
|
||||
);
|
||||
}
|
||||
|
||||
final ids = {
|
||||
...response.acknowledgedStockAdjustmentResultIds,
|
||||
for (final rejected in response.rejectedStockAdjustmentResults)
|
||||
if (rejected.id != null) rejected.id!,
|
||||
};
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
await (database.update(
|
||||
database.stockAdjustmentResults,
|
||||
)..where((row) => row.id.isIn(ids))).write(
|
||||
const StockAdjustmentResultsCompanion(acknowledged: Value(true)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies [adjustment] unless it was handled before, and stores the
|
||||
/// outcome in the same transaction, so a crash can neither apply it twice
|
||||
/// nor lose it. Returns whether the stock changed.
|
||||
Future<bool> _applyStockAdjustment(StockAdjustment adjustment) async {
|
||||
final type = adjustment.type;
|
||||
if (type == null) return false;
|
||||
|
||||
return database.transaction(() async {
|
||||
final handled = await (database.select(
|
||||
database.stockAdjustmentResults,
|
||||
)..where((row) => row.id.equals(adjustment.id))).getSingleOrNull();
|
||||
if (handled != null) return false;
|
||||
|
||||
final product = await (database.select(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(adjustment.productId))).getSingleOrNull();
|
||||
|
||||
final Map<String, dynamic> result;
|
||||
if (product == null) {
|
||||
result = _rejectedStockResult(adjustment, 'unknown_product');
|
||||
} else if (type == StockAdjustmentType.add && product.infiniteStock) {
|
||||
result = _rejectedStockResult(adjustment, 'stock_not_tracked');
|
||||
} else {
|
||||
final stockAfter = switch (type) {
|
||||
StockAdjustmentType.add =>
|
||||
product.stockQuantity + adjustment.quantity,
|
||||
StockAdjustmentType.set => adjustment.quantity,
|
||||
};
|
||||
// A stock count on an untracked product starts tracking it
|
||||
await (database.update(
|
||||
database.products,
|
||||
)..where((row) => row.id.equals(product.id))).write(
|
||||
ProductsCompanion(
|
||||
stockQuantity: Value(stockAfter),
|
||||
infiniteStock: const Value(false),
|
||||
),
|
||||
);
|
||||
result = {
|
||||
'id': adjustment.id,
|
||||
'status': 'applied',
|
||||
'stockAfter': stockAfter,
|
||||
};
|
||||
}
|
||||
|
||||
await database
|
||||
.into(database.stockAdjustmentResults)
|
||||
.insert(
|
||||
StockAdjustmentResultsCompanion.insert(
|
||||
id: adjustment.id,
|
||||
payload: jsonEncode(result),
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
return result['status'] == 'applied';
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _rejectedStockResult(
|
||||
StockAdjustment adjustment,
|
||||
String reason,
|
||||
) {
|
||||
debugPrint(
|
||||
'RemoteSyncService: stock adjustment ${adjustment.id} rejected: $reason',
|
||||
);
|
||||
return {'id': adjustment.id, 'status': 'rejected', 'reason': reason};
|
||||
}
|
||||
|
||||
/// Forgets handled stock adjustments whose outcome the server received
|
||||
/// more than [stockResultRetention] ago.
|
||||
Future<void> pruneStockAdjustmentResults({DateTime? now}) async {
|
||||
final cutoff = (now ?? DateTime.now()).subtract(stockResultRetention);
|
||||
|
||||
await (database.delete(database.stockAdjustmentResults)..where(
|
||||
(row) => row.acknowledged & row.createdAt.isSmallerThanValue(cutoff),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
/// Hidden products are included: they still exist. A server that owns the
|
||||
/// stock ignores `stock`; it gets the counts as `stock_set` events.
|
||||
Map<String, dynamic> _toCatalogEntry(ProductRow product) {
|
||||
final category = product.category.trim();
|
||||
|
||||
return {
|
||||
'id': product.id,
|
||||
'name': product.name,
|
||||
'category': category.isEmpty ? null : category,
|
||||
'priceInCents': product.priceInCents,
|
||||
'stock': product.infiniteStock ? null : product.stockQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
SnapshotTab _toSnapshot(BarTab tab) {
|
||||
return SnapshotTab(
|
||||
id: tab.id,
|
||||
@@ -404,7 +607,13 @@ class RemoteSyncService {
|
||||
)..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> _updateSyncState({required bool success, String? cursor}) async {
|
||||
/// Settlements are deduplicated by id, so a cursor lost in a crash only
|
||||
/// makes the server send some of them again.
|
||||
Future<void> _updateSyncState({
|
||||
required bool success,
|
||||
String? cursor,
|
||||
String? settlementsCursor,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final existing = await _getSyncState();
|
||||
|
||||
@@ -414,6 +623,9 @@ class RemoteSyncService {
|
||||
RemoteSyncStateCompanion.insert(
|
||||
deviceId: _syncStateId,
|
||||
serverCursor: Value(cursor ?? existing?.serverCursor),
|
||||
settlementsCursor: Value(
|
||||
settlementsCursor ?? existing?.settlementsCursor,
|
||||
),
|
||||
lastSuccessfulSyncAt: Value(
|
||||
success ? now : existing?.lastSuccessfulSyncAt,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user