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:
@@ -61,11 +61,22 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
if (!store.isReady) return;
|
||||
|
||||
final dao = store.catalogue;
|
||||
|
||||
// Read every value first, then assign. Written inline the four awaits still
|
||||
// run before the assignment, so leaving Settings mid-read threw
|
||||
// "used after dispose" — which reaches the cashier as a red screen.
|
||||
final url = await dao.meta(MetaKeys.printerUrl);
|
||||
final name = await dao.meta(MetaKeys.printerName);
|
||||
final autoPrint = await dao.meta(MetaKeys.autoPrint);
|
||||
final openDrawer = await dao.meta(MetaKeys.openDrawer);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
state = PrinterSettings(
|
||||
printerUrl: await dao.meta(MetaKeys.printerUrl),
|
||||
printerName: await dao.meta(MetaKeys.printerName),
|
||||
autoPrint: (await dao.meta(MetaKeys.autoPrint)) == '1',
|
||||
openDrawer: (await dao.meta(MetaKeys.openDrawer)) != '0',
|
||||
printerUrl: url,
|
||||
printerName: name,
|
||||
autoPrint: autoPrint == '1',
|
||||
openDrawer: openDrawer != '0',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,12 +86,14 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
if (printer == null) {
|
||||
await dao.setMeta(MetaKeys.printerUrl, '');
|
||||
await dao.setMeta(MetaKeys.printerName, '');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(clearPrinter: true, autoPrint: false);
|
||||
return;
|
||||
}
|
||||
|
||||
await dao.setMeta(MetaKeys.printerUrl, printer.url);
|
||||
await dao.setMeta(MetaKeys.printerName, printer.name);
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
printerUrl: printer.url,
|
||||
printerName: printer.name,
|
||||
@@ -95,6 +108,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
.read(localStoreProvider)
|
||||
.catalogue
|
||||
.setMeta(MetaKeys.autoPrint, value ? '1' : '0');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(autoPrint: value);
|
||||
}
|
||||
|
||||
@@ -103,6 +117,7 @@ class PrinterSettingsController extends StateNotifier<PrinterSettings> {
|
||||
.read(localStoreProvider)
|
||||
.catalogue
|
||||
.setMeta(MetaKeys.openDrawer, value ? '1' : '0');
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(openDrawer: value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/providers.dart';
|
||||
import '../../../core/utils/extensions.dart';
|
||||
import '../../../data/sync/sync_engine.dart';
|
||||
import '../../../domain/entities/transaction.dart';
|
||||
import '../../../domain/usecases/checkout_sale.dart';
|
||||
import '../../auth/providers/auth_controller.dart';
|
||||
@@ -199,6 +200,11 @@ class PaymentController extends StateNotifier<PaymentState> {
|
||||
_ref.invalidate(visibleProductsProvider);
|
||||
_ref.read(orderVersionProvider.notifier).state++;
|
||||
|
||||
// The bill is safely on disk; getting it to the back office is the
|
||||
// engine's problem now. Deliberately not awaited — the cashier must
|
||||
// reach the receipt screen at network speed of zero.
|
||||
_ref.read(syncEngineProvider).nudge(SyncTrigger.saleCommitted);
|
||||
|
||||
return result;
|
||||
} on CheckoutFailure catch (e) {
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -154,16 +154,19 @@ class _Breadcrumb extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LivePill extends StatefulWidget {
|
||||
/// What the pill is saying, in the order it takes precedence.
|
||||
enum _Liveness { offlineSim, halted, syncing, queued, live }
|
||||
|
||||
class _LivePill extends ConsumerStatefulWidget {
|
||||
const _LivePill({required this.offline});
|
||||
|
||||
final bool offline;
|
||||
|
||||
@override
|
||||
State<_LivePill> createState() => _LivePillState();
|
||||
ConsumerState<_LivePill> createState() => _LivePillState();
|
||||
}
|
||||
|
||||
class _LivePillState extends State<_LivePill>
|
||||
class _LivePillState extends ConsumerState<_LivePill>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
@@ -178,16 +181,62 @@ class _LivePillState extends State<_LivePill>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final offline = widget.offline;
|
||||
final tone = offline ? AppColors.warning : AppColors.success;
|
||||
final surface =
|
||||
offline ? AppColors.warningSurface : AppColors.successSurface;
|
||||
// The engine may not have emitted yet on a cold start, so fall back to its
|
||||
// current value rather than showing nothing.
|
||||
final sync = ref.watch(syncEngineStateProvider).value ??
|
||||
ref.watch(syncEngineProvider).state;
|
||||
|
||||
final liveness = switch (0) {
|
||||
_ when widget.offline => _Liveness.offlineSim,
|
||||
_ when sync.isHalted => _Liveness.halted,
|
||||
_ when sync.isSyncing => _Liveness.syncing,
|
||||
// Bills waiting is normal for a few seconds after a sale; it is only
|
||||
// worth flagging once they are visibly piling up.
|
||||
_ when sync.pending > 0 => _Liveness.queued,
|
||||
_ => _Liveness.live,
|
||||
};
|
||||
|
||||
final (label, tone, surface, message) = switch (liveness) {
|
||||
_Liveness.offlineSim => (
|
||||
'OFFLINE (SIM)',
|
||||
AppColors.warning,
|
||||
AppColors.warningSurface,
|
||||
'Simulate offline is ON in Settings — imports and syncs are being '
|
||||
'failed deliberately.',
|
||||
),
|
||||
_Liveness.halted => (
|
||||
'SYNC HALTED',
|
||||
AppColors.danger,
|
||||
AppColors.dangerSurface,
|
||||
'Uploading stopped because retrying will not help: '
|
||||
'${sync.lastError ?? 'the back office refused the batch'}. '
|
||||
'Every bill is still safe on this terminal. Press Sync to try '
|
||||
'again once it is sorted.',
|
||||
),
|
||||
_Liveness.syncing => (
|
||||
'SYNCING',
|
||||
AppColors.primary,
|
||||
AppColors.primarySurface,
|
||||
'Uploading bills to the back office.',
|
||||
),
|
||||
_Liveness.queued => (
|
||||
'${sync.pending} QUEUED',
|
||||
AppColors.warning,
|
||||
AppColors.warningSurface,
|
||||
'${sync.pending} bill(s) are stored on this terminal and waiting to '
|
||||
'upload. They are safe; nothing is lost while the line is down.',
|
||||
),
|
||||
_Liveness.live => (
|
||||
'LIVE',
|
||||
AppColors.success,
|
||||
AppColors.successSurface,
|
||||
'Terminal is operating normally and everything rung has been '
|
||||
'uploaded.',
|
||||
),
|
||||
};
|
||||
|
||||
return Tooltip(
|
||||
message: offline
|
||||
? 'Simulate offline is ON in Settings — imports and syncs are being '
|
||||
'failed deliberately.'
|
||||
: 'Terminal is operating normally.',
|
||||
message: message,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
@@ -210,7 +259,7 @@ class _LivePillState extends State<_LivePill>
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs + 2),
|
||||
Text(
|
||||
offline ? 'OFFLINE (SIM)' : 'LIVE',
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: tone,
|
||||
fontSize: 10.5,
|
||||
|
||||
@@ -167,6 +167,12 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
|
||||
bool get isRunning => state is SyncRunning;
|
||||
|
||||
/// Uploads every bill at sync_status = 0 and flips the accepted ones to 1.
|
||||
///
|
||||
/// Goes through the engine rather than straight to the repository, so a
|
||||
/// cashier pressing sync while a background drain is already mid-flight
|
||||
/// joins it instead of starting a second pass over the same rows. It also
|
||||
/// clears a halt: pressing the button is how you retry after the back office
|
||||
/// has been fixed.
|
||||
Future<SyncOutcome> run() async {
|
||||
if (isRunning) {
|
||||
return const SyncOutcome(attempted: 0, uploaded: 0);
|
||||
@@ -174,7 +180,7 @@ class OrderSyncController extends StateNotifier<OrderSyncState> {
|
||||
|
||||
state = const SyncRunning(0, 'Starting…');
|
||||
|
||||
final outcome = await _ref.read(syncRepositoryProvider).syncOrders(
|
||||
final outcome = await _ref.read(syncEngineProvider).syncNow(
|
||||
onProgress: (progress, stage) {
|
||||
if (mounted) state = SyncRunning(progress, stage);
|
||||
},
|
||||
@@ -192,3 +198,30 @@ final orderSyncProvider =
|
||||
StateNotifierProvider<OrderSyncController, OrderSyncState>(
|
||||
(ref) => OrderSyncController(ref),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------- Background drain
|
||||
/// Brings the queue-and-drain machinery up, once, when the shell mounts.
|
||||
///
|
||||
/// Deliberately not gated on sign-in: a terminal that boots holding yesterday's
|
||||
/// bills should be emptying its queue before anyone reaches the till.
|
||||
///
|
||||
/// Overridden to a no-op in widget tests, which have no network stack and
|
||||
/// cannot drive real disk I/O on a fake clock.
|
||||
final syncBootstrapProvider = FutureProvider<void>((ref) async {
|
||||
await ref.read(connectivityServiceProvider).start();
|
||||
|
||||
final engine = ref.read(syncEngineProvider);
|
||||
|
||||
// A background drain moves bills out of the pending set, so the tallies and
|
||||
// shift totals on screen are stale the moment one finishes.
|
||||
var wasSyncing = false;
|
||||
final subscription = engine.states.listen((state) {
|
||||
if (wasSyncing && !state.isSyncing) {
|
||||
ref.read(orderVersionProvider.notifier).state++;
|
||||
}
|
||||
wasSyncing = state.isSyncing;
|
||||
});
|
||||
ref.onDispose(subscription.cancel);
|
||||
|
||||
await engine.start();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user