feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+38 -1
View File
@@ -162,6 +162,9 @@ class RemoteSyncState extends Table {
TextColumn get serverCursor => text().nullable()();
/// Where the feed of settlements made anywhere in the venue left off.
TextColumn get settlementsCursor => text().nullable()();
DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()();
DateTimeColumn get lastAttemptedSyncAt => dateTime().nullable()();
@@ -204,6 +207,26 @@ class TabEvents extends Table {
Set<Column> get primaryKey => {id};
}
/// Outcomes of the stock adjustments sent by the dashboard, waiting to be
/// reported. A row also records that its adjustment was handled, so it is
/// kept for a while after the server acknowledges it: the same adjustment
/// can still arrive in a response computed before the server saw the outcome.
@DataClassName('StockAdjustmentResultRow')
class StockAdjustmentResults extends Table {
/// The adjustment's id from the server.
TextColumn get id => text()();
/// The outcome as JSON, exactly as it is sent.
TextColumn get payload => text()();
BoolColumn get acknowledged => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(
tables: [
Products,
@@ -216,13 +239,14 @@ class TabEvents extends Table {
RemoteSyncState,
ProcessedRemoteCommands,
TabEvents,
StockAdjustmentResults,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 15;
int get schemaVersion => 17;
@override
MigrationStrategy get migration {
@@ -325,6 +349,19 @@ class AppDatabase extends _$AppDatabase {
if (from < 15) {
await migrator.createTable(tabEvents);
}
if (from < 16) {
await migrator.createTable(stockAdjustmentResults);
}
if (from < 17 &&
await hasTableColumn('remote_sync_state', 'device_id') &&
!await hasTableColumn('remote_sync_state', 'settlements_cursor')) {
await migrator.addColumn(
remoteSyncState,
remoteSyncState.settlementsCursor,
);
}
},
);
}
+618
View File
@@ -3060,6 +3060,18 @@ class $RemoteSyncStateTable extends RemoteSyncState
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _settlementsCursorMeta = const VerificationMeta(
'settlementsCursor',
);
@override
late final GeneratedColumn<String> settlementsCursor =
GeneratedColumn<String>(
'settlements_cursor',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _lastSuccessfulSyncAtMeta =
const VerificationMeta('lastSuccessfulSyncAt');
@override
@@ -3098,6 +3110,7 @@ class $RemoteSyncStateTable extends RemoteSyncState
List<GeneratedColumn> get $columns => [
deviceId,
serverCursor,
settlementsCursor,
lastSuccessfulSyncAt,
lastAttemptedSyncAt,
consecutiveFailureCount,
@@ -3131,6 +3144,15 @@ class $RemoteSyncStateTable extends RemoteSyncState
),
);
}
if (data.containsKey('settlements_cursor')) {
context.handle(
_settlementsCursorMeta,
settlementsCursor.isAcceptableOrUnknown(
data['settlements_cursor']!,
_settlementsCursorMeta,
),
);
}
if (data.containsKey('last_successful_sync_at')) {
context.handle(
_lastSuccessfulSyncAtMeta,
@@ -3175,6 +3197,10 @@ class $RemoteSyncStateTable extends RemoteSyncState
DriftSqlType.string,
data['${effectivePrefix}server_cursor'],
),
settlementsCursor: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}settlements_cursor'],
),
lastSuccessfulSyncAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}last_successful_sync_at'],
@@ -3200,12 +3226,16 @@ class RemoteSyncStateRow extends DataClass
implements Insertable<RemoteSyncStateRow> {
final String deviceId;
final String? serverCursor;
/// Where the feed of settlements made anywhere in the venue left off.
final String? settlementsCursor;
final DateTime? lastSuccessfulSyncAt;
final DateTime? lastAttemptedSyncAt;
final int consecutiveFailureCount;
const RemoteSyncStateRow({
required this.deviceId,
this.serverCursor,
this.settlementsCursor,
this.lastSuccessfulSyncAt,
this.lastAttemptedSyncAt,
required this.consecutiveFailureCount,
@@ -3217,6 +3247,9 @@ class RemoteSyncStateRow extends DataClass
if (!nullToAbsent || serverCursor != null) {
map['server_cursor'] = Variable<String>(serverCursor);
}
if (!nullToAbsent || settlementsCursor != null) {
map['settlements_cursor'] = Variable<String>(settlementsCursor);
}
if (!nullToAbsent || lastSuccessfulSyncAt != null) {
map['last_successful_sync_at'] = Variable<DateTime>(lastSuccessfulSyncAt);
}
@@ -3233,6 +3266,9 @@ class RemoteSyncStateRow extends DataClass
serverCursor: serverCursor == null && nullToAbsent
? const Value.absent()
: Value(serverCursor),
settlementsCursor: settlementsCursor == null && nullToAbsent
? const Value.absent()
: Value(settlementsCursor),
lastSuccessfulSyncAt: lastSuccessfulSyncAt == null && nullToAbsent
? const Value.absent()
: Value(lastSuccessfulSyncAt),
@@ -3251,6 +3287,9 @@ class RemoteSyncStateRow extends DataClass
return RemoteSyncStateRow(
deviceId: serializer.fromJson<String>(json['deviceId']),
serverCursor: serializer.fromJson<String?>(json['serverCursor']),
settlementsCursor: serializer.fromJson<String?>(
json['settlementsCursor'],
),
lastSuccessfulSyncAt: serializer.fromJson<DateTime?>(
json['lastSuccessfulSyncAt'],
),
@@ -3268,6 +3307,7 @@ class RemoteSyncStateRow extends DataClass
return <String, dynamic>{
'deviceId': serializer.toJson<String>(deviceId),
'serverCursor': serializer.toJson<String?>(serverCursor),
'settlementsCursor': serializer.toJson<String?>(settlementsCursor),
'lastSuccessfulSyncAt': serializer.toJson<DateTime?>(
lastSuccessfulSyncAt,
),
@@ -3281,12 +3321,16 @@ class RemoteSyncStateRow extends DataClass
RemoteSyncStateRow copyWith({
String? deviceId,
Value<String?> serverCursor = const Value.absent(),
Value<String?> settlementsCursor = const Value.absent(),
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
int? consecutiveFailureCount,
}) => RemoteSyncStateRow(
deviceId: deviceId ?? this.deviceId,
serverCursor: serverCursor.present ? serverCursor.value : this.serverCursor,
settlementsCursor: settlementsCursor.present
? settlementsCursor.value
: this.settlementsCursor,
lastSuccessfulSyncAt: lastSuccessfulSyncAt.present
? lastSuccessfulSyncAt.value
: this.lastSuccessfulSyncAt,
@@ -3302,6 +3346,9 @@ class RemoteSyncStateRow extends DataClass
serverCursor: data.serverCursor.present
? data.serverCursor.value
: this.serverCursor,
settlementsCursor: data.settlementsCursor.present
? data.settlementsCursor.value
: this.settlementsCursor,
lastSuccessfulSyncAt: data.lastSuccessfulSyncAt.present
? data.lastSuccessfulSyncAt.value
: this.lastSuccessfulSyncAt,
@@ -3319,6 +3366,7 @@ class RemoteSyncStateRow extends DataClass
return (StringBuffer('RemoteSyncStateRow(')
..write('deviceId: $deviceId, ')
..write('serverCursor: $serverCursor, ')
..write('settlementsCursor: $settlementsCursor, ')
..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ')
..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ')
..write('consecutiveFailureCount: $consecutiveFailureCount')
@@ -3330,6 +3378,7 @@ class RemoteSyncStateRow extends DataClass
int get hashCode => Object.hash(
deviceId,
serverCursor,
settlementsCursor,
lastSuccessfulSyncAt,
lastAttemptedSyncAt,
consecutiveFailureCount,
@@ -3340,6 +3389,7 @@ class RemoteSyncStateRow extends DataClass
(other is RemoteSyncStateRow &&
other.deviceId == this.deviceId &&
other.serverCursor == this.serverCursor &&
other.settlementsCursor == this.settlementsCursor &&
other.lastSuccessfulSyncAt == this.lastSuccessfulSyncAt &&
other.lastAttemptedSyncAt == this.lastAttemptedSyncAt &&
other.consecutiveFailureCount == this.consecutiveFailureCount);
@@ -3348,6 +3398,7 @@ class RemoteSyncStateRow extends DataClass
class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
final Value<String> deviceId;
final Value<String?> serverCursor;
final Value<String?> settlementsCursor;
final Value<DateTime?> lastSuccessfulSyncAt;
final Value<DateTime?> lastAttemptedSyncAt;
final Value<int> consecutiveFailureCount;
@@ -3355,6 +3406,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
const RemoteSyncStateCompanion({
this.deviceId = const Value.absent(),
this.serverCursor = const Value.absent(),
this.settlementsCursor = const Value.absent(),
this.lastSuccessfulSyncAt = const Value.absent(),
this.lastAttemptedSyncAt = const Value.absent(),
this.consecutiveFailureCount = const Value.absent(),
@@ -3363,6 +3415,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
RemoteSyncStateCompanion.insert({
required String deviceId,
this.serverCursor = const Value.absent(),
this.settlementsCursor = const Value.absent(),
this.lastSuccessfulSyncAt = const Value.absent(),
this.lastAttemptedSyncAt = const Value.absent(),
this.consecutiveFailureCount = const Value.absent(),
@@ -3371,6 +3424,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
static Insertable<RemoteSyncStateRow> custom({
Expression<String>? deviceId,
Expression<String>? serverCursor,
Expression<String>? settlementsCursor,
Expression<DateTime>? lastSuccessfulSyncAt,
Expression<DateTime>? lastAttemptedSyncAt,
Expression<int>? consecutiveFailureCount,
@@ -3379,6 +3433,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
return RawValuesInsertable({
if (deviceId != null) 'device_id': deviceId,
if (serverCursor != null) 'server_cursor': serverCursor,
if (settlementsCursor != null) 'settlements_cursor': settlementsCursor,
if (lastSuccessfulSyncAt != null)
'last_successful_sync_at': lastSuccessfulSyncAt,
if (lastAttemptedSyncAt != null)
@@ -3392,6 +3447,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
RemoteSyncStateCompanion copyWith({
Value<String>? deviceId,
Value<String?>? serverCursor,
Value<String?>? settlementsCursor,
Value<DateTime?>? lastSuccessfulSyncAt,
Value<DateTime?>? lastAttemptedSyncAt,
Value<int>? consecutiveFailureCount,
@@ -3400,6 +3456,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
return RemoteSyncStateCompanion(
deviceId: deviceId ?? this.deviceId,
serverCursor: serverCursor ?? this.serverCursor,
settlementsCursor: settlementsCursor ?? this.settlementsCursor,
lastSuccessfulSyncAt: lastSuccessfulSyncAt ?? this.lastSuccessfulSyncAt,
lastAttemptedSyncAt: lastAttemptedSyncAt ?? this.lastAttemptedSyncAt,
consecutiveFailureCount:
@@ -3417,6 +3474,9 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
if (serverCursor.present) {
map['server_cursor'] = Variable<String>(serverCursor.value);
}
if (settlementsCursor.present) {
map['settlements_cursor'] = Variable<String>(settlementsCursor.value);
}
if (lastSuccessfulSyncAt.present) {
map['last_successful_sync_at'] = Variable<DateTime>(
lastSuccessfulSyncAt.value,
@@ -3443,6 +3503,7 @@ class RemoteSyncStateCompanion extends UpdateCompanion<RemoteSyncStateRow> {
return (StringBuffer('RemoteSyncStateCompanion(')
..write('deviceId: $deviceId, ')
..write('serverCursor: $serverCursor, ')
..write('settlementsCursor: $settlementsCursor, ')
..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ')
..write('lastAttemptedSyncAt: $lastAttemptedSyncAt, ')
..write('consecutiveFailureCount: $consecutiveFailureCount, ')
@@ -4060,6 +4121,332 @@ class TabEventsCompanion extends UpdateCompanion<TabEventRow> {
}
}
class $StockAdjustmentResultsTable extends StockAdjustmentResults
with TableInfo<$StockAdjustmentResultsTable, StockAdjustmentResultRow> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$StockAdjustmentResultsTable(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 _acknowledgedMeta = const VerificationMeta(
'acknowledged',
);
@override
late final GeneratedColumn<bool> acknowledged = GeneratedColumn<bool>(
'acknowledged',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("acknowledged" IN (0, 1))',
),
defaultValue: const Constant(false),
);
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, acknowledged, createdAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'stock_adjustment_results';
@override
VerificationContext validateIntegrity(
Insertable<StockAdjustmentResultRow> 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('acknowledged')) {
context.handle(
_acknowledgedMeta,
acknowledged.isAcceptableOrUnknown(
data['acknowledged']!,
_acknowledgedMeta,
),
);
}
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
StockAdjustmentResultRow map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return StockAdjustmentResultRow(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
payload: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}payload'],
)!,
acknowledged: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}acknowledged'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
);
}
@override
$StockAdjustmentResultsTable createAlias(String alias) {
return $StockAdjustmentResultsTable(attachedDatabase, alias);
}
}
class StockAdjustmentResultRow extends DataClass
implements Insertable<StockAdjustmentResultRow> {
/// The adjustment's id from the server.
final String id;
/// The outcome as JSON, exactly as it is sent.
final String payload;
final bool acknowledged;
final DateTime createdAt;
const StockAdjustmentResultRow({
required this.id,
required this.payload,
required this.acknowledged,
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['acknowledged'] = Variable<bool>(acknowledged);
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
StockAdjustmentResultsCompanion toCompanion(bool nullToAbsent) {
return StockAdjustmentResultsCompanion(
id: Value(id),
payload: Value(payload),
acknowledged: Value(acknowledged),
createdAt: Value(createdAt),
);
}
factory StockAdjustmentResultRow.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return StockAdjustmentResultRow(
id: serializer.fromJson<String>(json['id']),
payload: serializer.fromJson<String>(json['payload']),
acknowledged: serializer.fromJson<bool>(json['acknowledged']),
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),
'acknowledged': serializer.toJson<bool>(acknowledged),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
StockAdjustmentResultRow copyWith({
String? id,
String? payload,
bool? acknowledged,
DateTime? createdAt,
}) => StockAdjustmentResultRow(
id: id ?? this.id,
payload: payload ?? this.payload,
acknowledged: acknowledged ?? this.acknowledged,
createdAt: createdAt ?? this.createdAt,
);
StockAdjustmentResultRow copyWithCompanion(
StockAdjustmentResultsCompanion data,
) {
return StockAdjustmentResultRow(
id: data.id.present ? data.id.value : this.id,
payload: data.payload.present ? data.payload.value : this.payload,
acknowledged: data.acknowledged.present
? data.acknowledged.value
: this.acknowledged,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@override
String toString() {
return (StringBuffer('StockAdjustmentResultRow(')
..write('id: $id, ')
..write('payload: $payload, ')
..write('acknowledged: $acknowledged, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, payload, acknowledged, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is StockAdjustmentResultRow &&
other.id == this.id &&
other.payload == this.payload &&
other.acknowledged == this.acknowledged &&
other.createdAt == this.createdAt);
}
class StockAdjustmentResultsCompanion
extends UpdateCompanion<StockAdjustmentResultRow> {
final Value<String> id;
final Value<String> payload;
final Value<bool> acknowledged;
final Value<DateTime> createdAt;
final Value<int> rowid;
const StockAdjustmentResultsCompanion({
this.id = const Value.absent(),
this.payload = const Value.absent(),
this.acknowledged = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
StockAdjustmentResultsCompanion.insert({
required String id,
required String payload,
this.acknowledged = const Value.absent(),
required DateTime createdAt,
this.rowid = const Value.absent(),
}) : id = Value(id),
payload = Value(payload),
createdAt = Value(createdAt);
static Insertable<StockAdjustmentResultRow> custom({
Expression<String>? id,
Expression<String>? payload,
Expression<bool>? acknowledged,
Expression<DateTime>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (payload != null) 'payload': payload,
if (acknowledged != null) 'acknowledged': acknowledged,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
}
StockAdjustmentResultsCompanion copyWith({
Value<String>? id,
Value<String>? payload,
Value<bool>? acknowledged,
Value<DateTime>? createdAt,
Value<int>? rowid,
}) {
return StockAdjustmentResultsCompanion(
id: id ?? this.id,
payload: payload ?? this.payload,
acknowledged: acknowledged ?? this.acknowledged,
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 (acknowledged.present) {
map['acknowledged'] = Variable<bool>(acknowledged.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('StockAdjustmentResultsCompanion(')
..write('id: $id, ')
..write('payload: $payload, ')
..write('acknowledged: $acknowledged, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -4080,6 +4467,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $ProcessedRemoteCommandsTable processedRemoteCommands =
$ProcessedRemoteCommandsTable(this);
late final $TabEventsTable tabEvents = $TabEventsTable(this);
late final $StockAdjustmentResultsTable stockAdjustmentResults =
$StockAdjustmentResultsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -4095,6 +4484,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
remoteSyncState,
processedRemoteCommands,
tabEvents,
stockAdjustmentResults,
];
@override
StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([
@@ -6355,6 +6745,7 @@ typedef $$RemoteSyncStateTableCreateCompanionBuilder =
RemoteSyncStateCompanion Function({
required String deviceId,
Value<String?> serverCursor,
Value<String?> settlementsCursor,
Value<DateTime?> lastSuccessfulSyncAt,
Value<DateTime?> lastAttemptedSyncAt,
Value<int> consecutiveFailureCount,
@@ -6364,6 +6755,7 @@ typedef $$RemoteSyncStateTableUpdateCompanionBuilder =
RemoteSyncStateCompanion Function({
Value<String> deviceId,
Value<String?> serverCursor,
Value<String?> settlementsCursor,
Value<DateTime?> lastSuccessfulSyncAt,
Value<DateTime?> lastAttemptedSyncAt,
Value<int> consecutiveFailureCount,
@@ -6389,6 +6781,11 @@ class $$RemoteSyncStateTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get settlementsCursor => $composableBuilder(
column: $table.settlementsCursor,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
column: $table.lastSuccessfulSyncAt,
builder: (column) => ColumnFilters(column),
@@ -6424,6 +6821,11 @@ class $$RemoteSyncStateTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get settlementsCursor => $composableBuilder(
column: $table.settlementsCursor,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
column: $table.lastSuccessfulSyncAt,
builder: (column) => ColumnOrderings(column),
@@ -6457,6 +6859,11 @@ class $$RemoteSyncStateTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<String> get settlementsCursor => $composableBuilder(
column: $table.settlementsCursor,
builder: (column) => column,
);
GeneratedColumn<DateTime> get lastSuccessfulSyncAt => $composableBuilder(
column: $table.lastSuccessfulSyncAt,
builder: (column) => column,
@@ -6512,6 +6919,7 @@ class $$RemoteSyncStateTableTableManager
({
Value<String> deviceId = const Value.absent(),
Value<String?> serverCursor = const Value.absent(),
Value<String?> settlementsCursor = const Value.absent(),
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
Value<int> consecutiveFailureCount = const Value.absent(),
@@ -6519,6 +6927,7 @@ class $$RemoteSyncStateTableTableManager
}) => RemoteSyncStateCompanion(
deviceId: deviceId,
serverCursor: serverCursor,
settlementsCursor: settlementsCursor,
lastSuccessfulSyncAt: lastSuccessfulSyncAt,
lastAttemptedSyncAt: lastAttemptedSyncAt,
consecutiveFailureCount: consecutiveFailureCount,
@@ -6528,6 +6937,7 @@ class $$RemoteSyncStateTableTableManager
({
required String deviceId,
Value<String?> serverCursor = const Value.absent(),
Value<String?> settlementsCursor = const Value.absent(),
Value<DateTime?> lastSuccessfulSyncAt = const Value.absent(),
Value<DateTime?> lastAttemptedSyncAt = const Value.absent(),
Value<int> consecutiveFailureCount = const Value.absent(),
@@ -6535,6 +6945,7 @@ class $$RemoteSyncStateTableTableManager
}) => RemoteSyncStateCompanion.insert(
deviceId: deviceId,
serverCursor: serverCursor,
settlementsCursor: settlementsCursor,
lastSuccessfulSyncAt: lastSuccessfulSyncAt,
lastAttemptedSyncAt: lastAttemptedSyncAt,
consecutiveFailureCount: consecutiveFailureCount,
@@ -6936,6 +7347,208 @@ typedef $$TabEventsTableProcessedTableManager =
TabEventRow,
PrefetchHooks Function()
>;
typedef $$StockAdjustmentResultsTableCreateCompanionBuilder =
StockAdjustmentResultsCompanion Function({
required String id,
required String payload,
Value<bool> acknowledged,
required DateTime createdAt,
Value<int> rowid,
});
typedef $$StockAdjustmentResultsTableUpdateCompanionBuilder =
StockAdjustmentResultsCompanion Function({
Value<String> id,
Value<String> payload,
Value<bool> acknowledged,
Value<DateTime> createdAt,
Value<int> rowid,
});
class $$StockAdjustmentResultsTableFilterComposer
extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> {
$$StockAdjustmentResultsTableFilterComposer({
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<bool> get acknowledged => $composableBuilder(
column: $table.acknowledged,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
}
class $$StockAdjustmentResultsTableOrderingComposer
extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> {
$$StockAdjustmentResultsTableOrderingComposer({
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<bool> get acknowledged => $composableBuilder(
column: $table.acknowledged,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$StockAdjustmentResultsTableAnnotationComposer
extends Composer<_$AppDatabase, $StockAdjustmentResultsTable> {
$$StockAdjustmentResultsTableAnnotationComposer({
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<bool> get acknowledged => $composableBuilder(
column: $table.acknowledged,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$StockAdjustmentResultsTableTableManager
extends
RootTableManager<
_$AppDatabase,
$StockAdjustmentResultsTable,
StockAdjustmentResultRow,
$$StockAdjustmentResultsTableFilterComposer,
$$StockAdjustmentResultsTableOrderingComposer,
$$StockAdjustmentResultsTableAnnotationComposer,
$$StockAdjustmentResultsTableCreateCompanionBuilder,
$$StockAdjustmentResultsTableUpdateCompanionBuilder,
(
StockAdjustmentResultRow,
BaseReferences<
_$AppDatabase,
$StockAdjustmentResultsTable,
StockAdjustmentResultRow
>,
),
StockAdjustmentResultRow,
PrefetchHooks Function()
> {
$$StockAdjustmentResultsTableTableManager(
_$AppDatabase db,
$StockAdjustmentResultsTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$StockAdjustmentResultsTableFilterComposer(
$db: db,
$table: table,
),
createOrderingComposer: () =>
$$StockAdjustmentResultsTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
$$StockAdjustmentResultsTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<String> payload = const Value.absent(),
Value<bool> acknowledged = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => StockAdjustmentResultsCompanion(
id: id,
payload: payload,
acknowledged: acknowledged,
createdAt: createdAt,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required String payload,
Value<bool> acknowledged = const Value.absent(),
required DateTime createdAt,
Value<int> rowid = const Value.absent(),
}) => StockAdjustmentResultsCompanion.insert(
id: id,
payload: payload,
acknowledged: acknowledged,
createdAt: createdAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$StockAdjustmentResultsTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$StockAdjustmentResultsTable,
StockAdjustmentResultRow,
$$StockAdjustmentResultsTableFilterComposer,
$$StockAdjustmentResultsTableOrderingComposer,
$$StockAdjustmentResultsTableAnnotationComposer,
$$StockAdjustmentResultsTableCreateCompanionBuilder,
$$StockAdjustmentResultsTableUpdateCompanionBuilder,
(
StockAdjustmentResultRow,
BaseReferences<
_$AppDatabase,
$StockAdjustmentResultsTable,
StockAdjustmentResultRow
>,
),
StockAdjustmentResultRow,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@@ -6963,4 +7576,9 @@ class $AppDatabaseManager {
);
$$TabEventsTableTableManager get tabEvents =>
$$TabEventsTableTableManager(_db, _db.tabEvents);
$$StockAdjustmentResultsTableTableManager get stockAdjustmentResults =>
$$StockAdjustmentResultsTableTableManager(
_db,
_db.stockAdjustmentResults,
);
}
+116 -34
View File
@@ -1,14 +1,11 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../models/remote_command.dart';
import '../models/tab_event.dart';
import 'app_database.dart';
class InsufficientStockException implements Exception {
const InsufficientStockException();
@override
String toString() => 'Not enough stock';
}
class ProductNotFoundException implements Exception {
final String productId;
@@ -20,44 +17,129 @@ class ProductNotFoundException implements Exception {
/// Atomic stock updates shared by the product and tab services, so they can
/// run inside either one's transaction.
///
/// Stock may go below zero: the bar keeps serving when the count was off.
extension StockQueries on AppDatabase {
/// Products with infinite stock are only checked for existence.
Future<void> decreaseStock(String productId, int amount) async {
_validateStockAmount(amount);
final updatedRows =
await (update(products)..where(
(row) =>
row.id.equals(productId) &
row.infiniteStock.not() &
row.stockQuantity.isBiggerOrEqualValue(amount),
))
.write(
ProductsCompanion.custom(
stockQuantity: products.stockQuantity - Variable(amount),
),
);
if (updatedRows == 1) return;
final product = await _findProduct(productId);
if (!product.infiniteStock) throw const InsufficientStockException();
if (await changeTrackedStock(productId, -amount) == 0) {
await _findProduct(productId);
}
}
Future<void> increaseStock(String productId, int amount) async {
_validateStockAmount(amount);
final updatedRows =
await (update(products)..where(
(row) => row.id.equals(productId) & row.infiniteStock.not(),
))
.write(
ProductsCompanion.custom(
stockQuantity: products.stockQuantity + Variable(amount),
),
);
if (await changeTrackedStock(productId, amount) == 0) {
await _findProduct(productId);
}
}
if (updatedRows != 1) await _findProduct(productId);
/// Adds [delta] to the stock of [productId] when its stock is tracked.
/// Returns the number of products changed: 0 when the product has infinite
/// stock or no longer exists, which is not an error.
Future<int> changeTrackedStock(String productId, int delta) {
return (update(products)
..where((row) => row.id.equals(productId) & row.infiniteStock.not()))
.write(
ProductsCompanion.custom(
stockQuantity: products.stockQuantity + Variable(delta),
),
);
}
/// Queues a `stock_set` event, so a stock count or tracking change made on
/// this tablet reaches the server. Call it inside the transaction that
/// writes the stock.
Future<void> queueStockSet(TabEvent event) async {
await into(tabEvents).insert(
TabEventsCompanion.insert(
id: event.id,
payload: jsonEncode(event.payload),
createdAt: DateTime.now(),
),
);
}
/// Replaces the local stock of the products in [serverStock] with the
/// server's, then replays the outbox events the server hasn't applied yet
/// on top, the way the server will. The events the server acknowledged
/// must have left the outbox first: they are in [serverStock] already.
///
/// Products the server doesn't know keep their local stock.
Future<void> rebaseStock(List<ServerStock> serverStock) {
return transaction(() async {
final stock = {
for (final entry in serverStock)
entry.productId: (stock: entry.stock, countedAt: entry.countedAt),
};
// Read here, so changes made while the sync was in flight are included
final outbox =
await (select(tabEvents)..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
]))
.get();
for (final row in outbox) {
final event = jsonDecode(row.payload) as Map<String, dynamic>;
final productId = event['productId'] as String?;
final current = stock[productId];
if (productId == null || current == null) continue;
final occurredAt = DateTime.tryParse(
event['occurredAt'] as String? ?? '',
);
if (occurredAt == null) continue;
final countedAt = current.countedAt;
switch (TabEventType.fromValue(event['type'] as String? ?? '')) {
// An older count than the server's is outdated
case TabEventType.stockSet:
if (countedAt != null && occurredAt.isBefore(countedAt)) continue;
stock[productId] = (
stock: event['quantity'] as int?,
countedAt: occurredAt,
);
// A sale before the count is already in the count
case TabEventType.lineAdded || TabEventType.lineRemoved
when current.stock != null &&
(countedAt == null || occurredAt.isAfter(countedAt)):
final quantity = event['quantity'] as int? ?? 0;
final sign = event['type'] == TabEventType.lineAdded.value ? -1 : 1;
stock[productId] = (
stock: current.stock! + sign * quantity,
countedAt: countedAt,
);
default:
continue;
}
}
for (final product in await select(products).get()) {
final rebased = stock[product.id];
if (rebased == null) continue;
final infinite = rebased.stock == null;
// An untracked product keeps its last count for when tracking resumes
final quantity = rebased.stock ?? product.stockQuantity;
if (product.infiniteStock == infinite &&
product.stockQuantity == quantity) {
continue;
}
await (update(
products,
)..where((row) => row.id.equals(product.id))).write(
ProductsCompanion(
stockQuantity: Value(quantity),
infiniteStock: Value(infinite),
),
);
}
});
}
Future<ProductRow> _findProduct(String productId) async {
+178
View File
@@ -1,4 +1,5 @@
import '../utils/app_config.dart';
import 'server_tab.dart';
enum CommandType {
closeTab('close_tab');
@@ -74,6 +75,88 @@ class RejectedEvent {
}
}
enum StockAdjustmentType {
/// `stock += quantity`; quantity may be negative (breakage, own use).
add('add'),
/// `stock = quantity`, a stock count. Starts tracking an untracked product.
set('set');
final String value;
const StockAdjustmentType(this.value);
static StockAdjustmentType? fromValue(String value) {
for (final type in StockAdjustmentType.values) {
if (type.value == value) return type;
}
return null;
}
}
/// A stock change made on the dashboard. The server resends it on every sync
/// until it has received the outcome, so it must be applied only once.
class StockAdjustment {
final String id;
final String productId;
final String rawType;
final int quantity;
final String? note;
final String requestedBy;
final DateTime createdAt;
const StockAdjustment({
required this.id,
required this.productId,
required this.rawType,
required this.quantity,
this.note,
required this.requestedBy,
required this.createdAt,
});
factory StockAdjustment.fromJson(Map<String, dynamic> json) {
return StockAdjustment(
id: json['id'] as String,
productId: json['productId'] as String,
rawType: json['type'] as String,
quantity: json['quantity'] as int,
note: json['note'] as String?,
requestedBy: json['requestedBy'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Null for a type this app version doesn't know; such an adjustment is
/// neither applied nor reported.
StockAdjustmentType? get type => StockAdjustmentType.fromValue(rawType);
}
/// The server's stock of one product, after applying the events it
/// acknowledged. The server owns the stock once it sends these.
class ServerStock {
final String productId;
/// Null when the stock of this product is not tracked. May be negative.
final int? stock;
/// When the stock was last counted (set). Line events that happened before
/// it are already in the count and don't change the stock.
final DateTime? countedAt;
const ServerStock({required this.productId, this.stock, this.countedAt});
factory ServerStock.fromJson(Map<String, dynamic> json) {
final countedAt = json['countedAt'] as String?;
return ServerStock(
productId: json['productId'] as String,
stock: json['stock'] as int?,
countedAt: countedAt == null ? null : DateTime.parse(countedAt),
);
}
}
class SyncResponse {
final List<RemoteCommand> commands;
final String? cursor;
@@ -81,12 +164,38 @@ class SyncResponse {
final List<String> acknowledgedEventIds;
final List<RejectedEvent> rejectedEvents;
/// Oldest first.
final List<StockAdjustment> stockAdjustments;
final List<String> acknowledgedStockAdjustmentResultIds;
final List<RejectedEvent> rejectedStockAdjustmentResults;
/// The venue's open tabs after applying the acknowledged events: the
/// source of truth. Null from a server that doesn't hold the tabs yet.
final List<ServerTab>? tabs;
/// Settlements made anywhere in the venue since [settlementsCursor] was
/// last sent, oldest first.
final List<ServerSettlement> settlements;
final String? settlementsCursor;
/// The stock of the venue's products: the source of truth. Null from a
/// server that doesn't own the stock yet; the tablet then counts its own
/// sales and applies [stockAdjustments] itself.
final List<ServerStock>? stock;
const SyncResponse({
required this.commands,
this.cursor,
this.plannedTabNames = const [],
this.acknowledgedEventIds = const [],
this.rejectedEvents = const [],
this.stockAdjustments = const [],
this.acknowledgedStockAdjustmentResultIds = const [],
this.rejectedStockAdjustmentResults = const [],
this.tabs,
this.settlements = const [],
this.settlementsCursor,
this.stock,
});
factory SyncResponse.fromJson(Map<String, dynamic> json) {
@@ -108,8 +217,61 @@ class SyncResponse {
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(event as Map<String, dynamic>),
],
stockAdjustments: [
for (final adjustment
in json['stockAdjustments'] as List<dynamic>? ?? [])
?_parseStockAdjustment(adjustment),
],
acknowledgedStockAdjustmentResultIds: [
for (final id
in json['acknowledgedStockAdjustmentResultIds'] as List<dynamic>? ??
[])
id as String,
],
rejectedStockAdjustmentResults: [
for (final result
in json['rejectedStockAdjustmentResults'] as List<dynamic>? ?? [])
RejectedEvent.fromJson(result as Map<String, dynamic>),
],
tabs: switch (json['tabs']) {
final List<dynamic> tabs => [
for (final tab in tabs)
ServerTab.fromJson(tab as Map<String, dynamic>),
],
_ => null,
},
settlements: [
for (final settlement in json['settlements'] as List<dynamic>? ?? [])
ServerSettlement.fromJson(settlement as Map<String, dynamic>),
],
settlementsCursor: json['settlementsCursor'] as String?,
stock: switch (json['stock']) {
final List<dynamic> stock => [
for (final entry in stock) ?_parseServerStock(entry),
],
_ => null,
},
);
}
/// A malformed entry only leaves that product's local stock as it is.
static ServerStock? _parseServerStock(Object? json) {
try {
return ServerStock.fromJson(json! as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// A malformed adjustment is skipped instead of failing every sync from
/// now on, since the server keeps sending it.
static StockAdjustment? _parseStockAdjustment(Object? json) {
try {
return StockAdjustment.fromJson(json! as Map<String, dynamic>);
} catch (_) {
return null;
}
}
}
/// One line on a tab as shown in the dashboard, e.g. "10× Cola".
@@ -166,11 +328,24 @@ class SyncRequest {
/// Outbox tab events, oldest first, as their stored JSON objects.
final List<Map<String, dynamic>> events;
/// The complete product catalog with stock: a product left out is removed
/// from the dashboard.
final List<Map<String, dynamic>> products;
/// Unacknowledged stock adjustment outcomes, oldest first.
final List<Map<String, dynamic>> stockAdjustmentResults;
/// Where the settlement feed left off; null asks for the recent ones.
final String? settlementsCursor;
const SyncRequest({
required this.deviceId,
this.cursor,
required this.openTabs,
this.events = const [],
this.products = const [],
this.stockAdjustmentResults = const [],
this.settlementsCursor,
});
Map<String, dynamic> toJson() => {
@@ -178,6 +353,9 @@ class SyncRequest {
if (cursor != null) 'cursor': cursor,
'openTabs': openTabs.map((tab) => tab.toJson()).toList(),
'events': events,
'products': products,
'stockAdjustmentResults': stockAdjustmentResults,
if (settlementsCursor != null) 'settlementsCursor': settlementsCursor,
};
}
+122
View File
@@ -0,0 +1,122 @@
import 'payment_method.dart';
/// One piece on a line: when it was put on the tab and, if it was taken off
/// again, when. Kept so history can show every drink with its time.
class ServerUnit {
/// Stable across syncs: `<event id>:<index within that event>`.
final String id;
final DateTime addedAt;
final DateTime? removedAt;
const ServerUnit({required this.id, required this.addedAt, this.removedAt});
factory ServerUnit.fromJson(Map<String, dynamic> json) {
return ServerUnit(
id: json['id'] as String,
addedAt: DateTime.parse(json['addedAt'] as String).toLocal(),
removedAt: switch (json['removedAt']) {
final String value => DateTime.parse(value).toLocal(),
_ => null,
},
);
}
}
class ServerTabLine {
final String id;
final String productId;
final String productName;
final int unitPriceInCents;
final int quantity;
final List<ServerUnit> units;
const ServerTabLine({
required this.id,
required this.productId,
required this.productName,
required this.unitPriceInCents,
required this.quantity,
this.units = const [],
});
factory ServerTabLine.fromJson(Map<String, dynamic> json) {
return ServerTabLine(
id: json['id'] as String,
productId: json['productId'] as String? ?? '',
productName: json['productName'] as String,
unitPriceInCents: json['unitPriceInCents'] as int,
quantity: json['quantity'] as int,
units: [
for (final unit in json['units'] as List<dynamic>? ?? [])
ServerUnit.fromJson(unit as Map<String, dynamic>),
],
);
}
}
/// An open tab as the server holds it: the venue's source of truth.
class ServerTab {
final String id;
final String customerName;
final DateTime openedAt;
final int version;
final List<ServerTabLine> lines;
const ServerTab({
required this.id,
required this.customerName,
required this.openedAt,
required this.version,
this.lines = const [],
});
factory ServerTab.fromJson(Map<String, dynamic> json) {
return ServerTab(
id: json['id'] as String,
customerName: json['customerName'] as String,
openedAt: DateTime.parse(json['openedAt'] as String).toLocal(),
version: json['version'] as int? ?? 0,
lines: [
for (final line in json['lines'] as List<dynamic>? ?? [])
ServerTabLine.fromJson(line as Map<String, dynamic>),
],
);
}
}
/// A tab settled anywhere in the venue (this tablet, another tablet or the
/// dashboard), for the local history.
class ServerSettlement {
/// For a settlement made on a tablet: the id of its `settled` event.
final String id;
final String tabId;
final String customerName;
final DateTime settledAt;
final PaymentMethod paymentMethod;
final List<ServerTabLine> lines;
const ServerSettlement({
required this.id,
required this.tabId,
required this.customerName,
required this.settledAt,
required this.paymentMethod,
this.lines = const [],
});
factory ServerSettlement.fromJson(Map<String, dynamic> json) {
return ServerSettlement(
id: json['id'] as String,
tabId: json['tabId'] as String,
customerName: json['customerName'] as String,
settledAt: DateTime.parse(json['settledAt'] as String).toLocal(),
paymentMethod: PaymentMethod.fromValue(
json['paymentMethod'] as String? ?? 'cash',
),
lines: [
for (final line in json['lines'] as List<dynamic>? ?? [])
ServerTabLine.fromJson(line as Map<String, dynamic>),
],
);
}
}
+85 -5
View File
@@ -4,18 +4,33 @@ import 'payment_method.dart';
/// Types of [TabEvent] the server understands.
enum TabEventType {
tabOpened('tab_opened'),
tabRenamed('tab_renamed'),
tabDeleted('tab_deleted'),
lineAdded('line_added'),
lineRemoved('line_removed'),
settled('settled');
settled('settled'),
/// A stock count or tracking change made on the tablet. Not about a tab:
/// it has no `tabId`.
stockSet('stock_set');
final String value;
const TabEventType(this.value);
static TabEventType? fromValue(String value) {
for (final type in TabEventType.values) {
if (type.value == value) return type;
}
return null;
}
}
/// 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.
/// Something that happened on a tab. Together the events form the operation
/// log the server builds the venue's tabs from, and the tablet replays the
/// ones the server hasn't applied yet on top of the server's tabs. [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;
@@ -25,6 +40,7 @@ class TabEvent {
/// One settled line in a `settled` event.
typedef SettledLine = ({
String productId,
String productName,
int quantity,
int unitPriceInCents,
@@ -37,10 +53,46 @@ class TabEventFactory {
TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now;
/// [quantity] is how many pieces were added; always positive.
TabEvent tabOpened({
required String tabId,
required String customerName,
required DateTime openedAt,
}) {
return _event(
TabEventType.tabOpened,
tabId: tabId,
customerName: customerName,
fields: {'openedAt': openedAt.toUtc().toIso8601String()},
);
}
/// [customerName] is the new name.
TabEvent tabRenamed({required String tabId, required String customerName}) {
return _event(
TabEventType.tabRenamed,
tabId: tabId,
customerName: customerName,
fields: const {},
);
}
/// Written after a `line_removed` for every line still on the tab.
TabEvent tabDeleted({required String tabId, required String customerName}) {
return _event(
TabEventType.tabDeleted,
tabId: tabId,
customerName: customerName,
fields: const {},
);
}
/// [quantity] is how many pieces were added; always positive. [lineId] is
/// the local line, so the server can keep its id.
TabEvent lineAdded({
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int quantity,
@@ -49,6 +101,8 @@ class TabEventFactory {
TabEventType.lineAdded,
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
@@ -59,6 +113,8 @@ class TabEventFactory {
TabEvent lineRemoved({
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int quantity,
@@ -67,6 +123,8 @@ class TabEventFactory {
TabEventType.lineRemoved,
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
@@ -92,6 +150,7 @@ class TabEventFactory {
'items': [
for (final item in items)
{
'productId': item.productId,
'productName': item.productName,
'quantity': item.quantity,
'unitPriceInCents': item.unitPriceInCents,
@@ -101,6 +160,23 @@ class TabEventFactory {
);
}
/// The stock of [productId] was set to [quantity] on this tablet (a count,
/// or a new product's starting stock); null stops tracking it.
TabEvent stockSet({required String productId, required int? quantity}) {
final id = _uuid.v4();
return TabEvent(
id: id,
payload: {
'id': id,
'type': TabEventType.stockSet.value,
'productId': productId,
'quantity': quantity,
'occurredAt': _now().toUtc().toIso8601String(),
},
);
}
/// The server's payment method names: `cash`, `payconiq`,
/// `payment_pending` or `other`.
static String paymentMethodValue(PaymentMethod method) {
@@ -115,6 +191,8 @@ class TabEventFactory {
TabEventType type, {
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int quantity,
@@ -128,6 +206,8 @@ class TabEventFactory {
tabId: tabId,
customerName: customerName,
fields: {
'lineId': lineId,
'productId': productId,
'productName': productName,
'unitPriceInCents': unitPriceInCents,
'quantity': quantity,
+480 -92
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
@@ -11,6 +12,7 @@ import '../models/closed_tab.dart';
import '../models/closed_tab_item.dart';
import '../models/payment_method.dart';
import '../models/product.dart';
import '../models/server_tab.dart';
import '../models/tab_event.dart';
import '../models/tab_item.dart';
import '../models/tab_item_purchase.dart';
@@ -91,6 +93,16 @@ abstract class BarTabService {
Future<int> getClosedTabCount({String? customerName});
Future<List<String>> getDistinctCustomerNames();
/// Replaces the open tabs with the server's [tabs], the venue's source of
/// truth, and replays the changes the server hasn't applied yet (the
/// events still in the outbox) on top. Stock is left alone: the local
/// changes already counted it.
Future<void> applyServerState(List<ServerTab> tabs);
/// Adds settlements made anywhere in the venue to the history. Settlements
/// already in it (such as this tablet's own) are skipped.
Future<void> recordSettlements(List<ServerSettlement> settlements);
}
class DriftBarTabService implements BarTabService {
@@ -226,7 +238,11 @@ class DriftBarTabService implements BarTabService {
/// Marks the most recent active purchases as removed, keeping them in the
/// audit trail.
Future<void> _removeLatestPurchases(String tabItemId, int count) async {
Future<void> _removeLatestPurchases(
String tabItemId,
int count, {
DateTime? removedAt,
}) async {
final latest =
await (database.select(database.tabItemPurchases)
..where(
@@ -243,7 +259,18 @@ class DriftBarTabService implements BarTabService {
await (database.update(database.tabItemPurchases)
..where((purchase) => purchase.id.isIn(latest.map((row) => row.id))))
.write(TabItemPurchasesCompanion(removedAt: Value(DateTime.now())));
.write(
TabItemPurchasesCompanion(
removedAt: Value(removedAt ?? DateTime.now()),
),
);
}
Future<void> _deleteTabWithItems(String tabId) async {
await _deleteItemsOfTab(tabId);
await (database.delete(
database.barTabs,
)..where((row) => row.id.equals(tabId))).go();
}
/// Foreign keys aren't enforced, so purchases are deleted explicitly.
@@ -276,6 +303,36 @@ class DriftBarTabService implements BarTabService {
);
}
/// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0)
/// event and moves the stock of [productId], the product the line was
/// created from, the other way. Every line event goes through here, so a
/// line event and its stock change can never drift apart.
Future<void> _recordLineChange({
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int delta,
}) async {
final lineEvent = delta > 0
? eventFactory.lineAdded
: eventFactory.lineRemoved;
await _recordEvent(
lineEvent(
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: delta.abs(),
),
);
await database.changeTrackedStock(productId, -delta);
}
Future<void> _incrementTabVersion(String tabId) async {
await (database.update(
database.barTabs,
@@ -300,9 +357,11 @@ class DriftBarTabService implements BarTabService {
@override
Stream<List<BarTab>> watchOpenTabs() {
return _tabsQuery().watch().map(
(rows) => _groupTabs(rows, includeRemoved: false),
);
return _tabsQuery()
.watch()
.map((rows) => _groupTabs(rows, includeRemoved: false))
// A sync rewrites the tabs even when nothing changed
.distinct(listEquals);
}
@override
@@ -320,17 +379,28 @@ class DriftBarTabService implements BarTabService {
@override
Future<BarTab> createTab({required String customerName}) async {
final id = _uuid.v4();
final name = _validateCustomerName(customerName);
final openedAt = DateTime.now();
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: id,
customerName: _validateCustomerName(customerName),
openedAt: DateTime.now(),
version: const Value(1),
),
);
await database.transaction(() async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: id,
customerName: name,
openedAt: openedAt,
version: const Value(1),
),
);
await _recordEvent(
eventFactory.tabOpened(
tabId: id,
customerName: name,
openedAt: openedAt,
),
);
});
return (await getTabById(id))!;
}
@@ -350,6 +420,9 @@ class DriftBarTabService implements BarTabService {
if (updatedRows != 1) throw TabNotFoundException(tabId);
await _recordEvent(
eventFactory.tabRenamed(tabId: tabId, customerName: name),
);
await _incrementTabVersion(tabId);
});
}
@@ -361,22 +434,21 @@ class DriftBarTabService implements BarTabService {
if (tab == null) return;
for (final item in tab.items) {
await database.increaseStock(item.productId, item.quantity);
await _recordEvent(
eventFactory.lineRemoved(
tabId: tab.id,
customerName: tab.customerName,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
),
await _recordLineChange(
tabId: tab.id,
customerName: tab.customerName,
lineId: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
delta: -item.quantity,
);
}
await _recordEvent(
eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName),
);
await _deleteItemsOfTab(tabId);
await (database.delete(
database.barTabs,
)..where((row) => row.id.equals(tabId))).go();
await _deleteTabWithItems(tabId);
});
}
@@ -389,14 +461,17 @@ class DriftBarTabService implements BarTabService {
final tabRow = await _getTabRow(tabId);
if (tabRow == null) throw TabNotFoundException(tabId);
await database.decreaseStock(product.id, 1);
// A merge on the server can leave two lines of one product at
// different prices; new drinks go on the oldest
final existingItem =
await (database.select(database.tabItems)..where(
(item) =>
item.tabId.equals(tabId) &
item.productId.equals(product.id),
))
await (database.select(database.tabItems)
..where(
(item) =>
item.tabId.equals(tabId) &
item.productId.equals(product.id),
)
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
..limit(1))
.getSingleOrNull();
if (existingItem != null) {
@@ -407,14 +482,14 @@ class DriftBarTabService implements BarTabService {
);
await _recordPurchase(existingItem.id);
// The line keeps the price it was first added at
await _recordEvent(
eventFactory.lineAdded(
tabId: tabId,
customerName: tabRow.customerName,
productName: existingItem.productName,
unitPriceInCents: existingItem.unitPriceInCents,
quantity: 1,
),
await _recordLineChange(
tabId: tabId,
customerName: tabRow.customerName,
lineId: existingItem.id,
productId: existingItem.productId,
productName: existingItem.productName,
unitPriceInCents: existingItem.unitPriceInCents,
delta: 1,
);
} else {
final tabItemId = _uuid.v4();
@@ -433,14 +508,14 @@ class DriftBarTabService implements BarTabService {
),
);
await _recordPurchase(tabItemId);
await _recordEvent(
eventFactory.lineAdded(
tabId: tabId,
customerName: tabRow.customerName,
productName: product.name,
unitPriceInCents: product.priceInCents,
quantity: 1,
),
await _recordLineChange(
tabId: tabId,
customerName: tabRow.customerName,
lineId: tabItemId,
productId: product.id,
productName: product.name,
unitPriceInCents: product.priceInCents,
delta: 1,
);
}
@@ -468,12 +543,10 @@ class DriftBarTabService implements BarTabService {
if (actualDelta == 0) return 0;
if (actualDelta > 0) {
await database.decreaseStock(item.productId, actualDelta);
for (var index = 0; index < actualDelta; index++) {
await _recordPurchase(item.id);
}
} else {
await database.increaseStock(item.productId, -actualDelta);
await _removeLatestPurchases(item.id, -actualDelta);
}
@@ -482,17 +555,14 @@ class DriftBarTabService implements BarTabService {
.write(TabItemsCompanion(quantity: Value(newQuantity)));
final tabRow = await _getTabRow(item.tabId);
final lineEvent = actualDelta > 0
? eventFactory.lineAdded
: eventFactory.lineRemoved;
await _recordEvent(
lineEvent(
tabId: item.tabId,
customerName: tabRow?.customerName ?? '',
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: actualDelta.abs(),
),
await _recordLineChange(
tabId: item.tabId,
customerName: tabRow?.customerName ?? '',
lineId: item.id,
productId: item.productId,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
delta: actualDelta,
);
await _incrementTabVersion(item.tabId);
@@ -524,7 +594,36 @@ class DriftBarTabService implements BarTabService {
if (items.isEmpty) return;
final closedTabId = _uuid.v4();
// One settlement, not a removal per line: these drinks were sold
final settledItems = [
for (final item in items)
if (item.quantity > 0) item,
];
final settledEvent = settledItems.isEmpty
? null
: eventFactory.settled(
tabId: tabId,
customerName: tabRow.customerName,
amountInCents: settledItems.fold(
0,
(total, item) => total + item.lineTotalInCents,
),
paymentMethod: paymentMethod,
commandId: commandId,
items: [
for (final item in settledItems)
(
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPriceInCents: item.unitPriceInCents,
),
],
);
// The server reports this settlement back under the event's id, so it
// isn't added to history twice
final closedTabId = settledEvent?.id ?? _uuid.v4();
await database
.into(database.closedTabs)
@@ -555,33 +654,7 @@ class DriftBarTabService implements BarTabService {
]);
});
// One settlement, not a removal per line: these drinks were sold
final settledItems = [
for (final item in items)
if (item.quantity > 0) item,
];
if (settledItems.isNotEmpty) {
await _recordEvent(
eventFactory.settled(
tabId: tabId,
customerName: tabRow.customerName,
amountInCents: settledItems.fold(
0,
(total, item) => total + item.lineTotalInCents,
),
paymentMethod: paymentMethod,
commandId: commandId,
items: [
for (final item in settledItems)
(
productName: item.productName,
quantity: item.quantity,
unitPriceInCents: item.unitPriceInCents,
),
],
),
);
}
if (settledEvent != null) await _recordEvent(settledEvent);
await _deleteItemsOfTab(tabId);
await _incrementTabVersion(tabId);
@@ -679,4 +752,319 @@ class DriftBarTabService implements BarTabService {
final rows = await query.get();
return rows.map((row) => row.read(customerName)!).toList();
}
@override
Future<void> applyServerState(List<ServerTab> tabs) async {
await database.transaction(() async {
for (final tab in await database.select(database.barTabs).get()) {
await _deleteTabWithItems(tab.id);
}
for (final tab in tabs) {
await _insertServerTab(tab);
}
// Read here, so changes made while the sync was in flight are included
final outbox =
await (database.select(database.tabEvents)..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
]))
.get();
for (final row in outbox) {
await _replayEvent(jsonDecode(row.payload) as Map<String, dynamic>);
}
});
}
Future<void> _insertServerTab(ServerTab tab) async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: tab.id,
customerName: tab.customerName,
openedAt: tab.openedAt,
version: Value(tab.version),
),
);
for (final line in tab.lines) {
final createdAt = line.units.isEmpty
? tab.openedAt
: line.units
.map((unit) => unit.addedAt)
.reduce((a, b) => a.isBefore(b) ? a : b);
await database
.into(database.tabItems)
.insert(
TabItemsCompanion.insert(
id: line.id,
tabId: tab.id,
productId: line.productId,
productName: line.productName,
quantity: line.quantity,
unitPriceInCents: line.unitPriceInCents,
createdAt: createdAt,
),
);
await database.batch((batch) {
batch.insertAll(database.tabItemPurchases, [
for (final unit in line.units)
TabItemPurchasesCompanion.insert(
id: unit.id,
tabItemId: line.id,
purchasedAt: unit.addedAt,
removedAt: Value(unit.removedAt),
),
]);
});
}
}
/// Applies an outbox event to the local tables the way the server will:
/// without writing events and without touching stock. Unknown types and
/// events about tabs that are gone are skipped.
Future<void> _replayEvent(Map<String, dynamic> event) async {
final tabId = event['tabId'] as String?;
if (tabId == null) return;
final customerName = event['customerName'] as String? ?? '';
final occurredAt =
DateTime.tryParse(event['occurredAt'] as String? ?? '')?.toLocal() ??
DateTime.now();
switch (TabEventType.fromValue(event['type'] as String? ?? '')) {
case TabEventType.tabOpened:
final openedAt = DateTime.tryParse(
event['openedAt'] as String? ?? '',
)?.toLocal();
await _ensureTab(tabId, customerName, openedAt ?? occurredAt);
case TabEventType.tabRenamed:
await (database.update(database.barTabs)
..where((tab) => tab.id.equals(tabId)))
.write(BarTabsCompanion(customerName: Value(customerName)));
case TabEventType.tabDeleted:
await _deleteTabWithItems(tabId);
case TabEventType.lineAdded:
await _replayLineAdded(event, tabId, customerName, occurredAt);
case TabEventType.lineRemoved:
final line = await _findLine(tabId, event);
final quantity = event['quantity'] as int? ?? 0;
if (line == null || quantity <= 0) return;
final removed = min(quantity, line.quantity);
await _setLineQuantity(line.id, line.quantity - removed);
await _removeLatestPurchases(line.id, removed, removedAt: occurredAt);
case TabEventType.settled:
for (final item in event['items'] as List<dynamic>? ?? []) {
await _replaySettledLine(tabId, item as Map<String, dynamic>);
}
// Stock is rebased separately, see StockQueries.rebaseStock
case TabEventType.stockSet:
case null:
return;
}
}
/// A drink put on a tab that was deleted elsewhere brings the tab back, so
/// no sale is lost.
Future<void> _replayLineAdded(
Map<String, dynamic> event,
String tabId,
String customerName,
DateTime occurredAt,
) async {
final quantity = event['quantity'] as int? ?? 0;
if (quantity <= 0) return;
await _ensureTab(tabId, customerName, occurredAt);
var line = await _findLine(tabId, event);
if (line == null) {
final lineId = event['lineId'] as String?;
final idTaken =
lineId == null ||
await (database.select(
database.tabItems,
)..where((item) => item.id.equals(lineId))).getSingleOrNull() !=
null;
line = TabItemRow(
id: idTaken ? _uuid.v4() : lineId,
tabId: tabId,
productId: event['productId'] as String? ?? '',
productName: event['productName'] as String? ?? '',
quantity: 0,
unitPriceInCents: event['unitPriceInCents'] as int? ?? 0,
createdAt: occurredAt,
);
await database.into(database.tabItems).insert(line);
}
await _setLineQuantity(line.id, line.quantity + quantity);
await database.batch((batch) {
batch.insertAll(database.tabItemPurchases, [
for (var index = 0; index < quantity; index++)
TabItemPurchasesCompanion.insert(
// The same id the server gives this unit
id: '${event['id']}:$index',
tabItemId: line!.id,
purchasedAt: occurredAt,
),
]);
});
}
/// Takes the settled pieces off the line, oldest first: drinks added
/// elsewhere after the settlement stay on the tab.
Future<void> _replaySettledLine(
String tabId,
Map<String, dynamic> item,
) async {
final line = await _findLine(tabId, item);
final quantity = item['quantity'] as int? ?? 0;
if (line == null || quantity <= 0) return;
final settled = min(quantity, line.quantity);
final oldest =
await (database.select(database.tabItemPurchases)
..where(
(purchase) =>
purchase.tabItemId.equals(line.id) &
purchase.removedAt.isNull(),
)
..orderBy([
(purchase) => OrderingTerm.asc(purchase.purchasedAt),
(purchase) => OrderingTerm.asc(purchase.id),
])
..limit(settled))
.get();
// Undone drinks went into the history with the settlement
await (database.delete(database.tabItemPurchases)..where(
(purchase) =>
purchase.id.isIn(oldest.map((row) => row.id)) |
(purchase.tabItemId.equals(line.id) &
purchase.removedAt.isNotNull()),
))
.go();
if (line.quantity == settled) {
await (database.delete(
database.tabItems,
)..where((row) => row.id.equals(line.id))).go();
} else {
await _setLineQuantity(line.id, line.quantity - settled);
}
}
Future<void> _ensureTab(
String tabId,
String customerName,
DateTime openedAt,
) async {
await database
.into(database.barTabs)
.insert(
BarTabsCompanion.insert(
id: tabId,
customerName: customerName,
openedAt: openedAt,
version: const Value(1),
),
mode: InsertMode.insertOrIgnore,
);
}
/// The line [event] is about: the same line id, else the same product at
/// the same price, else the same name at the same price.
Future<TabItemRow?> _findLine(
String tabId,
Map<String, dynamic> event,
) async {
final lineId = event['lineId'] as String?;
final productId = event['productId'] as String?;
final productName = event['productName'] as String?;
final price = event['unitPriceInCents'] as int?;
Future<TabItemRow?> first(Expression<bool> Function($TabItemsTable) where) {
return (database.select(database.tabItems)
..where((item) => item.tabId.equals(tabId) & where(item))
..orderBy([(item) => OrderingTerm.asc(item.createdAt)])
..limit(1))
.getSingleOrNull();
}
if (lineId != null) {
final line = await first((item) => item.id.equals(lineId));
if (line != null) return line;
}
if (price == null) return null;
if (productId != null && productId.isNotEmpty) {
final line = await first(
(item) =>
item.productId.equals(productId) &
item.unitPriceInCents.equals(price),
);
if (line != null) return line;
}
if (productName == null) return null;
return first(
(item) =>
item.productName.equals(productName) &
item.unitPriceInCents.equals(price),
);
}
Future<void> _setLineQuantity(String lineId, int quantity) async {
await (database.update(database.tabItems)
..where((row) => row.id.equals(lineId)))
.write(TabItemsCompanion(quantity: Value(quantity)));
}
@override
Future<void> recordSettlements(List<ServerSettlement> settlements) async {
if (settlements.isEmpty) return;
await database.transaction(() async {
for (final settlement in settlements) {
final known = await (database.select(
database.closedTabs,
)..where((row) => row.id.equals(settlement.id))).getSingleOrNull();
// Already in the history, e.g. settled on this tablet
if (known != null) continue;
await database
.into(database.closedTabs)
.insert(
ClosedTabsCompanion.insert(
id: settlement.id,
originalTabId: settlement.tabId,
customerName: settlement.customerName,
closedAt: settlement.settledAt,
paymentMethod: Value(settlement.paymentMethod),
),
);
await database.batch((batch) {
batch.insertAll(database.closedTabItems, [
for (final line in settlement.lines)
for (final unit in line.units)
ClosedTabItemsCompanion.insert(
id: _uuid.v4(),
closedTabId: settlement.id,
productId: line.productId,
productName: line.productName,
quantity: 1,
unitPriceInCents: line.unitPriceInCents,
purchasedAt: Value(unit.addedAt),
removedAt: Value(unit.removedAt),
),
]);
});
}
});
}
}
+8 -1
View File
@@ -11,6 +11,8 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
import '../database/stock_queries.dart';
import '../models/tab_event.dart';
class DefaultProductSeeder {
final AppDatabase database;
@@ -46,6 +48,7 @@ class DefaultProductSeeder {
await database.transaction(() async {
final uuid = const Uuid();
final events = TabEventFactory();
for (final (name, priceInCents, stock, category) in _defaultProducts) {
String? imagePath;
@@ -54,11 +57,12 @@ class DefaultProductSeeder {
imagePath = await _copyBundledImage(name, imagesDir);
}
final id = uuid.v4();
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: uuid.v4(),
id: id,
name: name,
category: category,
stockQuantity: stock,
@@ -67,6 +71,9 @@ class DefaultProductSeeder {
imagePath: Value(imagePath),
),
);
await database.queueStockSet(
events.stockSet(productId: id, quantity: stock),
);
}
});
} catch (e, stack) {
+62 -30
View File
@@ -4,9 +4,9 @@ import 'package:uuid/uuid.dart';
import '../database/app_database.dart';
import '../database/stock_queries.dart';
import '../models/product.dart';
import '../models/tab_event.dart';
export '../database/stock_queries.dart'
show InsufficientStockException, ProductNotFoundException;
export '../database/stock_queries.dart' show ProductNotFoundException;
abstract class ProductService {
/// Active products, re-emitted whenever the products table changes.
@@ -37,9 +37,11 @@ abstract class ProductService {
class DriftProductService implements ProductService {
final AppDatabase database;
final TabEventFactory eventFactory;
final _uuid = const Uuid();
DriftProductService({required this.database});
DriftProductService({required this.database, TabEventFactory? eventFactory})
: eventFactory = eventFactory ?? TabEventFactory();
Product _mapRowToProduct(ProductRow row) {
return Product(
@@ -95,39 +97,69 @@ class DriftProductService implements ProductService {
required int priceInCents,
required String? imagePath,
}) async {
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: _uuid.v4(),
name: name,
category: category,
stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold,
infiniteStock: Value(infiniteStock),
priceInCents: priceInCents,
imagePath: Value(imagePath),
),
final id = _uuid.v4();
await database.transaction(() async {
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: id,
name: name,
category: category,
stockQuantity: stockQuantity,
lowStockThreshold: lowStockThreshold,
infiniteStock: Value(infiniteStock),
priceInCents: priceInCents,
imagePath: Value(imagePath),
),
);
// The server starts a product it doesn't know as untracked
if (!infiniteStock) {
await database.queueStockSet(
eventFactory.stockSet(productId: id, quantity: stockQuantity),
);
}
});
}
/// A changed stock or tracking is queued for the server as a count.
@override
Future<void> updateProduct(Product product) async {
final query = database.update(database.products)
..where((row) => row.id.equals(product.id));
await database.transaction(() async {
final current = await (database.select(
database.products,
)..where((row) => row.id.equals(product.id))).getSingleOrNull();
await query.write(
ProductsCompanion(
name: Value(product.name),
category: Value(product.category),
stockQuantity: Value(product.stockQuantity),
lowStockThreshold: Value(product.lowStockThreshold),
infiniteStock: Value(product.infiniteStock),
priceInCents: Value(product.priceInCents),
imagePath: Value(product.imagePath),
active: Value(product.active),
),
);
await (database.update(
database.products,
)..where((row) => row.id.equals(product.id))).write(
ProductsCompanion(
name: Value(product.name),
category: Value(product.category),
stockQuantity: Value(product.stockQuantity),
lowStockThreshold: Value(product.lowStockThreshold),
infiniteStock: Value(product.infiniteStock),
priceInCents: Value(product.priceInCents),
imagePath: Value(product.imagePath),
active: Value(product.active),
),
);
if (current == null) return;
final stockChanged = product.infiniteStock
? !current.infiniteStock
: current.infiniteStock ||
current.stockQuantity != product.stockQuantity;
if (stockChanged) {
await database.queueStockSet(
eventFactory.stockSet(
productId: product.id,
quantity: product.infiniteStock ? null : product.stockQuantity,
),
);
}
});
}
/// Only hidden, since open tabs and history still refer to it.
+230 -18
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart';
import '../database/app_database.dart';
import '../database/stock_queries.dart';
import '../models/bar_tab.dart';
import '../models/payment_method.dart';
import '../models/remote_command.dart';
@@ -49,6 +50,13 @@ class RemoteSyncService {
/// Most outbox events sent per sync; a larger backlog drains over syncs.
static const maxEventsPerSync = 500;
/// Most stock adjustment outcomes sent per sync.
static const maxStockResultsPerSync = 500;
/// How long a handled stock adjustment is remembered after the server
/// acknowledged its outcome.
static const stockResultRetention = Duration(days: 30);
static const _pollingInterval = Duration(
seconds: kSyncPollingIntervalSeconds,
);
@@ -66,7 +74,7 @@ class RemoteSyncService {
bool _paused = false;
int _consecutiveFailures = 0;
StreamSubscription<List<BarTab>>? _tabChanges;
StreamSubscription<Set<String>>? _tabChanges;
Timer? _changeTimer;
DateTime? _firstPendingChange;
bool _syncQueued = false;
@@ -98,11 +106,8 @@ class RemoteSyncService {
void startPeriodicSync() {
_isRunning = true;
// The first emission is the current state, not a change
_tabChanges ??= barTabService
.watchOpenTabs()
.skip(1)
.listen((_) => _onTabsChanged());
unawaited(pruneStockAdjustmentResults());
_tabChanges ??= _watchNewOutboxEvents();
_scheduleNext(_pollingInterval);
}
@@ -126,6 +131,27 @@ class RemoteSyncService {
_paused = false;
}
/// Every local tab change queues an event, so a new event in the outbox
/// means a change to push. Watching the tabs instead would also fire on
/// the server state a sync writes, and sync again forever.
StreamSubscription<Set<String>> _watchNewOutboxEvents() {
Set<String>? known;
final ids = database.selectOnly(database.tabEvents)
..addColumns([database.tabEvents.id]);
return ids
.map((row) => row.read(database.tabEvents.id)!)
.watch()
.map((rows) => rows.toSet())
.listen((current) {
// The first emission is the current state, not a change
final isNew = known != null && !current.every(known!.contains);
known = current;
if (isNew) _onTabsChanged();
});
}
/// Pushes tab changes to the server soon after they happen instead of
/// waiting for the next poll. A burst of changes (several drinks rung up in
/// a row) becomes one sync, sent once things are quiet for [changeDebounce]
@@ -183,9 +209,11 @@ class RemoteSyncService {
Duration nextDelay;
try {
await _doSync(credentials);
final appliedStock = await _doSync(credentials);
_consecutiveFailures = 0;
nextDelay = _pollingInterval;
// Show the new stock on the dashboard within seconds. The follow-up
// skips the adjustments it already applied, so this doesn't loop.
nextDelay = appliedStock ? Duration.zero : _pollingInterval;
_emit(RemoteSyncEventType.syncCompleted);
} on UnauthorizedException {
debugPrint('RemoteSyncService: token revoked');
@@ -229,11 +257,20 @@ class RemoteSyncService {
return retryAfter ?? _backoff();
}
Future<void> _doSync(_Credentials credentials) async {
/// Returns whether any stock adjustment was applied, which only happens
/// with a server that doesn't own the stock.
Future<bool> _doSync(_Credentials credentials) async {
final state = await _getSyncState();
// Read before the snapshot, so the snapshot is never older than the events
final outbox = await _pendingEvents();
final openTabs = await barTabService.getOpenTabs();
// One read, so the snapshot and the stock match the events that are sent
final (outbox, openTabs, products, stockResults) = await database
.transaction(
() async => (
await _pendingEvents(),
await barTabService.getOpenTabs(),
await database.select(database.products).get(),
await _pendingStockResults(),
),
);
final response = await apiClient.sync(
token: credentials.token,
@@ -245,22 +282,60 @@ class RemoteSyncService {
for (final row in outbox)
jsonDecode(row.payload) as Map<String, dynamic>,
],
products: products.map(_toCatalogEntry).toList(),
stockAdjustmentResults: [
for (final row in stockResults)
jsonDecode(row.payload) as Map<String, dynamic>,
],
settlementsCursor: state?.settlementsCursor,
),
);
await _clearSentEvents(response);
await _markStockResultsReceived(response);
await barTabService.recordSettlements(response.settlements);
final serverTabs = response.tabs;
if (serverTabs != null) {
// After the acked events left the outbox: what is left still has to
// reach the server and is replayed on top
await barTabService.applyServerState(serverTabs);
}
// Same rebase for the stock: the server counts every tablet's sales
final serverStock = response.stock;
if (serverStock != null) await database.rebaseStock(serverStock);
for (final command in response.commands) {
await _processCommand(command, credentials);
}
final openNames = openTabs.map((tab) => tab.customerName).toSet();
for (final name in response.plannedTabNames) {
if (name.trim().isEmpty || !openNames.add(name)) continue;
await barTabService.createTab(customerName: name);
// A server that holds the tabs creates planned tabs itself; doing it
// here too would open them once per tablet
if (serverTabs == null) {
final openNames = openTabs.map((tab) => tab.customerName).toSet();
for (final name in response.plannedTabNames) {
if (name.trim().isEmpty || !openNames.add(name)) continue;
await barTabService.createTab(customerName: name);
}
}
await _updateSyncState(success: true, cursor: response.cursor);
// A server that owns the stock applies the dashboard's adjustments
// itself; doing it here too would apply a delivery once per tablet
var appliedStock = false;
if (serverStock == null) {
for (final adjustment in response.stockAdjustments) {
if (await _applyStockAdjustment(adjustment)) appliedStock = true;
}
}
await _updateSyncState(
success: true,
cursor: response.cursor,
settlementsCursor: response.settlementsCursor,
);
return appliedStock;
}
Future<List<TabEventRow>> _pendingEvents() {
@@ -295,6 +370,134 @@ class RemoteSyncService {
)..where((row) => row.id.isIn(ids))).go();
}
Future<List<StockAdjustmentResultRow>> _pendingStockResults() {
return (database.select(database.stockAdjustmentResults)
..where((row) => row.acknowledged.not())
..orderBy([
(row) => OrderingTerm.asc(row.createdAt),
(row) => OrderingTerm.asc(const CustomExpression<int>('rowid')),
])
..limit(maxStockResultsPerSync))
.get();
}
/// Received outcomes stay as the record that their adjustment was handled;
/// [pruneStockAdjustmentResults] removes them later.
Future<void> _markStockResultsReceived(SyncResponse response) async {
for (final rejected in response.rejectedStockAdjustmentResults) {
debugPrint(
'RemoteSyncService: stock result ${rejected.id} rejected: '
'${rejected.reason}',
);
}
final ids = {
...response.acknowledgedStockAdjustmentResultIds,
for (final rejected in response.rejectedStockAdjustmentResults)
if (rejected.id != null) rejected.id!,
};
if (ids.isEmpty) return;
await (database.update(
database.stockAdjustmentResults,
)..where((row) => row.id.isIn(ids))).write(
const StockAdjustmentResultsCompanion(acknowledged: Value(true)),
);
}
/// Applies [adjustment] unless it was handled before, and stores the
/// outcome in the same transaction, so a crash can neither apply it twice
/// nor lose it. Returns whether the stock changed.
Future<bool> _applyStockAdjustment(StockAdjustment adjustment) async {
final type = adjustment.type;
if (type == null) return false;
return database.transaction(() async {
final handled = await (database.select(
database.stockAdjustmentResults,
)..where((row) => row.id.equals(adjustment.id))).getSingleOrNull();
if (handled != null) return false;
final product = await (database.select(
database.products,
)..where((row) => row.id.equals(adjustment.productId))).getSingleOrNull();
final Map<String, dynamic> result;
if (product == null) {
result = _rejectedStockResult(adjustment, 'unknown_product');
} else if (type == StockAdjustmentType.add && product.infiniteStock) {
result = _rejectedStockResult(adjustment, 'stock_not_tracked');
} else {
final stockAfter = switch (type) {
StockAdjustmentType.add =>
product.stockQuantity + adjustment.quantity,
StockAdjustmentType.set => adjustment.quantity,
};
// A stock count on an untracked product starts tracking it
await (database.update(
database.products,
)..where((row) => row.id.equals(product.id))).write(
ProductsCompanion(
stockQuantity: Value(stockAfter),
infiniteStock: const Value(false),
),
);
result = {
'id': adjustment.id,
'status': 'applied',
'stockAfter': stockAfter,
};
}
await database
.into(database.stockAdjustmentResults)
.insert(
StockAdjustmentResultsCompanion.insert(
id: adjustment.id,
payload: jsonEncode(result),
createdAt: DateTime.now(),
),
);
return result['status'] == 'applied';
});
}
Map<String, dynamic> _rejectedStockResult(
StockAdjustment adjustment,
String reason,
) {
debugPrint(
'RemoteSyncService: stock adjustment ${adjustment.id} rejected: $reason',
);
return {'id': adjustment.id, 'status': 'rejected', 'reason': reason};
}
/// Forgets handled stock adjustments whose outcome the server received
/// more than [stockResultRetention] ago.
Future<void> pruneStockAdjustmentResults({DateTime? now}) async {
final cutoff = (now ?? DateTime.now()).subtract(stockResultRetention);
await (database.delete(database.stockAdjustmentResults)..where(
(row) => row.acknowledged & row.createdAt.isSmallerThanValue(cutoff),
))
.go();
}
/// Hidden products are included: they still exist. A server that owns the
/// stock ignores `stock`; it gets the counts as `stock_set` events.
Map<String, dynamic> _toCatalogEntry(ProductRow product) {
final category = product.category.trim();
return {
'id': product.id,
'name': product.name,
'category': category.isEmpty ? null : category,
'priceInCents': product.priceInCents,
'stock': product.infiniteStock ? null : product.stockQuantity,
};
}
SnapshotTab _toSnapshot(BarTab tab) {
return SnapshotTab(
id: tab.id,
@@ -404,7 +607,13 @@ class RemoteSyncService {
)..where((row) => row.deviceId.equals(_syncStateId))).getSingleOrNull();
}
Future<void> _updateSyncState({required bool success, String? cursor}) async {
/// Settlements are deduplicated by id, so a cursor lost in a crash only
/// makes the server send some of them again.
Future<void> _updateSyncState({
required bool success,
String? cursor,
String? settlementsCursor,
}) async {
final now = DateTime.now();
final existing = await _getSyncState();
@@ -414,6 +623,9 @@ class RemoteSyncService {
RemoteSyncStateCompanion.insert(
deviceId: _syncStateId,
serverCursor: Value(cursor ?? existing?.serverCursor),
settlementsCursor: Value(
settlementsCursor ?? existing?.settlementsCursor,
),
lastSuccessfulSyncAt: Value(
success ? now : existing?.lastSuccessfulSyncAt,
),
@@ -10,7 +10,6 @@ import '../models/payment_method.dart';
import '../models/product.dart';
import '../models/tab_item.dart';
import '../services/bar_tab_service.dart';
import '../services/product_service.dart';
/// Tabs come from a database stream, so every change (local or remote) shows
/// up without manual reloads. Actions report to Sentry and rethrow; the view
@@ -129,12 +128,9 @@ class BarScreenViewModel extends ChangeNotifier {
);
}
/// Running out of stock is an expected outcome, not a bug worth reporting.
Future<T> _guard<T>(String action, Future<T> Function() body) async {
try {
return await body();
} on InsufficientStockException {
rethrow;
} catch (e, stack) {
debugPrint('BarScreenViewModel: $action error: $e');
unawaited(Sentry.captureException(e, stackTrace: stack));
+3 -15
View File
@@ -9,7 +9,6 @@ import '../../l10n/app_localizations.dart';
import '../../l10n/app_localizations_helpers.dart';
import '../../models/product.dart';
import '../../models/remote_command.dart';
import '../../services/product_service.dart';
import '../../utils/app_updater.dart';
import '../../viewmodels/admin_pin_view_model.dart';
import '../../viewmodels/bar_screen_view_model.dart';
@@ -85,15 +84,9 @@ class _BarScreenViewState extends State<BarScreenView> {
await viewModel.addProductToSelectedTab(product);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is InsufficientStockException
? l10n.productOutOfStock
: l10n.couldNotAddProductToTab,
),
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.couldNotAddProductToTab)));
}
}
@@ -108,10 +101,6 @@ class _BarScreenViewState extends State<BarScreenView> {
final viewModel = context.watch<BarScreenViewModel>();
final productsViewModel = context.watch<ProductListViewModel>();
final settings = context.watch<SettingsViewModel>().settings;
final stockByProductId = {
for (final product in productsViewModel.products)
if (!product.infiniteStock) product.id: product.stockQuantity,
};
return Scaffold(
appBar: AppBar(
@@ -206,7 +195,6 @@ class _BarScreenViewState extends State<BarScreenView> {
tabs: viewModel.tabs,
selectedTab: viewModel.selectedTab,
selectedTabId: viewModel.selectedTabId,
stockByProductId: stockByProductId,
showSearch: _showSearch,
onTabSelected: viewModel.selectTab,
onItemQuantityChanged: viewModel.changeItemQuantity,
+4 -20
View File
@@ -3,19 +3,16 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bar_tab.dart';
import '../../models/tab_item.dart';
import '../../services/product_service.dart';
import '../../utils/money.dart';
class SelectedTabDetails extends StatelessWidget {
final BarTab tab;
final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
final VoidCallback onCloseTabPressed;
const SelectedTabDetails({
super.key,
required this.tab,
required this.stockByProductId,
required this.onItemQuantityChanged,
required this.onCloseTabPressed,
});
@@ -55,7 +52,6 @@ class SelectedTabDetails extends StatelessWidget {
return TabItemRow(
item: item,
stockByProductId: stockByProductId,
onQuantityChanged: onItemQuantityChanged,
);
},
@@ -108,13 +104,11 @@ class SelectedTabDetails extends StatelessWidget {
class TabItemRow extends StatelessWidget {
final TabItem item;
final Map<String, int> stockByProductId;
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
const TabItemRow({
super.key,
required this.item,
required this.stockByProductId,
required this.onQuantityChanged,
});
@@ -125,23 +119,15 @@ class TabItemRow extends StatelessWidget {
await onQuantityChanged(item, delta);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is InsufficientStockException
? l10n.productOutOfStock
: l10n.couldNotUpdateQuantity,
),
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.couldNotUpdateQuantity)));
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final availableStock = stockByProductId[item.productId];
final canIncrease = availableStock == null || availableStock > 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
@@ -188,9 +174,7 @@ class TabItemRow extends StatelessWidget {
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: canIncrease
? () => _changeQuantity(context, 1)
: null,
onPressed: () => _changeQuantity(context, 1),
icon: const Icon(Icons.add_rounded, size: 18),
),
],
-4
View File
@@ -39,7 +39,6 @@ class TabPanel extends StatefulWidget {
final List<BarTab> tabs;
final BarTab? selectedTab;
final String? selectedTabId;
final Map<String, int> stockByProductId;
final bool showSearch;
final ValueChanged<String> onTabSelected;
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
@@ -52,7 +51,6 @@ class TabPanel extends StatefulWidget {
required this.tabs,
required this.selectedTab,
required this.selectedTabId,
required this.stockByProductId,
required this.showSearch,
required this.onTabSelected,
required this.onItemQuantityChanged,
@@ -409,8 +407,6 @@ class _TabPanelState extends State<TabPanel> {
)
: SelectedTabDetails(
tab: widget.selectedTab!,
stockByProductId:
widget.stockByProductId,
onItemQuantityChanged:
widget.onItemQuantityChanged,
onCloseTabPressed:
+13 -34
View File
@@ -2,7 +2,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/product.dart';
class ProductTile extends StatelessWidget {
@@ -24,8 +23,6 @@ class ProductTile extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final l10n = AppLocalizations.of(context);
final outOfStock = product.isOutOfStock;
final lowStock = !outOfStock && product.isLowStock;
@@ -50,13 +47,14 @@ class ProductTile extends StatelessWidget {
child: Material(
color: theme.cardTheme.color ?? scheme.surface,
child: InkWell(
onTap: enabled && !outOfStock ? onTap : null,
// Never blocked by stock: the count may simply be off
onTap: enabled ? onTap : null,
onLongPress: onLongPress,
child: Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4),
opacity: enabled ? 1 : 0.4,
child: hasImage
? Image.file(
File(product.imagePath!),
@@ -90,28 +88,6 @@ class ProductTile extends StatelessWidget {
right: 10,
child: _StockBadge(product: product),
),
if (outOfStock)
Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: scheme.error.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.outOfStock,
style: TextStyle(
color: scheme.onError,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
),
],
),
),
@@ -140,16 +116,15 @@ class _StockBadge extends StatelessWidget {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (product.isOutOfStock) {
return const SizedBox.shrink();
}
final low = product.isLowStock;
final out = product.isOutOfStock;
final low = !out && product.isLowStock;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: low
color: out
? scheme.error.withValues(alpha: 0.9)
: low
? Colors.amber.withValues(alpha: 0.9)
: scheme.primary.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(999),
@@ -159,7 +134,11 @@ class _StockBadge extends StatelessWidget {
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: low ? Colors.black : scheme.onPrimary,
color: out
? scheme.onError
: low
? Colors.black
: scheme.onPrimary,
),
),
);