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(
|
||||
|
||||
Reference in New Issue
Block a user