Upload shopper registrations, and charge GST to the lines that earned it
Two defects that share a shape: a figure landing on the wrong record.
Bill-level discounts were apportioned across every line by a single
factor, so "20% off Beverages" pulled tax out of the atta line as well.
The bill total was right either way, which is what made it easy to ship
— only the slab split on a filed return was wrong. Targeted campaigns
now reduce the lines they name, and bill-wide reductions still spread
pro rata, so the arithmetic is unchanged wherever it was already right.
Shoppers registered at a till only ever reached the back office as three
fields riding along on a bill. Somebody who signed up and bought nothing
existed on one terminal and nowhere else, and two tills registering the
same mobile each minted their own row. Customers are now an outbox of
their own on pos/{store}/{terminal}/customer, and the id is a UUIDv5
over the normalised mobile number — so a hundred terminals agree on who
a shopper is without talking to each other.
Registrations go up before bills, and a failure there cannot strand a
day's takings. No loyalty figures are sent: they belong to the bill
stream, which is idempotent and knows about every counter.
Two things found while building it. Numbers were keyed on raw digits, so
a cashier typing +91 forked a shopper as effectively as a random id
would. And the sale path wrote the customer with ConflictAlgorithm
.replace, which is a DELETE and an INSERT — every column absent from the
row reverts to its schema default, so the new sync flag would have been
cleared by the shopper's next purchase.
Schema v8. Existing customers are queued rather than assumed sent: the
terminal cannot tell an imported row from a locally registered one, and
only one of those mistakes loses somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,8 +50,19 @@ class HttpOrderTransport implements OrderTransport {
|
||||
Future<void> connect() async {}
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
|
||||
if (orders.isEmpty) return const PushReceipt(accepted: []);
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||
_post(path: 'orders', key: 'orders', items: orders);
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
|
||||
_post(path: 'customers', key: 'customers', items: customers);
|
||||
|
||||
Future<PushReceipt> _post({
|
||||
required String path,
|
||||
required String key,
|
||||
required List<Map<String, Object?>> items,
|
||||
}) async {
|
||||
if (items.isEmpty) return const PushReceipt(accepted: []);
|
||||
|
||||
if (config.httpBaseUrl.isEmpty) {
|
||||
throw const TransportException(
|
||||
@@ -60,7 +71,7 @@ class HttpOrderTransport implements OrderTransport {
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.parse('${config.httpBaseUrl}/orders');
|
||||
final uri = Uri.parse('${config.httpBaseUrl}/$path');
|
||||
|
||||
http.Response response;
|
||||
try {
|
||||
@@ -73,14 +84,14 @@ class HttpOrderTransport implements OrderTransport {
|
||||
'authorization': 'Bearer ${config.apiKey}',
|
||||
// Lets the endpoint collapse a retried batch server-side rather
|
||||
// than relying on every order id being checked individually.
|
||||
'idempotency-key': _batchKey(orders),
|
||||
'idempotency-key': _batchKey(items),
|
||||
},
|
||||
body: jsonEncode({
|
||||
'schema': 1,
|
||||
'store_id': config.storeId,
|
||||
'terminal_id': config.terminalId,
|
||||
'sent_at': DateTime.now().toIso8601String(),
|
||||
'orders': orders,
|
||||
key: items,
|
||||
}),
|
||||
)
|
||||
.timeout(config.ackTimeout);
|
||||
@@ -132,10 +143,10 @@ class HttpOrderTransport implements OrderTransport {
|
||||
return PushReceipt(accepted: accepted, rejected: rejected);
|
||||
}
|
||||
|
||||
/// Stable for a given set of bills, so a retry after a timeout carries the
|
||||
/// Stable for a given set of records, so a retry after a timeout carries the
|
||||
/// same key as the attempt that may already have landed.
|
||||
String _batchKey(List<Map<String, Object?>> orders) =>
|
||||
orders.map((o) => o['id']).join('|').hashCode.toRadixString(16);
|
||||
String _batchKey(List<Map<String, Object?>> items) =>
|
||||
items.map((o) => o['id']).join('|').hashCode.toRadixString(16);
|
||||
|
||||
void _setReachable(bool value) {
|
||||
if (_reachable == value) return;
|
||||
|
||||
@@ -170,8 +170,36 @@ class MqttOrderTransport implements OrderTransport {
|
||||
|
||||
// ----------------------------------------------------------------- Uplink
|
||||
@override
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
|
||||
if (orders.isEmpty) return const PushReceipt(accepted: []);
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||
_publishBatch(
|
||||
topic: config.orderTopic,
|
||||
key: 'orders',
|
||||
items: orders,
|
||||
noun: 'bills',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
|
||||
_publishBatch(
|
||||
topic: config.customerTopic,
|
||||
key: 'customers',
|
||||
items: customers,
|
||||
noun: 'registrations',
|
||||
);
|
||||
|
||||
/// Publishes one correlated batch and waits for the back office to answer it.
|
||||
///
|
||||
/// Shared by both uplinks because the rule they must obey is the same one,
|
||||
/// and it is the rule the whole design rests on: a batch counts as delivered
|
||||
/// only when the *application* names its ids, never when the broker
|
||||
/// acknowledges the bytes.
|
||||
Future<PushReceipt> _publishBatch({
|
||||
required String topic,
|
||||
required String key,
|
||||
required List<Map<String, Object?>> items,
|
||||
required String noun,
|
||||
}) async {
|
||||
if (items.isEmpty) return const PushReceipt(accepted: []);
|
||||
|
||||
await connect();
|
||||
|
||||
@@ -181,14 +209,14 @@ class MqttOrderTransport implements OrderTransport {
|
||||
|
||||
try {
|
||||
_publish(
|
||||
config.orderTopic,
|
||||
topic,
|
||||
jsonEncode({
|
||||
'schema': 1,
|
||||
'batch_id': batchId,
|
||||
'store_id': config.storeId,
|
||||
'terminal_id': config.terminalId,
|
||||
'sent_at': DateTime.now().toIso8601String(),
|
||||
'orders': orders,
|
||||
key: items,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -196,7 +224,7 @@ class MqttOrderTransport implements OrderTransport {
|
||||
config.ackTimeout,
|
||||
onTimeout: () => throw TransportException(
|
||||
'The back office did not confirm the batch within '
|
||||
'${config.ackTimeout.inSeconds}s. The bills are still on this '
|
||||
'${config.ackTimeout.inSeconds}s. The $noun are still on this '
|
||||
'terminal and will be sent again.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -86,6 +86,17 @@ abstract class OrderTransport {
|
||||
/// Throws [TransportException] when the outcome is unknown.
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders);
|
||||
|
||||
/// Hands over shoppers registered at this till.
|
||||
///
|
||||
/// Same acceptance contract as [pushOrders] — only ids the back office names
|
||||
/// are marked sent — but the payload is a registration rather than a
|
||||
/// financial record, so the back office is expected to treat it as
|
||||
/// insert-if-absent on id. Replaying one it already holds must be a no-op,
|
||||
/// never an overwrite of a profile corrected at head office.
|
||||
///
|
||||
/// Throws [TransportException] when the outcome is unknown.
|
||||
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers);
|
||||
|
||||
/// Cloud-initiated messages. Empty for transports that cannot receive.
|
||||
Stream<DownlinkMessage> get downlink;
|
||||
|
||||
|
||||
@@ -33,20 +33,30 @@ class SimulatedOrderTransport implements OrderTransport {
|
||||
Future<void> connect() async {}
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) =>
|
||||
_accept(orders, 'bill');
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushCustomers(List<Map<String, Object?>> customers) =>
|
||||
_accept(customers, 'registration');
|
||||
|
||||
Future<PushReceipt> _accept(
|
||||
List<Map<String, Object?>> items,
|
||||
String noun,
|
||||
) async {
|
||||
await Future<void>.delayed(
|
||||
Duration(milliseconds: 400 + orders.length * 60),
|
||||
Duration(milliseconds: 400 + items.length * 60),
|
||||
);
|
||||
|
||||
if (isOffline()) {
|
||||
throw const TransportException(
|
||||
throw TransportException(
|
||||
'Simulate offline is ON in Settings, so the upload was failed on '
|
||||
'purpose. Every bill is still stored on this terminal.',
|
||||
'purpose. Every $noun is still stored on this terminal.',
|
||||
);
|
||||
}
|
||||
|
||||
return PushReceipt(
|
||||
accepted: orders.map((o) => o['id']! as String).toList(),
|
||||
accepted: items.map((o) => o['id']! as String).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user