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

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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();
});