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