feat: make server owner of tabs
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user