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:
@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/config/sync_config.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../data/local/order_dao.dart';
|
||||
import '../../../domain/entities/store_account.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
import '../providers/printer_settings.dart';
|
||||
@@ -318,11 +320,14 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
final ready = ref.watch(catalogueReadyProvider);
|
||||
final lastImport = ref.watch(lastImportAtProvider);
|
||||
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final config = ref.watch(syncConfigProvider);
|
||||
final sync = ref.watch(syncEngineStateProvider).value ??
|
||||
ref.watch(syncEngineProvider).state;
|
||||
|
||||
return PanelCard(
|
||||
title: 'Connectivity & sync',
|
||||
subtitle: 'This terminal only needs a connection to import the '
|
||||
'catalogue and to push the shift report.',
|
||||
subtitle: 'Bills are written to this terminal first and uploaded in the '
|
||||
'background. Nothing is ever held up waiting for the network.',
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -331,7 +336,26 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
'Last import',
|
||||
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
|
||||
),
|
||||
_row('Unsynced bills', '$outstanding'),
|
||||
_row('Route', _transportLabel(config)),
|
||||
_row('Waiting to upload', '$outstanding bill(s)'),
|
||||
_row(
|
||||
'Last upload',
|
||||
sync.lastSuccessAt == null
|
||||
? 'Never'
|
||||
: Formatters.dateTime(sync.lastSuccessAt!),
|
||||
),
|
||||
if (sync.isHalted)
|
||||
_row('Status', 'Halted — ${sync.lastError ?? 'refused'}')
|
||||
else if (sync.nextAttemptAt != null)
|
||||
_row(
|
||||
'Next attempt',
|
||||
'${Formatters.time(sync.nextAttemptAt!)} '
|
||||
'(attempt ${sync.consecutiveFailures + 1})',
|
||||
),
|
||||
_row(
|
||||
'Bills kept on device',
|
||||
'${OrderDao.retentionWindow.inDays} days after upload',
|
||||
),
|
||||
_toggle(
|
||||
'Simulate offline',
|
||||
'Forces import and sync to fail, so you can confirm nothing is '
|
||||
@@ -340,6 +364,9 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
ref.watch(simulateOfflineProvider),
|
||||
(v) {
|
||||
ref.read(simulateOfflineProvider.notifier).state = v;
|
||||
// The pill and the drain both read connectivity, so they have to
|
||||
// be told the switch moved.
|
||||
ref.read(connectivityServiceProvider).refresh();
|
||||
// Clear any stale failure banner left by the previous setting.
|
||||
ref.read(catalogueImportProvider.notifier).reset();
|
||||
ref.read(orderSyncProvider.notifier).reset();
|
||||
@@ -350,6 +377,15 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
|
||||
);
|
||||
}
|
||||
|
||||
String _transportLabel(SyncConfig config) => switch (config.transport) {
|
||||
TransportKind.simulated =>
|
||||
'Simulated — no back office configured for this terminal',
|
||||
TransportKind.http => 'HTTP · ${config.httpBaseUrl}',
|
||||
TransportKind.mqtt =>
|
||||
'MQTT · ${config.brokerHost}:${config.brokerPort}'
|
||||
'${config.useTls ? ' (TLS)' : ''}',
|
||||
};
|
||||
|
||||
Widget _aboutCard() => PanelCard(
|
||||
title: 'About',
|
||||
child: Column(
|
||||
|
||||
Reference in New Issue
Block a user