Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside receivedat 2026-08-05T07:19:28Z — the sale appearing to have been rung five and a half hours after the back office received it. Exactly the IST offset, on every bill. Nothing was lying. DateTime.toIso8601String() on a local time emits no zone marker at all, and Go's time.Parse fills that silence with UTC, so a Coimbatore wall clock was recorded as though it had been read in London. The daily figures survived by luck: businessdate is derived from the wall clock either way, and the wall clock was always the till's own, so a day's takings landed on the right day even while the instant was wrong. Anything comparing billedat against real time did not. Formatters.isoWithOffset attaches the offset, which fixes both readings at once — the instant parses correctly and the local date still formats correctly. The minutes come from the real offset rather than being assumed zero, because India is +05:30 and a whole-hour implementation would be wrong in a way that looks almost right. Applied to all six timestamps the till sends. date_of_birth is left alone: a birthday is a date, not an instant, and giving it a zone would be meaningless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
7.6 KiB
Dart
227 lines
7.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import '../../core/config/sync_config.dart';
|
|
import '../../core/utils/formatters.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': _iso(await _oldestPendingAt()),
|
|
'sync_halted': state.isHalted,
|
|
'sync_error': state.lastError,
|
|
'last_upload_at': _iso(state.lastSuccessAt),
|
|
|
|
// 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': _iso(today.lastBillAt),
|
|
|
|
'reported_at': Formatters.isoWithOffset(_now()),
|
|
};
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// [Formatters.isoWithOffset], tolerating a null.
|
|
///
|
|
/// Several of these are genuinely absent — a till that has never uploaded has
|
|
/// no last upload, and one with an empty queue has no oldest pending bill.
|
|
/// Omitting the key is the honest answer; sending an epoch would put 1970 on a
|
|
/// dashboard and read as a real reading.
|
|
String? _iso(DateTime? time) =>
|
|
time == null ? null : Formatters.isoWithOffset(time);
|