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:
123
lib/core/config/sync_config.dart
Normal file
123
lib/core/config/sync_config.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
76
lib/core/services/connectivity_service.dart
Normal file
76
lib/core/services/connectivity_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
/// Tells the terminal when it is worth trying the network.
|
||||
///
|
||||
/// This is a *hint*, not proof. The platform reports whether an interface is
|
||||
/// up, which on shop wifi is routinely true while the line itself is dead. So
|
||||
/// nothing here decides that a sync succeeded — only the transport's answer
|
||||
/// does. What this buys is the moment to try: the difference between a bill
|
||||
/// going up the second the router comes back and it waiting for the next poll.
|
||||
///
|
||||
/// The Settings "Simulate offline" switch is folded in here so there is one
|
||||
/// answer to "are we online", rather than a real state and a demo state that
|
||||
/// can disagree.
|
||||
class ConnectivityService {
|
||||
ConnectivityService({
|
||||
Connectivity? connectivity,
|
||||
bool Function()? isSimulatedOffline,
|
||||
}) : _connectivity = connectivity ?? Connectivity(),
|
||||
_isSimulatedOffline = isSimulatedOffline ?? (() => false);
|
||||
|
||||
final Connectivity _connectivity;
|
||||
final bool Function() _isSimulatedOffline;
|
||||
|
||||
final _controller = StreamController<bool>.broadcast();
|
||||
StreamSubscription<List<ConnectivityResult>>? _subscription;
|
||||
|
||||
bool _hasInterface = true;
|
||||
bool _started = false;
|
||||
|
||||
/// Fires only when the answer changes, so a subscriber can treat every event
|
||||
/// as an edge.
|
||||
Stream<bool> get onlineChanges => _controller.stream;
|
||||
|
||||
bool get isOnline => _hasInterface && !_isSimulatedOffline();
|
||||
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
try {
|
||||
_hasInterface = _hasAny(await _connectivity.checkConnectivity());
|
||||
} on Exception {
|
||||
// No platform channel — a test host, or a desktop build without the
|
||||
// plugin registered. Assume online and let the transport be the judge;
|
||||
// refusing to try would be worse than trying and failing.
|
||||
_hasInterface = true;
|
||||
}
|
||||
|
||||
try {
|
||||
_subscription = _connectivity.onConnectivityChanged.listen((results) {
|
||||
_update(_hasAny(results));
|
||||
});
|
||||
} on Exception {
|
||||
// As above: without the stream the periodic poll still drains the queue.
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-evaluates after the Settings switch is flipped.
|
||||
void refresh() => _update(_hasInterface);
|
||||
|
||||
void _update(bool hasInterface) {
|
||||
final was = isOnline;
|
||||
_hasInterface = hasInterface;
|
||||
if (isOnline != was) _controller.add(isOnline);
|
||||
}
|
||||
|
||||
static bool _hasAny(List<ConnectivityResult> results) =>
|
||||
results.any((r) => r != ConnectivityResult.none);
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _subscription?.cancel();
|
||||
await _controller.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user