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>
101 lines
2.9 KiB
Dart
101 lines
2.9 KiB
Dart
import '../entities/shift_report.dart';
|
|
import '../entities/sync_event.dart';
|
|
import '../entities/transaction.dart';
|
|
|
|
/// Result of one upload pass.
|
|
class SyncOutcome {
|
|
const SyncOutcome({
|
|
required this.attempted,
|
|
required this.uploaded,
|
|
this.rejected = 0,
|
|
this.error,
|
|
this.isRetryable = true,
|
|
});
|
|
|
|
final int attempted;
|
|
final int uploaded;
|
|
|
|
/// Bills the back office looked at and refused. These stay on the terminal
|
|
/// but sending them again unchanged will fail again, so they need a person.
|
|
final int rejected;
|
|
|
|
final String? error;
|
|
|
|
/// Whether trying again could plausibly work. False for a bad credential or
|
|
/// an unconfigured endpoint — the drain engine halts rather than retrying
|
|
/// something that cannot succeed.
|
|
final bool isRetryable;
|
|
|
|
bool get isSuccess => error == null;
|
|
bool get hadNothingToDo => attempted == 0;
|
|
int get remaining => attempted - uploaded;
|
|
}
|
|
|
|
/// One row of the order sync log.
|
|
class OrderSyncRow {
|
|
const OrderSyncRow({
|
|
required this.orderId,
|
|
required this.invoiceNumber,
|
|
required this.total,
|
|
required this.createdAt,
|
|
required this.isSynced,
|
|
this.syncedAt,
|
|
this.attempts = 0,
|
|
this.error,
|
|
});
|
|
|
|
final String orderId;
|
|
final String invoiceNumber;
|
|
final double total;
|
|
final DateTime createdAt;
|
|
final bool isSynced;
|
|
final DateTime? syncedAt;
|
|
final int attempts;
|
|
final String? error;
|
|
}
|
|
|
|
/// The terminal's network touchpoints.
|
|
///
|
|
/// Pull the catalogue; upload every order still at `sync_status = 0`. Nothing
|
|
/// else leaves the device. *When* the upload runs is not decided here — see
|
|
/// `SyncEngine`, which owns triggers and retry policy.
|
|
abstract class SyncRepository {
|
|
bool get hasCatalogue;
|
|
DateTime? get lastImportAt;
|
|
String? get catalogueRevision;
|
|
|
|
/// Morning step — downloads products and writes them to SQLite.
|
|
Future<SyncEvent> importCatalogue({
|
|
void Function(double progress, String stage)? onProgress,
|
|
});
|
|
|
|
/// How many bills are still held locally.
|
|
Future<int> unsyncedCount();
|
|
|
|
Future<List<SaleTransaction>> unsyncedOrders();
|
|
|
|
/// Today's trading totals, read back from SQLite.
|
|
///
|
|
/// Terminal-wide by default. Set [scopeToCashier] to cover only the bills
|
|
/// [cashierName] rang — what a till is actually settled against.
|
|
Future<ShiftReport> todayReport({
|
|
required String terminalId,
|
|
required String cashierName,
|
|
bool scopeToCashier = false,
|
|
});
|
|
|
|
/// One upload pass — sends pending orders and flips the ones the back office
|
|
/// confirmed to `sync_status = 1`. Failures leave every row untouched at 0.
|
|
Future<SyncOutcome> syncOrders({
|
|
void Function(double progress, String stage)? onProgress,
|
|
});
|
|
|
|
/// Retires confirmed bills past their retention window. Archived totals are
|
|
/// untouched.
|
|
Future<int> purgeExpired();
|
|
|
|
Future<List<OrderSyncRow>> orderSyncRows({int limit = 200});
|
|
|
|
List<SyncEvent> get events;
|
|
}
|