401 lines
11 KiB
Dart
401 lines
11 KiB
Dart
import '../utils/app_config.dart';
|
||
import 'server_tab.dart';
|
||
|
||
enum CommandType {
|
||
closeTab('close_tab');
|
||
|
||
final String value;
|
||
|
||
const CommandType(this.value);
|
||
|
||
static CommandType? fromValue(String value) {
|
||
for (final type in CommandType.values) {
|
||
if (type.value == value) return type;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
enum CommandResult { completed, conflict, failed, expired }
|
||
|
||
class RemoteCommand {
|
||
final String id;
|
||
final String rawType;
|
||
final String tabId;
|
||
final int expectedVersion;
|
||
final String paymentMethod;
|
||
final String requestedBy;
|
||
final DateTime createdAt;
|
||
|
||
const RemoteCommand({
|
||
required this.id,
|
||
required this.rawType,
|
||
required this.tabId,
|
||
required this.expectedVersion,
|
||
required this.paymentMethod,
|
||
required this.requestedBy,
|
||
required this.createdAt,
|
||
});
|
||
|
||
factory RemoteCommand.fromJson(Map<String, dynamic> json) {
|
||
return RemoteCommand(
|
||
id: json['id'] as String,
|
||
rawType: json['type'] as String,
|
||
tabId: json['tabId'] as String,
|
||
expectedVersion: json['expectedVersion'] as int,
|
||
paymentMethod: json['paymentMethod'] as String? ?? 'cash',
|
||
requestedBy: json['requestedBy'] as String? ?? '',
|
||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||
);
|
||
}
|
||
|
||
/// Null when the server sends a command this app version doesn't know.
|
||
CommandType? get type => CommandType.fromValue(rawType);
|
||
|
||
bool get isExpired {
|
||
final cutoff = DateTime.now().subtract(
|
||
const Duration(hours: kCommandExpiryHours),
|
||
);
|
||
return createdAt.isBefore(cutoff);
|
||
}
|
||
}
|
||
|
||
/// A tab event the server refused to store. Re-sending won't help.
|
||
class RejectedEvent {
|
||
final String? id;
|
||
final String reason;
|
||
|
||
const RejectedEvent({this.id, required this.reason});
|
||
|
||
factory RejectedEvent.fromJson(Map<String, dynamic> json) {
|
||
return RejectedEvent(
|
||
id: json['id'] as String?,
|
||
reason: json['reason'] as String? ?? '',
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
final List<String> plannedTabNames;
|
||
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) {
|
||
return SyncResponse(
|
||
commands: [
|
||
for (final command in json['commands'] as List<dynamic>? ?? [])
|
||
RemoteCommand.fromJson(command as Map<String, dynamic>),
|
||
],
|
||
cursor: json['cursor'] as String?,
|
||
plannedTabNames: [
|
||
for (final name in json['plannedTabNames'] as List<dynamic>? ?? [])
|
||
name as String,
|
||
],
|
||
acknowledgedEventIds: [
|
||
for (final id in json['acknowledgedEventIds'] as List<dynamic>? ?? [])
|
||
id as String,
|
||
],
|
||
rejectedEvents: [
|
||
for (final event in json['rejectedEvents'] as List<dynamic>? ?? [])
|
||
RejectedEvent.fromJson(event as Map<String, dynamic>),
|
||
],
|
||
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".
|
||
class SnapshotTabItem {
|
||
final String productName;
|
||
final int quantity;
|
||
final int unitPriceInCents;
|
||
|
||
const SnapshotTabItem({
|
||
required this.productName,
|
||
required this.quantity,
|
||
required this.unitPriceInCents,
|
||
});
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'productName': productName,
|
||
'quantity': quantity,
|
||
'unitPriceInCents': unitPriceInCents,
|
||
};
|
||
}
|
||
|
||
class SnapshotTab {
|
||
final String id;
|
||
final String customerName;
|
||
final int version;
|
||
final int totalInCents;
|
||
final int itemCount;
|
||
final List<SnapshotTabItem> items;
|
||
|
||
const SnapshotTab({
|
||
required this.id,
|
||
required this.customerName,
|
||
required this.version,
|
||
required this.totalInCents,
|
||
required this.itemCount,
|
||
this.items = const [],
|
||
});
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'id': id,
|
||
'customerName': customerName,
|
||
'version': version,
|
||
'totalInCents': totalInCents,
|
||
'itemCount': itemCount,
|
||
'items': items.map((item) => item.toJson()).toList(),
|
||
};
|
||
}
|
||
|
||
class SyncRequest {
|
||
final String deviceId;
|
||
final String? cursor;
|
||
final List<SnapshotTab> openTabs;
|
||
|
||
/// 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() => {
|
||
'deviceId': deviceId,
|
||
if (cursor != null) 'cursor': cursor,
|
||
'openTabs': openTabs.map((tab) => tab.toJson()).toList(),
|
||
'events': events,
|
||
'products': products,
|
||
'stockAdjustmentResults': stockAdjustmentResults,
|
||
if (settlementsCursor != null) 'settlementsCursor': settlementsCursor,
|
||
};
|
||
}
|
||
|
||
class CommandAck {
|
||
final String commandId;
|
||
final CommandResult status;
|
||
final String? result;
|
||
|
||
const CommandAck({
|
||
required this.commandId,
|
||
required this.status,
|
||
this.result,
|
||
});
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'commandId': commandId,
|
||
'status': status.name,
|
||
if (result != null) 'result': result,
|
||
};
|
||
}
|
||
|
||
enum RemoteSyncEventType {
|
||
tabClosedRemotely,
|
||
syncCompleted,
|
||
syncFailed,
|
||
conflict,
|
||
unauthorized,
|
||
}
|
||
|
||
class RemoteSyncEvent {
|
||
final RemoteSyncEventType type;
|
||
final String? tabCustomerName;
|
||
final String? requestedBy;
|
||
final String? message;
|
||
|
||
const RemoteSyncEvent({
|
||
required this.type,
|
||
this.tabCustomerName,
|
||
this.requestedBy,
|
||
this.message,
|
||
});
|
||
}
|