Files
kooltab/lib/models/remote_command.dart
T

460 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
);
}
}
/// A product as the website manages it.
class ServerProduct {
final String id;
final String name;
final String? category;
final int priceInCents;
/// Null: no low stock warning.
final int? lowStockThreshold;
/// Deleted on the website: hidden, kept for open tabs and the history.
final bool archived;
/// The picture, at `/images/<imageId>`; null: none.
final String? imageId;
const ServerProduct({
required this.id,
required this.name,
this.category,
required this.priceInCents,
this.lowStockThreshold,
this.archived = false,
this.imageId,
});
factory ServerProduct.fromJson(Map<String, dynamic> json) {
return ServerProduct(
id: json['id'] as String,
name: json['name'] as String,
category: json['category'] as String?,
priceInCents: json['priceInCents'] as int,
lowStockThreshold: json['lowStockThreshold'] as int?,
archived: json['archived'] as bool? ?? false,
imageId: json['imageId'] as String?,
);
}
}
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;
/// Every product of the venue, managed on the website. Null from a server
/// that doesn't manage the catalog; the tablet then manages its own.
final List<ServerProduct>? catalog;
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,
this.catalog,
});
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,
},
catalog: switch (json['catalog']) {
final List<dynamic> catalog => [
for (final product in catalog)
ServerProduct.fromJson(product as Map<String, dynamic>),
],
_ => 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,
/// The backoffice no longer accepts this app version.
updateRequired,
}
class RemoteSyncEvent {
final RemoteSyncEventType type;
final String? tabCustomerName;
final String? requestedBy;
final String? message;
/// A failed sync that only lacked a connection: the bar keeps working and
/// everything is sent once it is back.
final bool offline;
const RemoteSyncEvent({
required this.type,
this.tabCustomerName,
this.requestedBy,
this.message,
this.offline = false,
});
}