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:
@@ -11,9 +11,13 @@ import 'catalogue_dao.dart';
|
||||
|
||||
/// Persists bills.
|
||||
///
|
||||
/// Every completed sale lands here with `sync_status = 0`. The end-of-day
|
||||
/// upload selects those rows, sends them, and flips the accepted ones to 1.
|
||||
/// Nothing is ever deleted as part of syncing.
|
||||
/// Every completed sale lands here with `sync_status = 0`, which makes this
|
||||
/// table the terminal's outbox: the drain engine selects those rows, sends
|
||||
/// them, and flips the ones the back office confirmed to 1.
|
||||
///
|
||||
/// Confirmed rows are kept for [retentionWindow] so a batch the server later
|
||||
/// loses can be re-sent in full, then retired by [purgeSyncedBefore]. Syncing
|
||||
/// itself never deletes anything.
|
||||
class OrderDao {
|
||||
const OrderDao(this._db);
|
||||
|
||||
@@ -22,6 +26,9 @@ class OrderDao {
|
||||
static const int pending = 0;
|
||||
static const int synced = 1;
|
||||
|
||||
/// How long an accepted bill stays re-sendable on the terminal.
|
||||
static const Duration retentionWindow = Duration(days: 7);
|
||||
|
||||
static String businessDateOf(DateTime dt) =>
|
||||
'${dt.year.toString().padLeft(4, '0')}-'
|
||||
'${dt.month.toString().padLeft(2, '0')}-'
|
||||
@@ -132,20 +139,26 @@ class OrderDao {
|
||||
Future<List<SaleTransaction>> recent({int limit = 100}) =>
|
||||
_query(orderBy: 'created_at DESC', limit: limit);
|
||||
|
||||
/// Bills for a day, optionally narrowed to one operator.
|
||||
/// Bills for a day that have *not* yet been accepted by the server.
|
||||
///
|
||||
/// A shift report that is settled against a till has to cover exactly the
|
||||
/// bills that cashier rang, not everything the terminal did that day.
|
||||
///
|
||||
/// Restricted to pending rows on purpose. The moment a bill is accepted its
|
||||
/// figures are folded into [Tables.dayArchive], and the report adds the two
|
||||
/// together — so an accepted row still sitting here during its retention
|
||||
/// window would be counted twice and inflate the day's takings.
|
||||
Future<List<SaleTransaction>> forBusinessDate(
|
||||
DateTime day, {
|
||||
String? cashierName,
|
||||
}) =>
|
||||
_query(
|
||||
where: cashierName == null
|
||||
? 'business_date = ?'
|
||||
: 'business_date = ? AND cashier_name = ?',
|
||||
? 'business_date = ? AND sync_status = ?'
|
||||
: 'business_date = ? AND sync_status = ? AND cashier_name = ?',
|
||||
whereArgs: [
|
||||
businessDateOf(day),
|
||||
pending,
|
||||
if (cashierName != null) cashierName,
|
||||
],
|
||||
);
|
||||
@@ -241,14 +254,19 @@ class OrderDao {
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- Sync
|
||||
/// Folds accepted orders into the day archive, then deletes them.
|
||||
/// Folds accepted orders into the day archive and marks them synced.
|
||||
///
|
||||
/// Once the server holds a bill the terminal has no reason to keep it, so
|
||||
/// the rows go. Their figures are added to [Tables.dayArchive] first, so the
|
||||
/// shift totals a cashier sees do not collapse after a mid-shift sync.
|
||||
/// Both steps run in one transaction: if the delete fails the archive is
|
||||
/// rolled back with it, and nothing is counted twice.
|
||||
Future<void> archiveAndDelete(List<SaleTransaction> orders) async {
|
||||
/// Their figures are added to [Tables.dayArchive] so the shift totals a
|
||||
/// cashier sees do not collapse after a mid-shift sync, and the rows
|
||||
/// themselves are kept — at `sync_status = 1` — until [purgeSyncedBefore]
|
||||
/// retires them. Keeping them buys a recovery window: if the back office
|
||||
/// loses a batch, the full bills are still on the terminal and can be sent
|
||||
/// again. Once deleted only the archived totals survive, and a lost bill's
|
||||
/// line items are gone for good.
|
||||
///
|
||||
/// Both steps run in one transaction: if the status flip fails the archive
|
||||
/// rolls back with it, and nothing is counted twice.
|
||||
Future<void> archiveAccepted(List<SaleTransaction> orders) async {
|
||||
if (orders.isEmpty) return;
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
@@ -331,17 +349,30 @@ class OrderDao {
|
||||
);
|
||||
}
|
||||
|
||||
// order_items goes with it via ON DELETE CASCADE.
|
||||
final ids = orders.map((o) => o.id).toList();
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
await txn.delete(
|
||||
Tables.orders,
|
||||
where: 'id IN ($placeholders)',
|
||||
whereArgs: ids,
|
||||
await txn.rawUpdate(
|
||||
'UPDATE ${Tables.orders} '
|
||||
'SET sync_status = ?, synced_at = ?, sync_error = NULL '
|
||||
'WHERE id IN ($placeholders)',
|
||||
[synced, DateTime.now().millisecondsSinceEpoch, ...ids],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Retires bills the server took delivery of more than [retention] ago.
|
||||
///
|
||||
/// Their archived totals are untouched and stay forever — this drops only the
|
||||
/// re-sendable copy once the recovery window has closed, so a terminal that
|
||||
/// trades for years does not carry every bill it has ever rung.
|
||||
///
|
||||
/// Returns how many rows went. `order_items` follows via ON DELETE CASCADE.
|
||||
Future<int> purgeSyncedBefore(DateTime cutoff) => _db.delete(
|
||||
Tables.orders,
|
||||
where: 'sync_status = ? AND synced_at IS NOT NULL AND synced_at < ?',
|
||||
whereArgs: [synced, cutoff.millisecondsSinceEpoch],
|
||||
);
|
||||
|
||||
/// Archived figures for a business day, one row per cashier.
|
||||
///
|
||||
/// Empty when nothing has synced yet. Pass [cashierName] to scope it to a
|
||||
|
||||
Reference in New Issue
Block a user