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
+23 -1
View File
@@ -187,6 +187,23 @@ class ProcessedRemoteCommands extends Table {
Set<Column> get primaryKey => {commandId};
}
/// Outbox of tab events (lines added/removed, settlements) waiting to be
/// sent to the server for sales statistics. Rows are deleted once the server
/// acknowledges or rejects them.
@DataClassName('TabEventRow')
class TabEvents extends Table {
/// The event's UUID.
TextColumn get id => text()();
/// The event as JSON, exactly as it is sent.
TextColumn get payload => text()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(
tables: [
Products,
@@ -198,13 +215,14 @@ class ProcessedRemoteCommands extends Table {
AppSettingsTable,
RemoteSyncState,
ProcessedRemoteCommands,
TabEvents,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 14;
int get schemaVersion => 15;
@override
MigrationStrategy get migration {
@@ -303,6 +321,10 @@ class AppDatabase extends _$AppDatabase {
if (from < 14 && !await hasTableColumn('products', 'infinite_stock')) {
await migrator.addColumn(products, products.infiniteStock);
}
if (from < 15) {
await migrator.createTable(tabEvents);
}
},
);
}
+430
View File
@@ -3796,6 +3796,270 @@ class ProcessedRemoteCommandsCompanion
}
}
class $TabEventsTable extends TabEvents
with TableInfo<$TabEventsTable, TabEventRow> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$TabEventsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _payloadMeta = const VerificationMeta(
'payload',
);
@override
late final GeneratedColumn<String> payload = GeneratedColumn<String>(
'payload',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [id, payload, createdAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'tab_events';
@override
VerificationContext validateIntegrity(
Insertable<TabEventRow> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('payload')) {
context.handle(
_payloadMeta,
payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta),
);
} else if (isInserting) {
context.missing(_payloadMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
} else if (isInserting) {
context.missing(_createdAtMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
TabEventRow map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return TabEventRow(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
payload: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}payload'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
$TabEventsTable createAlias(String alias) {
return $TabEventsTable(attachedDatabase, alias);
}
}
class TabEventRow extends DataClass implements Insertable<TabEventRow> {
/// The event's UUID.
final String id;
/// The event as JSON, exactly as it is sent.
final String payload;
final DateTime createdAt;
const TabEventRow({
required this.id,
required this.payload,
required this.createdAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['payload'] = Variable<String>(payload);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
TabEventsCompanion toCompanion(bool nullToAbsent) {
return TabEventsCompanion(
id: Value(id),
payload: Value(payload),
createdAt: Value(createdAt),
);
}
factory TabEventRow.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TabEventRow(
id: serializer.fromJson<String>(json['id']),
payload: serializer.fromJson<String>(json['payload']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'payload': serializer.toJson<String>(payload),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
TabEventRow copyWith({String? id, String? payload, DateTime? createdAt}) =>
TabEventRow(
id: id ?? this.id,
payload: payload ?? this.payload,
createdAt: createdAt ?? this.createdAt,
);
TabEventRow copyWithCompanion(TabEventsCompanion data) {
return TabEventRow(
id: data.id.present ? data.id.value : this.id,
payload: data.payload.present ? data.payload.value : this.payload,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('TabEventRow(')
..write('id: $id, ')
..write('payload: $payload, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, payload, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is TabEventRow &&
other.id == this.id &&
other.payload == this.payload &&
other.createdAt == this.createdAt);
}
class TabEventsCompanion extends UpdateCompanion<TabEventRow> {
final Value<String> id;
final Value<String> payload;
final Value<DateTime> createdAt;
final Value<int> rowid;
const TabEventsCompanion({
this.id = const Value.absent(),
this.payload = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
TabEventsCompanion.insert({
required String id,
required String payload,
required DateTime createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
payload = Value(payload),
createdAt = Value(createdAt);
static Insertable<TabEventRow> custom({
Expression<String>? id,
Expression<String>? payload,
Expression<DateTime>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (payload != null) 'payload': payload,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
TabEventsCompanion copyWith({
Value<String>? id,
Value<String>? payload,
Value<DateTime>? createdAt,
Value<int>? rowid,
}) {
return TabEventsCompanion(
id: id ?? this.id,
payload: payload ?? this.payload,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (payload.present) {
map['payload'] = Variable<String>(payload.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TabEventsCompanion(')
..write('id: $id, ')
..write('payload: $payload, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -3815,6 +4079,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
);
late final $ProcessedRemoteCommandsTable processedRemoteCommands =
$ProcessedRemoteCommandsTable(this);
late final $TabEventsTable tabEvents = $TabEventsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -3829,6 +4094,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
appSettingsTable,
remoteSyncState,
processedRemoteCommands,
tabEvents,
];
@override
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
@@ -6508,6 +6774,168 @@ typedef $$ProcessedRemoteCommandsTableProcessedTableManager =
ProcessedRemoteCommandRow,
PrefetchHooks Function()
>;
typedef $$TabEventsTableCreateCompanionBuilder =
TabEventsCompanion Function({
required String id,
required String payload,
required DateTime createdAt,
Value<int> rowid,
});
typedef $$TabEventsTableUpdateCompanionBuilder =
TabEventsCompanion Function({
Value<String> id,
Value<String> payload,
Value<DateTime> createdAt,
Value<int> rowid,
});
class $$TabEventsTableFilterComposer
extends Composer<_$AppDatabase, $TabEventsTable> {
$$TabEventsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get payload => $composableBuilder(
column: $table.payload,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
}
class $$TabEventsTableOrderingComposer
extends Composer<_$AppDatabase, $TabEventsTable> {
$$TabEventsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get payload => $composableBuilder(
column: $table.payload,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$TabEventsTableAnnotationComposer
extends Composer<_$AppDatabase, $TabEventsTable> {
$$TabEventsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get payload =>
$composableBuilder(column: $table.payload, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$TabEventsTableTableManager
extends
RootTableManager<
_$AppDatabase,
$TabEventsTable,
TabEventRow,
$$TabEventsTableFilterComposer,
$$TabEventsTableOrderingComposer,
$$TabEventsTableAnnotationComposer,
$$TabEventsTableCreateCompanionBuilder,
$$TabEventsTableUpdateCompanionBuilder,
(
TabEventRow,
BaseReferences<_$AppDatabase, $TabEventsTable, TabEventRow>,
),
TabEventRow,
PrefetchHooks Function()
> {
$$TabEventsTableTableManager(_$AppDatabase db, $TabEventsTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$TabEventsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$TabEventsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$TabEventsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> payload = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => TabEventsCompanion(
id: id,
payload: payload,
createdAt: createdAt,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String payload,
required DateTime createdAt,
Value<int> rowid = const Value.absent(),
}) => TabEventsCompanion.insert(
id: id,
payload: payload,
createdAt: createdAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$TabEventsTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$TabEventsTable,
TabEventRow,
$$TabEventsTableFilterComposer,
$$TabEventsTableOrderingComposer,
$$TabEventsTableAnnotationComposer,
$$TabEventsTableCreateCompanionBuilder,
$$TabEventsTableUpdateCompanionBuilder,
(
TabEventRow,
BaseReferences<_$AppDatabase, $TabEventsTable, TabEventRow>,
),
TabEventRow,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@@ -6533,4 +6961,6 @@ class $AppDatabaseManager {
_db,
_db.processedRemoteCommands,
);
$$TabEventsTableTableManager get tabEvents =>
$$TabEventsTableTableManager(_db, _db.tabEvents);
}
+32
View File
@@ -59,15 +59,34 @@ class RemoteCommand {
}
}
/// A tab event the server refused to store. Re-sending won't help.
class RejectedEvent {
final String? id;
final String reason;
const RejectedEvent({this.id, required this.reason});
factory RejectedEvent.fromJson(Map<String, dynamic> json) {
return RejectedEvent(
id: json['id'] as String?,
reason: json['reason'] as String? ?? '',
);
}
}
class SyncResponse {
final List<RemoteCommand> commands;
final String? cursor;
final List<String> plannedTabNames;
final List<String> acknowledgedEventIds;
final List<RejectedEvent> rejectedEvents;
const SyncResponse({
required this.commands,
this.cursor,
this.plannedTabNames = const [],
this.acknowledgedEventIds = const [],
this.rejectedEvents = const [],
});
factory SyncResponse.fromJson(Map<String, dynamic> json) {
@@ -81,6 +100,14 @@ class SyncResponse {
for (final name in json['plannedTabNames'] as List<dynamic>? ?? [])
name as String,
],
acknowledgedEventIds: [
for (final id in json['acknowledgedEventIds'] as List<dynamic>? ?? [])
id as String,
],
rejectedEvents: [
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(event as Map<String, dynamic>),
],
);
}
}
@@ -136,16 +163,21 @@ class SyncRequest {
final String? cursor;
final List<SnapshotTab> openTabs;
/// Outbox tab events, oldest first, as their stored JSON objects.
final List<Map<String, dynamic>> events;
const SyncRequest({
required this.deviceId,
this.cursor,
required this.openTabs,
this.events = const [],
});
Map<String, dynamic> toJson() => {
'deviceId': deviceId,
if (cursor != null) 'cursor': cursor,
'openTabs': openTabs.map((tab) => tab.toJson()).toList(),
'events': events,
};
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:uuid/uuid.dart';
import 'payment_method.dart';
/// Types of [TabEvent] the server understands.
enum TabEventType {
lineAdded('line_added'),
lineRemoved('line_removed'),
settled('settled');
final String value;
const TabEventType(this.value);
}
/// Something that happened on a tab, reported to the server for sales
/// statistics. [payload] is the JSON object that is stored in the outbox and
/// sent as is.
class TabEvent {
final String id;
final Map<String, dynamic> payload;
const TabEvent({required this.id, required this.payload});
}
/// One settled line in a `settled` event.
typedef SettledLine = ({
String productName,
int quantity,
int unitPriceInCents,
});
/// Builds the events, so their JSON shape lives in one place.
class TabEventFactory {
final _uuid = const Uuid();
final DateTime Function() _now;
TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now;
/// [quantity] is how many pieces were added; always positive.
TabEvent lineAdded({
required String tabId,
required String customerName,
required String productName,
required int unitPriceInCents,
required int quantity,
}) {
return _lineEvent(
TabEventType.lineAdded,
tabId: tabId,
customerName: customerName,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
);
}
/// [quantity] is how many pieces were removed; always positive.
TabEvent lineRemoved({
required String tabId,
required String customerName,
required String productName,
required int unitPriceInCents,
required int quantity,
}) {
return _lineEvent(
TabEventType.lineRemoved,
tabId: tabId,
customerName: customerName,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
);
}
TabEvent settled({
required String tabId,
required String customerName,
required int amountInCents,
required PaymentMethod paymentMethod,
required List<SettledLine> items,
String? commandId,
}) {
return _event(
TabEventType.settled,
tabId: tabId,
customerName: customerName,
fields: {
'amountInCents': amountInCents,
'paymentMethod': paymentMethodValue(paymentMethod),
'commandId': ?commandId,
'items': [
for (final item in items)
{
'productName': item.productName,
'quantity': item.quantity,
'unitPriceInCents': item.unitPriceInCents,
},
],
},
);
}
/// The server's payment method names: `cash`, `payconiq`,
/// `payment_pending` or `other`.
static String paymentMethodValue(PaymentMethod method) {
return switch (method) {
PaymentMethod.cash => 'cash',
PaymentMethod.payconiq => 'payconiq',
PaymentMethod.paymentPending => 'payment_pending',
};
}
TabEvent _lineEvent(
TabEventType type, {
required String tabId,
required String customerName,
required String productName,
required int unitPriceInCents,
required int quantity,
}) {
if (quantity < 1) {
throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1');
}
return _event(
type,
tabId: tabId,
customerName: customerName,
fields: {
'productName': productName,
'unitPriceInCents': unitPriceInCents,
'quantity': quantity,
},
);
}
TabEvent _event(
TabEventType type, {
required String tabId,
required String customerName,
required Map<String, dynamic> fields,
}) {
final id = _uuid.v4();
return TabEvent(
id: id,
payload: {
'id': id,
'type': type.value,
'tabId': tabId,
'customerName': customerName,
'occurredAt': _now().toUtc().toIso8601String(),
...fields,
},
);
}
}
+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(
+1 -1
View File
@@ -2,7 +2,7 @@ name: kooltap
description: "KoolTap: a tablet point-of-sale app for running bar tabs."
publish_to: 'none'
version: 1.2.13+0
version: 1.3.0+0
environment:
sdk: ^3.12.2
+5
View File
@@ -89,4 +89,9 @@ void main() {
expect(orphan, isNull);
});
test('creates an empty tab event outbox', () async {
expect(await database.select(database.tabEvents).get(), isEmpty);
expect((await database.select(database.barTabs).get()), hasLength(1));
});
}
+148
View File
@@ -443,4 +443,152 @@ void main() {
},
);
});
group('tab events', () {
Future<void> queueEvent(String id, DateTime createdAt) async {
await database
.into(database.tabEvents)
.insert(
TabEventsCompanion.insert(
id: id,
payload: jsonEncode({'id': id, 'type': 'line_added'}),
createdAt: createdAt,
),
);
}
Future<List<String>> outboxIds() async {
final rows = await database.select(database.tabEvents).get();
return [for (final row in rows) row.id];
}
List<String> sentIds([int request = 0]) => [
for (final event in server.syncRequests[request]['events'] as List)
event['id'] as String,
];
test('sends at most 500 events, oldest first', () async {
final start = DateTime(2026, 10, 24, 20);
// Inserted newest first, so only the timestamps give the order
for (var i = 501; i >= 1; i--) {
await queueEvent('e$i', start.add(Duration(minutes: i)));
}
await service.sync();
expect(sentIds(), [for (var i = 1; i <= 500; i++) 'e$i']);
expect(server.syncRequests.single['events'].first, {
'id': 'e1',
'type': 'line_added',
});
});
test('deletes exactly the acknowledged and rejected events', () async {
final now = DateTime.now();
for (final id in ['e1', 'e2', 'e3']) {
await queueEvent(id, now);
}
server.respondWith({
'acknowledgedEventIds': ['e1'],
'rejectedEvents': [
{'id': 'e2', 'reason': 'unknown type'},
{'reason': 'not an object'},
],
});
await service.sync();
expect(await outboxIds(), ['e3']);
});
test('a failed sync deletes nothing', () async {
await queueEvent('e1', DateTime.now());
server.syncResponse = http.Response(
jsonEncode({
'acknowledgedEventIds': ['e1'],
}),
500,
);
await service.sync();
expect(await outboxIds(), ['e1']);
});
test('an older server without event fields keeps the events', () async {
await queueEvent('e1', DateTime.now());
await service.sync();
expect(await outboxIds(), ['e1']);
});
test('events created during the sync wait for the next one', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
server.holdSync = Completer<void>();
final running = service.sync();
await pumpEventQueue();
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
server.respondWith({'acknowledgedEventIds': sentIds()});
server.holdSync!.complete();
server.holdSync = null;
await running;
expect(sentIds(), hasLength(1));
final remaining = await outboxIds();
expect(remaining, hasLength(1));
expect(remaining, isNot(contains(sentIds().single)));
});
test('a close_tab command writes one settled event', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
final current = (await barTabService.getTabById(tab.id))!;
server.respondWith({
'commands': [
_closeCommand(
id: 'cmd-1',
tabId: tab.id,
expectedVersion: current.version,
),
],
});
// The second sync receives the same command again
await service.sync();
await service.sync();
final settled = [
for (final event in server.syncRequests.last['events'] as List)
if (event['type'] == 'settled') event,
];
expect(settled, hasLength(1));
expect(settled.single['commandId'], 'cmd-1');
expect(settled.single['paymentMethod'], 'payconiq');
expect(settled.single['amountInCents'], 800);
expect(settled.single['customerName'], 'Alice');
});
test('a conflicting close_tab command writes no settled event', () async {
final tab = await barTabService.createTab(customerName: 'Alice');
await barTabService.addProductToTab(tabId: tab.id, product: _beer);
server.respondWith({
'commands': [
_closeCommand(id: 'cmd-1', tabId: tab.id, expectedVersion: 1),
],
});
await service.sync();
await service.sync();
final types = [
for (final event in server.syncRequests.last['events'] as List)
event['type'],
];
expect(types, ['line_added']);
});
});
}
+272
View File
@@ -0,0 +1,272 @@
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/models/payment_method.dart';
import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/tab_event.dart';
import 'package:kooltap/services/bar_tab_service.dart';
const _cola = Product(
id: 'prod-1',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 250,
);
void main() {
late AppDatabase database;
late DriftBarTabService service;
setUp(() async {
database = AppDatabase(NativeDatabase.memory());
service = DriftBarTabService(database: database);
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: _cola.id,
name: _cola.name,
category: _cola.category,
stockQuantity: _cola.stockQuantity,
lowStockThreshold: _cola.lowStockThreshold,
priceInCents: _cola.priceInCents,
),
);
});
tearDown(() async {
await database.close();
});
Future<List<Map<String, dynamic>>> outbox() async {
final rows =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(const CustomExpression('rowid')),
]))
.get();
return [
for (final row in rows) jsonDecode(row.payload) as Map<String, dynamic>,
];
}
Future<String> lineIdOf(String tabId) async {
return (await service.getTabById(tabId))!.items.single.id;
}
Future<BarTabRow> tabWithTwoColas() async {
final tab = await service.createTab(customerName: 'Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(tabId: tab.id, product: _cola);
await database.delete(database.tabEvents).go();
return (database.select(
database.barTabs,
)..where((row) => row.id.equals(tab.id))).getSingle();
}
group('line events', () {
test('adding a product writes line_added with the common fields', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
final event = (await outbox()).single;
expect(event['type'], 'line_added');
expect(event['tabId'], tab.id);
expect(event['customerName'], 'Jan');
expect(event['productName'], 'Cola');
expect(event['unitPriceInCents'], 250);
expect(event['quantity'], 1);
expect(event['id'], matches(RegExp(r'^[0-9a-f-]{36}$')));
expect(event['occurredAt'], endsWith('Z'));
final row = await database.select(database.tabEvents).getSingle();
expect(row.id, event['id']);
});
test('adding to an existing line keeps that line\'s price', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(
tabId: tab.id,
product: const Product(
id: 'prod-1',
name: 'Cola',
category: 'Drinks',
stockQuantity: 100,
lowStockThreshold: 10,
priceInCents: 300,
),
);
final events = await outbox();
expect(events.map((event) => event['unitPriceInCents']), [250, 250]);
});
test('changing a quantity writes the difference', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
await service.addProductToTab(tabId: tab.id, product: _cola);
final lineId = await lineIdOf(tab.id);
await service.adjustTabItemQuantity(tabItemId: lineId, delta: 3);
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -4);
final events = (await outbox()).skip(2).toList();
expect(events[0]['type'], 'line_added');
expect(events[0]['quantity'], 3);
expect(events[1]['type'], 'line_removed');
expect(events[1]['quantity'], 4);
expect(events[1]['unitPriceInCents'], 250);
});
test('removing a line writes line_removed for what was left', () async {
final tab = await tabWithTwoColas();
final lineId = await lineIdOf(tab.id);
// More than is on the tab: only the 2 that were there are removed
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -5);
final event = (await outbox()).single;
expect(event['type'], 'line_removed');
expect(event['quantity'], 2);
});
test('a change that does nothing writes no event', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.addProductToTab(tabId: tab.id, product: _cola);
final lineId = await lineIdOf(tab.id);
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1);
await database.delete(database.tabEvents).go();
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1);
await service.adjustTabItemQuantity(tabItemId: lineId, delta: 0);
expect(await outbox(), isEmpty);
});
test('deleting a tab writes line_removed for each line', () async {
final tab = await tabWithTwoColas();
await service.deleteTab(tab.id);
final event = (await outbox()).single;
expect(event['type'], 'line_removed');
expect(event['quantity'], 2);
expect(event['customerName'], 'Jan');
});
test('later events use the new name after a rename', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.renameTab(tabId: tab.id, customerName: 'Janneke');
expect(await outbox(), isEmpty);
await service.addProductToTab(tabId: tab.id, product: _cola);
expect((await outbox()).single['customerName'], 'Janneke');
});
test('a failed change writes no event', () async {
await expectLater(
service.addProductToTab(tabId: 'missing', product: _cola),
throwsA(isA<TabNotFoundException>()),
);
expect(await outbox(), isEmpty);
});
});
group('settled events', () {
test('settling writes one settled event and no removals', () async {
final tab = await tabWithTwoColas();
await service.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq);
final event = (await outbox()).single;
expect(event['type'], 'settled');
expect(event['customerName'], 'Jan');
expect(event['amountInCents'], 500);
expect(event['paymentMethod'], 'payconiq');
expect(event.containsKey('commandId'), false);
expect(event['items'], [
{'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250},
]);
});
test('lines that were taken back are not settled', () async {
final tab = await tabWithTwoColas();
final lineId = await lineIdOf(tab.id);
await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1);
await service.closeTab(tab.id);
final settled = (await outbox()).last;
expect(settled['amountInCents'], 250);
expect(settled['paymentMethod'], 'cash');
expect((settled['items'] as List).single['quantity'], 1);
});
test('settling an empty tab writes nothing', () async {
final tab = await service.createTab(customerName: 'Jan');
await service.closeTab(tab.id);
expect(await outbox(), isEmpty);
});
test('a version conflict writes nothing', () async {
final tab = await tabWithTwoColas();
await expectLater(
service.closeTab(tab.id, expectedVersion: tab.version - 1),
throwsA(isA<TabVersionConflictException>()),
);
expect(await outbox(), isEmpty);
});
test('carries the command id when given', () async {
final tab = await tabWithTwoColas();
await service.closeTab(
tab.id,
paymentMethod: PaymentMethod.paymentPending,
commandId: 'cmd-1',
);
final event = (await outbox()).single;
expect(event['commandId'], 'cmd-1');
expect(event['paymentMethod'], 'payment_pending');
});
});
test('occurredAt is the UTC time it happened', () {
final factory = TabEventFactory(
now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120),
);
final event = factory.lineAdded(
tabId: 'a1b2',
customerName: 'Jan',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
);
expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z');
expect(
() => factory.lineRemoved(
tabId: 'a1b2',
customerName: 'Jan',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 0,
),
throwsArgumentError,
);
});
}