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
+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,