feat: add historical data
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user