Files
nearle_pos/lib/domain/repositories/sync_repository.dart
Suriya 0a49323858 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>
2026-08-01 10:57:29 +05:30

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