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

@@ -0,0 +1,123 @@
/// Which route completed bills take to the back office.
enum TransportKind {
/// No back office wired up — bills queue and drain against a local stub.
simulated,
/// Plain request/response upload. Easiest to debug: curl reproduces it and
/// failures come back as status codes.
http,
/// Persistent connection. Costs a broker, and buys the downlink: head office
/// can push a price change or ask a terminal to sync without waiting for it
/// to ask first.
mqtt,
}
/// Everything the terminal needs to reach the back office.
///
/// One object rather than scattered constants so a store can be re-pointed at
/// a different broker without a rebuild, and so tests can construct a whole
/// configuration inline.
class SyncConfig {
const SyncConfig({
this.transport = TransportKind.simulated,
this.storeId = 'store-01',
this.terminalId = 'TERM-01',
this.brokerHost = '',
this.brokerPort = 8883,
this.useTls = true,
this.username,
this.password,
this.httpBaseUrl = '',
this.apiKey,
this.ackTimeout = const Duration(seconds: 20),
this.batchSize = 50,
});
final TransportKind transport;
/// Namespaces every topic. Two stores on one broker must never collide.
final String storeId;
final String terminalId;
final String brokerHost;
final int brokerPort;
final bool useTls;
final String? username;
final String? password;
final String httpBaseUrl;
final String? apiKey;
/// How long to wait for the back office to confirm a batch before treating
/// the outcome as unknown and leaving every row pending.
///
/// Generous on purpose: a timeout that fires while the server is committing
/// produces a duplicate send, which is safe only because the back office
/// keys on order id — but it is still wasted traffic on a slow shop line.
final Duration ackTimeout;
/// Bills per publish. Small enough to stay well inside broker message limits
/// on a day that has built up a long backlog.
final int batchSize;
bool get isConfigured => switch (transport) {
TransportKind.simulated => true,
TransportKind.http => httpBaseUrl.isNotEmpty,
TransportKind.mqtt => brokerHost.isNotEmpty,
};
// ------------------------------------------------------------------ Topics
String get _base => 'pos/$storeId/$terminalId';
/// Uplink. Completed bills, QoS 1.
String get orderTopic => '$_base/order';
/// The back office's answer, naming the ids it committed. Subscribed at
/// QoS 1: losing an ack means re-sending bills that are already banked.
String get ackTopic => '$_base/ack';
/// Retained, and set as the will message. A terminal that loses power stops
/// refreshing it and the broker publishes `offline` on its behalf, which is
/// what makes a head-office "which tills are dark" board possible.
String get statusTopic => '$_base/status';
/// Store-wide downlink: catalogue changes land here for every terminal.
String get catalogueTopic => 'pos/$storeId/catalogue';
/// Addressed to this terminal alone.
String get commandTopic => '$_base/command';
/// Stable across restarts so the broker can resume a session and redeliver
/// anything in flight, rather than treating each launch as a new client.
String get clientId => 'pos-$storeId-$terminalId';
SyncConfig copyWith({
TransportKind? transport,
String? storeId,
String? terminalId,
String? brokerHost,
int? brokerPort,
bool? useTls,
String? username,
String? password,
String? httpBaseUrl,
String? apiKey,
Duration? ackTimeout,
int? batchSize,
}) =>
SyncConfig(
transport: transport ?? this.transport,
storeId: storeId ?? this.storeId,
terminalId: terminalId ?? this.terminalId,
brokerHost: brokerHost ?? this.brokerHost,
brokerPort: brokerPort ?? this.brokerPort,
useTls: useTls ?? this.useTls,
username: username ?? this.username,
password: password ?? this.password,
httpBaseUrl: httpBaseUrl ?? this.httpBaseUrl,
apiKey: apiKey ?? this.apiKey,
ackTimeout: ackTimeout ?? this.ackTimeout,
batchSize: batchSize ?? this.batchSize,
);
}