Files

310 lines
8.5 KiB
Dart

import 'package:uuid/uuid.dart';
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'),
/// A stock count or tracking change made on the tablet. Not about a tab:
/// it has no `tabId`.
stockSet('stock_set'),
/// A product created, changed or deleted (archived) on the tablet. Not
/// about a tab either.
productCreated('product_created'),
productUpdated('product_updated'),
productArchived('product_archived');
/// The product events, which the catalog replays instead of the tabs.
static const products = {productCreated, productUpdated, productArchived};
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. 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;
const TabEvent({required this.id, required this.payload});
}
/// The order a removal takes drinks off: `orderId`, or (when null) the
/// drinks rung up before orders were tracked.
typedef OrderRef = ({String? orderId});
/// One settled line in a `settled` event.
typedef SettledLine = ({
String productId,
String productName,
int quantity,
int unitPriceInCents,
});
/// Builds the events, so their JSON shape lives in one place.
class TabEventFactory {
final _uuid = const Uuid();
final DateTime Function() _now;
TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now;
TabEvent tabOpened({
required String tabId,
required String customerName,
required DateTime openedAt,
String? email,
}) {
return _event(
TabEventType.tabOpened,
tabId: tabId,
customerName: customerName,
fields: {
'openedAt': openedAt.toUtc().toIso8601String(),
'email': ?email,
},
);
}
/// [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. [orderId] is the order
/// they are part of.
TabEvent lineAdded({
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int quantity,
required String? orderId,
}) {
return _lineEvent(
TabEventType.lineAdded,
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
order: (orderId: orderId),
);
}
/// [quantity] is how many pieces were removed; always positive. With
/// [order] they are taken off that order only; without, the latest drinks
/// of the line (as before orders).
TabEvent lineRemoved({
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int quantity,
OrderRef? order,
}) {
return _lineEvent(
TabEventType.lineRemoved,
tabId: tabId,
customerName: customerName,
lineId: lineId,
productId: productId,
productName: productName,
unitPriceInCents: unitPriceInCents,
quantity: quantity,
order: order,
);
}
TabEvent settled({
required String tabId,
required String customerName,
required int amountInCents,
required PaymentMethod paymentMethod,
required List<SettledLine> items,
String? commandId,
}) {
return _event(
TabEventType.settled,
tabId: tabId,
customerName: customerName,
fields: {
'amountInCents': amountInCents,
'paymentMethod': paymentMethodValue(paymentMethod),
'commandId': ?commandId,
'items': [
for (final item in items)
{
'productId': item.productId,
'productName': item.productName,
'quantity': item.quantity,
'unitPriceInCents': item.unitPriceInCents,
},
],
},
);
}
/// 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(),
},
);
}
/// A product created on this tablet: [fields] holds `name`, `category`
/// (null: none), `priceInCents` and `lowStockThreshold`. The id is the
/// tablet's, so the product can be sold before the server knows it.
TabEvent productCreated({
required String productId,
required Map<String, Object?> fields,
}) {
return _productEvent(TabEventType.productCreated, productId, fields);
}
/// Only the [fields] that changed; `imageId` is a picture uploaded first.
TabEvent productUpdated({
required String productId,
required Map<String, Object?> fields,
}) {
return _productEvent(TabEventType.productUpdated, productId, fields);
}
TabEvent productArchived({required String productId}) {
return _productEvent(TabEventType.productArchived, productId, null);
}
TabEvent _productEvent(
TabEventType type,
String productId,
Map<String, Object?>? fields,
) {
final id = _uuid.v4();
return TabEvent(
id: id,
payload: {
'id': id,
'type': type.value,
'productId': productId,
'product': ?fields,
'occurredAt': _now().toUtc().toIso8601String(),
},
);
}
/// The server's payment method names: `cash`, `payconiq`,
/// `payment_pending` or `other`.
static String paymentMethodValue(PaymentMethod method) {
return switch (method) {
PaymentMethod.cash => 'cash',
PaymentMethod.payconiq => 'payconiq',
PaymentMethod.paymentPending => 'payment_pending',
};
}
TabEvent _lineEvent(
TabEventType type, {
required String tabId,
required String customerName,
required String lineId,
required String productId,
required String productName,
required int unitPriceInCents,
required int quantity,
required OrderRef? order,
}) {
if (quantity < 1) {
throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1');
}
return _event(
type,
tabId: tabId,
customerName: customerName,
fields: {
'lineId': lineId,
// Lines taken over from a tablet's own tabs have no product: the
// server then matches them by name and price, and refuses an empty id
if (productId.isNotEmpty) 'productId': productId,
'productName': productName,
'unitPriceInCents': unitPriceInCents,
'quantity': quantity,
// Also when null: a removal then only takes drinks without an order,
// where a removal without `orderId` takes the latest of the line
if (order != null) 'orderId': order.orderId,
},
);
}
TabEvent _event(
TabEventType type, {
required String tabId,
required String customerName,
required Map<String, dynamic> fields,
}) {
final id = _uuid.v4();
return TabEvent(
id: id,
payload: {
'id': id,
'type': type.value,
'tabId': tabId,
'customerName': customerName,
'occurredAt': _now().toUtc().toIso8601String(),
...fields,
},
);
}
}