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:
Suriya
2026-08-01 10:57:29 +05:30
parent af3933092f
commit 0a49323858
29 changed files with 2855 additions and 112 deletions

View File

@@ -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