Files
nearle_pos/lib/data/sync/health_reporter.dart
Suriya 908058038a Send timestamps with the offset they were read in
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>
2026-08-05 20:17:57 +05:30

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