Drain bills to the back office automatically, over MQTT or HTTP
Turns the orders table into a queue that empties itself. Bills were only uploaded when a cashier pressed Sync at end of day; a till that was never pressed held a day's takings indefinitely. Drain engine (lib/data/sync/sync_engine.dart) - Triggers on sale committed, network regained, 5-minute poll, head-office request, and the manual button. - Single flight: a busy till firing a trigger per sale would otherwise have several passes reading the same pending rows and send every bill twice. A trigger arriving mid-drain is queued and replayed, so nothing is dropped. - Exponential backoff with +/-20% jitter to a 5-minute ceiling. The jitter matters: a store's terminals all fail at the same instant when the line drops, and would retry in lockstep without it. - Halts rather than loops on a failure retrying cannot fix (bad credential, refused batch). Pressing Sync clears the halt. Transports (lib/data/remote/) - OrderTransport interface; MQTT, HTTP and simulated implementations. The repository does not know which is in use. - MQTT: QoS 1 uplink, application-level ACK correlated by batch_id on a return topic, retained Last Will for terminal-offline detection, downlink for catalogue pushes and remote sync requests. - A broker PUBACK is never treated as acceptance. It means the broker holds the bytes, not that the ledger took the sale. Only ids the back office names are marked synced; silence leaves a bill pending. - HTTP carries a stable idempotency key across retries of the same bills. Retention - Accepted bills are kept 7 days instead of deleted, so a batch the back office later loses can be re-sent in full. Purged after that; archived totals stay forever. - forBusinessDate now reads pending rows only. A retained bill exists in both the orders table and day_archive, and summing both would overstate the day. Fixes found while building this - SyncEngine._refreshPending wrote state.copyWith(pending: await ...). Dart evaluates the receiver before the awaited argument, so a connectivity drop during the wait was silently overwritten by the stale snapshot. Caught by the first run of the new engine tests. - PrinterSettingsController wrote state after four awaits with no mounted check, throwing "used after dispose" when Settings was left mid-load. This was pre-existing and reached the cashier as a red screen. Also - Header pill now reports real sync state: LIVE / n QUEUED / SYNCING / SYNC HALTED, with an explanation of where the bills are. - Settings shows the route, last upload, next retry and retention window. - docs/sync-contract.md states what the back office must implement, including the idempotency requirement that at-least-once delivery makes mandatory. Tests: 90 -> 129 passing. New coverage for backoff shape and jitter band, single flight, halting, ACK correlation and partial acceptance, at-least-once duplicate handling, retention and purge, and no double-counting after a sync. Suite run six times clean. Not addressed: bills already synced by an older build went up overstated and still need server-side reconciliation. Broker credentials have no Settings editor yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
356
test/unit/retention_test.dart
Normal file
356
test/unit/retention_test.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nearle_pos/data/datasources/local_store.dart';
|
||||
import 'package:nearle_pos/data/datasources/remote_catalogue_source.dart';
|
||||
import 'package:nearle_pos/data/datasources/seed_data.dart';
|
||||
import 'package:nearle_pos/data/local/app_database.dart';
|
||||
import 'package:nearle_pos/data/local/order_dao.dart';
|
||||
import 'package:nearle_pos/data/remote/order_transport.dart';
|
||||
import 'package:nearle_pos/data/remote/simulated_order_transport.dart';
|
||||
import 'package:nearle_pos/data/repositories/customer_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/product_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/sync_repository_impl.dart';
|
||||
import 'package:nearle_pos/data/repositories/transaction_repository_impl.dart';
|
||||
import 'package:nearle_pos/domain/entities/cart.dart';
|
||||
import 'package:nearle_pos/domain/entities/transaction.dart';
|
||||
import 'package:nearle_pos/domain/usecases/checkout_sale.dart';
|
||||
|
||||
/// A transport whose answer each call is dictated by the test.
|
||||
class _ScriptedTransport implements OrderTransport {
|
||||
_ScriptedTransport(this.answer);
|
||||
|
||||
/// Given the ids in a batch, returns what the back office says about them.
|
||||
PushReceipt Function(List<String> ids) answer;
|
||||
|
||||
int batches = 0;
|
||||
|
||||
@override
|
||||
String get label => 'Scripted';
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
||||
@override
|
||||
Stream<DownlinkMessage> get downlink => const Stream.empty();
|
||||
|
||||
@override
|
||||
Stream<bool> get connectionState => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<void> connect() async {}
|
||||
|
||||
@override
|
||||
Future<PushReceipt> pushOrders(List<Map<String, Object?>> orders) async {
|
||||
batches++;
|
||||
return answer(orders.map((o) => o['id']! as String).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
/// Bills the back office has taken delivery of stay on the terminal for a
|
||||
/// week, so a batch the server later loses can still be re-sent in full.
|
||||
///
|
||||
/// The risk this buys is double counting: an accepted bill is now in two
|
||||
/// places at once — its own row, and the archived day totals. Most of what
|
||||
/// follows is about that.
|
||||
void main() {
|
||||
late LocalStore store;
|
||||
late ProductRepositoryImpl products;
|
||||
late CustomerRepositoryImpl customers;
|
||||
late TransactionRepositoryImpl transactions;
|
||||
late CheckoutSale checkout;
|
||||
|
||||
setUpAll(() {
|
||||
LocalStore.registerSeed(
|
||||
products: SeedData.products,
|
||||
customers: SeedData.customers,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
store = LocalStore.instance;
|
||||
await store.reset(withCatalogue: true);
|
||||
|
||||
products = ProductRepositoryImpl(store);
|
||||
customers = CustomerRepositoryImpl(store);
|
||||
transactions = TransactionRepositoryImpl(store);
|
||||
checkout = CheckoutSale(
|
||||
productRepository: products,
|
||||
customerRepository: customers,
|
||||
transactionRepository: transactions,
|
||||
);
|
||||
});
|
||||
|
||||
SyncRepositoryImpl syncWith(OrderTransport transport) => SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
transport,
|
||||
);
|
||||
|
||||
/// Rings one bill for [quantity] litres of milk at 62.00 each.
|
||||
Future<double> ringSale({
|
||||
double quantity = 2,
|
||||
String cashier = 'Divya',
|
||||
}) async {
|
||||
final milk = (await products.findByBarcode('8901234500011'))!;
|
||||
final cart = Cart(lines: [CartLine(product: milk, quantity: quantity)]);
|
||||
final due = cart.grandTotal;
|
||||
|
||||
await checkout(
|
||||
cart: cart,
|
||||
payments: [
|
||||
PaymentSplit(
|
||||
method: PaymentMethod.cash,
|
||||
amount: due,
|
||||
tendered: due,
|
||||
),
|
||||
],
|
||||
cashierName: cashier,
|
||||
);
|
||||
return due;
|
||||
}
|
||||
|
||||
group('accepted bills are kept, not deleted', () {
|
||||
test('a synced bill is still on the terminal and still re-sendable',
|
||||
() async {
|
||||
await ringSale();
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
|
||||
expect(await sync.unsyncedCount(), 1);
|
||||
await sync.syncOrders();
|
||||
expect(await sync.unsyncedCount(), 0);
|
||||
|
||||
// The row survives, carrying its line items, so the full bill can go up
|
||||
// again if the back office loses it.
|
||||
final rows = await sync.orderSyncRows();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.single.isSynced, isTrue);
|
||||
expect(rows.single.syncedAt, isNotNull);
|
||||
|
||||
final stored = await store.orders.recent();
|
||||
expect(stored, hasLength(1));
|
||||
expect(stored.single.cart.lines, isNotEmpty,
|
||||
reason: 'a kept bill with no lines could not be re-sent',);
|
||||
});
|
||||
|
||||
test("today's takings are not counted twice while the bill is retained",
|
||||
() async {
|
||||
// The bug this exists to catch: an accepted bill is in the archive *and*
|
||||
// still in the orders table. Summing both would inflate the day.
|
||||
final due = await ringSale(quantity: 3);
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
|
||||
final before = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(before.grossSales, closeTo(due, 0.01));
|
||||
|
||||
await sync.syncOrders();
|
||||
|
||||
final after = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(after.grossSales, closeTo(due, 0.01),
|
||||
reason: 'syncing must not change what the shop took',);
|
||||
expect(after.billCount, 1);
|
||||
});
|
||||
|
||||
test('a second sync does not send an already-accepted bill again',
|
||||
() async {
|
||||
await ringSale();
|
||||
final transport = _ScriptedTransport((ids) => PushReceipt(accepted: ids));
|
||||
final sync = syncWith(transport);
|
||||
|
||||
await sync.syncOrders();
|
||||
expect(transport.batches, 1);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
expect(outcome.hadNothingToDo, isTrue);
|
||||
expect(transport.batches, 1,
|
||||
reason: 'a retained bill must not be re-uploaded',);
|
||||
});
|
||||
});
|
||||
|
||||
group('purging', () {
|
||||
test('a bill past its window goes, and its archived totals stay', () async {
|
||||
final due = await ringSale(quantity: 4);
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
await sync.syncOrders();
|
||||
|
||||
// Backdate the acceptance past the retention window.
|
||||
await AppDatabase.instance.db.rawUpdate(
|
||||
'UPDATE orders SET synced_at = ?',
|
||||
[
|
||||
DateTime.now()
|
||||
.subtract(OrderDao.retentionWindow + const Duration(days: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
|
||||
expect(await sync.purgeExpired(), 1);
|
||||
expect(await store.orders.recent(), isEmpty);
|
||||
|
||||
// What the shop was paid is unchanged — only the re-sendable copy went.
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(report.grossSales, closeTo(due, 0.01));
|
||||
expect(report.billCount, 1);
|
||||
});
|
||||
|
||||
test('a bill inside its window is left alone', () async {
|
||||
await ringSale();
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
await sync.syncOrders();
|
||||
|
||||
expect(await sync.purgeExpired(), 0);
|
||||
expect(await store.orders.recent(), hasLength(1));
|
||||
});
|
||||
|
||||
test('an unsynced bill is never purged, however old', () async {
|
||||
// The one thing that must never happen: a bill the back office has not
|
||||
// taken delivery of being deleted from the only place it exists.
|
||||
await ringSale();
|
||||
await AppDatabase.instance.db.rawUpdate(
|
||||
'UPDATE orders SET created_at = ?, synced_at = ?',
|
||||
[0, 0],
|
||||
);
|
||||
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => false));
|
||||
expect(await sync.purgeExpired(), 0);
|
||||
expect(await sync.unsyncedCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('partial acceptance', () {
|
||||
test('a bill the back office stayed silent about stays pending', () async {
|
||||
// Silence is not acceptance.
|
||||
await ringSale(quantity: 1);
|
||||
await ringSale(quantity: 2);
|
||||
|
||||
final transport = _ScriptedTransport(
|
||||
(ids) => PushReceipt(accepted: [ids.first]),
|
||||
);
|
||||
final sync = syncWith(transport);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
expect(outcome.uploaded, 1);
|
||||
expect(outcome.rejected, 1);
|
||||
expect(await sync.unsyncedCount(), 1,
|
||||
reason: 'the unconfirmed bill must still be owed',);
|
||||
});
|
||||
|
||||
test('a refusal stops the drain instead of looping on the same rows',
|
||||
() async {
|
||||
await ringSale();
|
||||
final transport = _ScriptedTransport(
|
||||
(ids) => PushReceipt(
|
||||
accepted: const [],
|
||||
rejected: {for (final id in ids) id: 'duplicate invoice'},
|
||||
),
|
||||
);
|
||||
final sync = syncWith(transport);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
|
||||
expect(outcome.uploaded, 0);
|
||||
expect(outcome.isSuccess, isFalse);
|
||||
expect(outcome.isRetryable, isFalse,
|
||||
reason: 'the same bytes will be refused again',);
|
||||
expect(transport.batches, 1,
|
||||
reason: 'the refused page must not be fetched and sent forever',);
|
||||
expect(outcome.error, contains('duplicate invoice'));
|
||||
});
|
||||
|
||||
test('the refusal reason is recorded against the bill for a person to read',
|
||||
() async {
|
||||
await ringSale();
|
||||
final sync = syncWith(_ScriptedTransport(
|
||||
(ids) => PushReceipt(
|
||||
accepted: const [],
|
||||
rejected: {for (final id in ids) id: 'unknown product code'},
|
||||
),
|
||||
),);
|
||||
|
||||
await sync.syncOrders();
|
||||
|
||||
final row = (await sync.orderSyncRows()).single;
|
||||
expect(row.isSynced, isFalse);
|
||||
expect(row.error, 'unknown product code');
|
||||
expect(row.attempts, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('at-least-once delivery', () {
|
||||
test('a batch accepted twice is banked once', () async {
|
||||
// MQTT will re-deliver, and a lost ack means the terminal sends again.
|
||||
// The second acceptance must not double the archived takings.
|
||||
final due = await ringSale(quantity: 5);
|
||||
final transport = _ScriptedTransport((ids) => PushReceipt(accepted: ids));
|
||||
final sync = syncWith(transport);
|
||||
|
||||
await sync.syncOrders();
|
||||
// A duplicate ack for bills already marked synced — the drain finds
|
||||
// nothing pending and does nothing.
|
||||
await sync.syncOrders();
|
||||
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(report.grossSales, closeTo(due, 0.01));
|
||||
expect(report.billCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('transport failure', () {
|
||||
test('an unreachable back office leaves every bill exactly where it was',
|
||||
() async {
|
||||
final due = await ringSale(quantity: 6);
|
||||
final sync = syncWith(SimulatedOrderTransport(isOffline: () => true));
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
|
||||
expect(outcome.isSuccess, isFalse);
|
||||
expect(outcome.uploaded, 0);
|
||||
expect(await sync.unsyncedCount(), 1);
|
||||
|
||||
final report = await sync.todayReport(
|
||||
terminalId: 'TERM-01',
|
||||
cashierName: 'Divya',
|
||||
);
|
||||
expect(report.grossSales, closeTo(due, 0.01),
|
||||
reason: 'a failed upload must not change the shift total',);
|
||||
});
|
||||
|
||||
test('a batch is bounded so a backlog cannot exceed a broker message',
|
||||
() async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await ringSale(quantity: 1);
|
||||
}
|
||||
|
||||
final sizes = <int>[];
|
||||
final transport = _ScriptedTransport((ids) {
|
||||
sizes.add(ids.length);
|
||||
return PushReceipt(accepted: ids);
|
||||
});
|
||||
|
||||
final sync = SyncRepositoryImpl(
|
||||
store,
|
||||
RemoteCatalogueSource(isOffline: () => false),
|
||||
transport,
|
||||
batchSize: 2,
|
||||
);
|
||||
|
||||
final outcome = await sync.syncOrders();
|
||||
|
||||
expect(outcome.uploaded, 5);
|
||||
expect(sizes, [2, 2, 1], reason: 'the backlog must drain in pages');
|
||||
expect(await sync.unsyncedCount(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user