A shop configured for the HTTP route uploaded 17 bills correctly today and never once appeared on the fleet board. Nothing logged it, because from the terminal's point of view nothing had failed: the reporter was typed against MqttOrderTransport and started behind an `is MqttOrderTransport` check, so on HTTP it was silently never constructed. A monitoring feature that quietly does not exist on one of two supported routes is worse than no feature, because the blank square reads as "no terminals" rather than "not wired up". publishHealth moves onto the OrderTransport interface. The broker publishes to the health topic as before; HTTP posts the same payload to POST /pos/health; the simulated route does nothing, which is the honest answer for a till with no back office configured. The reporter is now started for every route. There was no test for the reporter at all, which is why this shipped. There are five now, including one that fails on the old code. Also removes test/widget_test.dart — the stock `flutter create` counter test, referencing a MyApp that never existed here. It has never compiled and was the only red in the suite. 263 tests pass, analyzer clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
7.2 KiB
Dart
217 lines
7.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import '../../core/config/sync_config.dart';
|
|
import '../../domain/repositories/sync_repository.dart';
|
|
import '../local/terminal_identity.dart';
|
|
import '../remote/order_transport.dart';
|
|
import 'sync_engine.dart';
|
|
|
|
/// What a till reports about itself, every 30 seconds.
|
|
///
|
|
/// The Last Will already answers *is it dead* — the broker publishes `offline`
|
|
/// on a terminal's behalf when it stops responding. That is not enough to run a
|
|
/// hundred shops on, because the failure that actually costs money looks
|
|
/// completely healthy from outside: a till that is connected, selling, and
|
|
/// quietly accumulating two hundred bills it has never managed to upload.
|
|
///
|
|
/// So this carries the numbers that separate *reachable* from *well*: how deep
|
|
/// the queue is, how long the oldest thing in it has been waiting, whether the
|
|
/// till has rung anything today, and whether the hardware is in the way.
|
|
///
|
|
/// Not retained, and never acknowledged. A heartbeat is a fact with an expiry
|
|
/// date — the back office holds it in Redis under a TTL, so a terminal that
|
|
/// loses power ages off the board by itself. Retaining it would leave a dead
|
|
/// till looking alive until something overwrote it.
|
|
class HealthReporter {
|
|
HealthReporter({
|
|
required OrderTransport transport,
|
|
required TerminalIdentity terminal,
|
|
required SyncConfig config,
|
|
required SyncEngine engine,
|
|
required SyncRepository repository,
|
|
required this.appVersion,
|
|
this.deviceState,
|
|
this.printerEndpoint,
|
|
Duration interval = const Duration(seconds: 30),
|
|
DateTime Function()? clock,
|
|
Timer Function(Duration, void Function())? scheduleTimer,
|
|
}) : _transport = transport,
|
|
_terminal = terminal,
|
|
_config = config,
|
|
_engine = engine,
|
|
_repository = repository,
|
|
_interval = interval,
|
|
_now = clock ?? DateTime.now,
|
|
_schedule = scheduleTimer ?? Timer.new;
|
|
|
|
final OrderTransport _transport;
|
|
final TerminalIdentity _terminal;
|
|
final SyncConfig _config;
|
|
final SyncEngine _engine;
|
|
final SyncRepository _repository;
|
|
final Duration _interval;
|
|
final DateTime Function() _now;
|
|
final Timer Function(Duration, void Function()) _schedule;
|
|
|
|
final String appVersion;
|
|
|
|
/// Hardware readings, if this build collects any.
|
|
///
|
|
/// A hook rather than a hard dependency: battery level and free storage need
|
|
/// platform packages that a desktop build has no use for, and a health board
|
|
/// is not a good enough reason to make the whole app depend on them. What is
|
|
/// not collected is *omitted* rather than sent as zero — a dashboard showing
|
|
/// every till at 0% battery is worse than one showing nothing.
|
|
final Future<Map<String, Object?>> Function()? deviceState;
|
|
|
|
/// Where the receipt printer lives, if one is configured.
|
|
///
|
|
/// Read fresh on each beat rather than captured once, because a shop can
|
|
/// re-point its printer in Settings without restarting the till.
|
|
final ({String host, int port})? Function()? printerEndpoint;
|
|
|
|
Timer? _timer;
|
|
bool _stopped = false;
|
|
|
|
Future<void> start() async {
|
|
if (_stopped) throw StateError('This HealthReporter has been disposed.');
|
|
await publish();
|
|
_tick();
|
|
}
|
|
|
|
void _tick() {
|
|
_timer = _schedule(_interval, () {
|
|
if (_stopped) return;
|
|
unawaited(publish());
|
|
_tick();
|
|
});
|
|
}
|
|
|
|
/// One heartbeat.
|
|
///
|
|
/// Every failure is swallowed. A terminal that cannot say how it is must
|
|
/// still sell — losing a heartbeat is a monitoring gap, and stopping a till
|
|
/// because a dashboard is unreachable would be a self-inflicted outage.
|
|
Future<void> publish() async {
|
|
if (!_transport.isConnected) return;
|
|
|
|
try {
|
|
final state = _engine.state;
|
|
|
|
final pendingBills = state.pending;
|
|
final pendingRegistrations = await _repository.unsyncedCustomerCount();
|
|
|
|
// Terminal-wide rather than scoped to whoever is signed in: the board
|
|
// watches a till, not a shift.
|
|
final today = await _repository.todayReport(
|
|
terminalId: _terminal.code,
|
|
cashierName: '',
|
|
);
|
|
|
|
final payload = <String, Object?>{
|
|
'schema': 1,
|
|
'status': 'online',
|
|
|
|
'terminal_id': _terminal.code,
|
|
'device_id': _terminal.deviceId,
|
|
'terminal_name': _terminal.name,
|
|
'location_id': _config.storeId,
|
|
'store_name': _terminal.name,
|
|
'app_version': appVersion,
|
|
'transport': _config.transport.name,
|
|
|
|
// Queue depth — the number that makes a silent failure visible.
|
|
'pending_bills': pendingBills,
|
|
'pending_registrations': pendingRegistrations,
|
|
'oldest_pending_at': (await _oldestPendingAt())?.toIso8601String(),
|
|
'sync_halted': state.isHalted,
|
|
'sync_error': state.lastError,
|
|
'last_upload_at': state.lastSuccessAt?.toIso8601String(),
|
|
|
|
// Today's trading. A till that is connected but has rung nothing in
|
|
// three hours is usually a jammed printer or an absent cashier, and
|
|
// neither shows up on an online/offline board.
|
|
'today_bills': today.billCount,
|
|
'today_amount': today.grossSales,
|
|
'last_bill_at': today.lastBillAt?.toIso8601String(),
|
|
|
|
'reported_at': _now().toIso8601String(),
|
|
};
|
|
|
|
final device = await _collectDeviceState();
|
|
payload.addAll(device);
|
|
|
|
await _transport.publishHealth(jsonEncode(payload));
|
|
} on Object {
|
|
// Deliberately silent — see above.
|
|
}
|
|
}
|
|
|
|
/// When the oldest unsent bill was rung.
|
|
///
|
|
/// More useful than the count on its own: fifty bills queued in the last ten
|
|
/// minutes is a broker hiccup, while three queued since Tuesday is a till
|
|
/// nobody has looked at.
|
|
Future<DateTime?> _oldestPendingAt() async {
|
|
try {
|
|
final rows = await _repository.orderSyncRows(limit: 500);
|
|
DateTime? oldest;
|
|
for (final row in rows) {
|
|
if (row.isSynced) continue;
|
|
if (oldest == null || row.createdAt.isBefore(oldest)) {
|
|
oldest = row.createdAt;
|
|
}
|
|
}
|
|
return oldest;
|
|
} on Object {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Hardware readings, plus whatever this build can work out for itself.
|
|
Future<Map<String, Object?>> _collectDeviceState() async {
|
|
final out = <String, Object?>{};
|
|
|
|
if (deviceState != null) {
|
|
try {
|
|
out.addAll(await deviceState!());
|
|
} on Object {
|
|
// A missing battery reading must not cost the rest of the heartbeat.
|
|
}
|
|
}
|
|
|
|
final printer = printerEndpoint?.call();
|
|
if (printer != null && printer.host.isNotEmpty) {
|
|
out['printer_reachable'] = await _canReach(printer.host, printer.port);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/// Opens and immediately closes a socket to the till's printer.
|
|
///
|
|
/// Cheap enough to run every 30 seconds, and it answers the question a shop
|
|
/// actually phones about — a printer that is switched off looks identical to
|
|
/// a working one until someone tries to print a bill.
|
|
Future<bool> _canReach(String host, int port) async {
|
|
try {
|
|
final socket = await Socket.connect(
|
|
host,
|
|
port,
|
|
timeout: const Duration(seconds: 2),
|
|
);
|
|
socket.destroy();
|
|
return true;
|
|
} on Object {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
_stopped = true;
|
|
_timer?.cancel();
|
|
}
|
|
}
|