feat: add historical data

This commit is contained in:
2026-10-19 23:31:54 +02:00
parent b0074aeb3f
commit 5754ae742d
10 changed files with 1211 additions and 4 deletions
+97 -2
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:drift/drift.dart';
@@ -10,6 +11,7 @@ import '../models/closed_tab.dart';
import '../models/closed_tab_item.dart';
import '../models/payment_method.dart';
import '../models/product.dart';
import '../models/tab_event.dart';
import '../models/tab_item.dart';
import '../models/tab_item_purchase.dart';
@@ -69,11 +71,13 @@ abstract class BarTabService {
/// The tab itself stays open under the same customer name.
///
/// Throws [TabVersionConflictException] when [expectedVersion] is given and
/// the tab changed since.
/// the tab changed since. [commandId] is the remote command that requested
/// the settlement, if any.
Future<void> closeTab(
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
int? expectedVersion,
String? commandId,
});
Future<List<ClosedTab>> getClosedTabs();
@@ -91,9 +95,11 @@ abstract class BarTabService {
class DriftBarTabService implements BarTabService {
final AppDatabase database;
final TabEventFactory eventFactory;
final _uuid = const Uuid();
DriftBarTabService({required this.database});
DriftBarTabService({required this.database, TabEventFactory? eventFactory})
: eventFactory = eventFactory ?? TabEventFactory();
TabItemPurchase _mapPurchaseRow(TabItemPurchaseRow row) {
return TabItemPurchase(
@@ -255,6 +261,21 @@ class DriftBarTabService implements BarTabService {
)..where((item) => item.tabId.equals(tabId))).go();
}
/// Queues [event] in the outbox. Call it inside the transaction that makes
/// the change, so the change and its event are stored together or not at
/// all.
Future<void> _recordEvent(TabEvent event) async {
await database
.into(database.tabEvents)
.insert(
TabEventsCompanion.insert(
id: event.id,
payload: jsonEncode(event.payload),
createdAt: DateTime.now(),
),
);
}
Future<void> _incrementTabVersion(String tabId) async {
await (database.update(
database.barTabs,
@@ -341,6 +362,15 @@ class DriftBarTabService implements BarTabService {
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 _deleteItemsOfTab(tabId);
@@ -356,6 +386,9 @@ class DriftBarTabService implements BarTabService {
required Product product,
}) async {
await database.transaction(() async {
final tabRow = await _getTabRow(tabId);
if (tabRow == null) throw TabNotFoundException(tabId);
await database.decreaseStock(product.id, 1);
final existingItem =
@@ -373,6 +406,16 @@ class DriftBarTabService implements BarTabService {
TabItemsCompanion(quantity: Value(existingItem.quantity + 1)),
);
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,
),
);
} else {
final tabItemId = _uuid.v4();
@@ -390,6 +433,15 @@ 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 _incrementTabVersion(tabId);
@@ -429,6 +481,20 @@ class DriftBarTabService implements BarTabService {
..where((row) => row.id.equals(tabItemId)))
.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 _incrementTabVersion(item.tabId);
return actualDelta;
@@ -440,6 +506,7 @@ class DriftBarTabService implements BarTabService {
String tabId, {
PaymentMethod paymentMethod = PaymentMethod.cash,
int? expectedVersion,
String? commandId,
}) async {
await database.transaction(() async {
final tabRow = await _getTabRow(tabId);
@@ -488,6 +555,34 @@ 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,
),
],
),
);
}
await _deleteItemsOfTab(tabId);
await _incrementTabVersion(tabId);
});
+45
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
@@ -45,6 +46,9 @@ class RemoteSyncService {
static const _venueIdKey = 'remote_venue_id';
static const _syncStateId = 'default';
/// Most outbox events sent per sync; a larger backlog drains over syncs.
static const maxEventsPerSync = 500;
static const _pollingInterval = Duration(
seconds: kSyncPollingIntervalSeconds,
);
@@ -227,6 +231,8 @@ class RemoteSyncService {
Future<void> _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();
final response = await apiClient.sync(
@@ -235,9 +241,15 @@ class RemoteSyncService {
deviceId: credentials.deviceId,
cursor: state?.serverCursor,
openTabs: openTabs.map(_toSnapshot).toList(),
events: [
for (final row in outbox)
jsonDecode(row.payload) as Map<String, dynamic>,
],
),
);
await _clearSentEvents(response);
for (final command in response.commands) {
await _processCommand(command, credentials);
}
@@ -251,6 +263,38 @@ class RemoteSyncService {
await _updateSyncState(success: true, cursor: response.cursor);
}
Future<List<TabEventRow>> _pendingEvents() {
return (database.select(database.tabEvents)
..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
// Same-second events keep their insertion order
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
])
..limit(maxEventsPerSync))
.get();
}
/// Events the server stored or refused leave the outbox. Events created
/// while the sync was in flight are in neither list and go next time.
Future<void> _clearSentEvents(SyncResponse response) async {
for (final rejected in response.rejectedEvents) {
debugPrint(
'RemoteSyncService: event ${rejected.id} rejected: ${rejected.reason}',
);
}
final ids = {
...response.acknowledgedEventIds,
for (final rejected in response.rejectedEvents)
if (rejected.id != null) rejected.id!,
};
if (ids.isEmpty) return;
await (database.delete(
database.tabEvents,
)..where((row) => row.id.isIn(ids))).go();
}
SnapshotTab _toSnapshot(BarTab tab) {
return SnapshotTab(
id: tab.id,
@@ -309,6 +353,7 @@ class RemoteSyncService {
command.tabId,
paymentMethod: PaymentMethod.fromValue(command.paymentMethod),
expectedVersion: command.expectedVersion,
commandId: command.id,
);
_eventController.add(