Answers "which of my 100 tills are alive and healthy", and fixes three things that were fine on one device and broken on a hundred. Terminal identity (lib/data/local/terminal_identity.dart) - Every device mints a UUID on first run, stored in its own database, plus a short code (T4A9) derived from it. Renaming keeps the device id, so history keeps pointing at the same physical till. - Replaces the literal 'TERM-01', which was hardcoded in five places. The whole fleet reported as one terminal: shift reports merged, MQTT topics collided, and a second connection with the same client id evicts the first from the broker — so two tills would have knocked each other offline in a loop. Invoice numbers now carry the terminal code - INV-2608-T4A9-00042. The sequence counter lives in each till's own database and starts at 1, so without this every terminal in the fleet minted INV-2608-00001 for its first sale of the month. The order UUID kept the data distinct; the number a customer quotes on a receipt was not. SQLite pragmas - WAL, so the product grid refreshing does not block the sale being written, and the file is never left mid-rewrite by a power cut. - busy_timeout 5s, so a contended lock waits instead of throwing "database is locked" — which at checkout is a failed sale with a customer standing there. - synchronous NORMAL, the right trade under WAL for a till. Fleet presence (lib/data/sync/presence_reporter.dart) - Retained status record on connect and once a minute: device id, code, name, app version, pending bill count, last upload, catalogue revision, sync halt state. Retained so a dashboard connecting at noon gets all 100 terminals immediately rather than a blank board. - The Last Will already said "reachable". A till can be connected and still be holding 200 unsent bills or running last month's prices; only pending_bills and catalogue_revision say so. NATS - The MQTT gateway maps / to . so the existing transport works unchanged. SyncConfig.asNatsSubject() exposes the translation, and the contract doc gives the JetStream subjects (pos.*.*.order, pos.*.*.status) plus the two server-side requirements: a file-backed stream, and the ack published by the consumer after commit rather than by the ingest handler. Tests: 129 -> 140. New coverage for identity minting and stability, per-device invoice uniqueness, topic and client-id separation, NATS subject mapping, and the two pragmas. Suite run three times clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
3.4 KiB
Dart
110 lines
3.4 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import '../../core/config/sync_config.dart';
|
|
import '../local/terminal_identity.dart';
|
|
import '../remote/mqtt_order_transport.dart';
|
|
import 'sync_engine.dart';
|
|
|
|
/// Publishes what this till is doing, so a fleet of them can be watched.
|
|
///
|
|
/// The Last Will already answers "is it dead" — the broker publishes `offline`
|
|
/// when a terminal stops responding. That is not enough to run 100 shops on: a
|
|
/// till can be connected and still be broken, holding 200 unsent bills or
|
|
/// running last month's catalogue. This publishes the state that distinguishes
|
|
/// *reachable* from *healthy*.
|
|
///
|
|
/// Retained on purpose. A dashboard that connects at noon gets every terminal's
|
|
/// last report immediately, instead of a blank board until each one happens to
|
|
/// tick.
|
|
class PresenceReporter {
|
|
PresenceReporter({
|
|
required MqttOrderTransport transport,
|
|
required TerminalIdentity terminal,
|
|
required SyncConfig config,
|
|
required SyncEngine engine,
|
|
required this.appVersion,
|
|
required Future<String?> Function() catalogueRevision,
|
|
Duration interval = const Duration(minutes: 1),
|
|
DateTime Function()? clock,
|
|
Timer Function(Duration, void Function())? scheduleTimer,
|
|
}) : _transport = transport,
|
|
_terminal = terminal,
|
|
_config = config,
|
|
_engine = engine,
|
|
_catalogueRevision = catalogueRevision,
|
|
_interval = interval,
|
|
_now = clock ?? DateTime.now,
|
|
_schedule = scheduleTimer ?? Timer.new;
|
|
|
|
final MqttOrderTransport _transport;
|
|
final TerminalIdentity _terminal;
|
|
final SyncConfig _config;
|
|
final SyncEngine _engine;
|
|
final Future<String?> Function() _catalogueRevision;
|
|
final Duration _interval;
|
|
final DateTime Function() _now;
|
|
final Timer Function(Duration, void Function()) _schedule;
|
|
|
|
final String appVersion;
|
|
|
|
Timer? _timer;
|
|
bool _stopped = false;
|
|
|
|
Future<void> start() async {
|
|
if (_stopped) throw StateError('This PresenceReporter has been disposed.');
|
|
await publish();
|
|
_tick();
|
|
}
|
|
|
|
void _tick() {
|
|
_timer = _schedule(_interval, () {
|
|
if (_stopped) return;
|
|
unawaited(publish());
|
|
_tick();
|
|
});
|
|
}
|
|
|
|
/// One presence record.
|
|
///
|
|
/// Failures are swallowed. A terminal that cannot tell head office how it is
|
|
/// must still sell — losing a heartbeat is a monitoring gap, not a reason to
|
|
/// stop trading.
|
|
Future<void> publish() async {
|
|
if (!_transport.isConnected) return;
|
|
|
|
try {
|
|
final state = _engine.state;
|
|
await _transport.publishStatus(
|
|
jsonEncode({
|
|
'schema': 1,
|
|
'state': 'online',
|
|
'device_id': _terminal.deviceId,
|
|
'terminal_code': _terminal.code,
|
|
'terminal_name': _terminal.name,
|
|
'store_id': _terminal.storeId,
|
|
'app_version': appVersion,
|
|
'reported_at': _now().toIso8601String(),
|
|
|
|
// The three numbers that separate a healthy till from a broken one.
|
|
'pending_bills': state.pending,
|
|
'last_upload_at': state.lastSuccessAt?.toIso8601String(),
|
|
'catalogue_revision': await _catalogueRevision(),
|
|
|
|
'sync_halted': state.isHalted,
|
|
'sync_error': state.lastError,
|
|
'consecutive_failures': state.consecutiveFailures,
|
|
'transport': _config.transport.name,
|
|
}),
|
|
);
|
|
} on Object {
|
|
// Deliberately silent — see above.
|
|
}
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
_stopped = true;
|
|
_timer?.cancel();
|
|
}
|
|
}
|