Files
biz_xfj_cloud/lib/common/utils/sync/print_await_tracker.dart
T
2026-09-22 13:29:10 +08:00

315 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
/// 凭证打印等待的稳定阶段,核销成功页与收银角标共用同一套语义。
enum PrintAwaitPhase {
/// 服务端尚未确认本单存在打印任务,或发票图仍在生成。
preparing,
/// 本地打印队列已出现本单任务,USB 正在或即将出纸。
printing,
/// 全部匹配任务均已被 USB 完整接收,凭证应当正在出纸。
done,
/// 寻单与补拉确认本单不存在打印任务,无需打印。
noPrintNeeded,
/// 达到等待上限仍在打印,任务保留在本地队列继续重试。
timeout,
/// 服务端或本地队列明确判定打印失败,需要人工在记录页补打。
failed,
/// 终端绑定了打印机但硬件探测不可用,连接后队列会自动补打。
offline;
/// 该阶段是否不会再变化;宿主界面据此安排自动收尾。
bool get isTerminal =>
this == done ||
this == noPrintNeeded ||
this == timeout ||
this == failed ||
this == offline;
}
/// 按服务端订单号读取本地打印任务快照。
typedef PrintAwaitTaskReader = Future<List<PrintHistoryEntity>> Function(
String orderId);
/// 按服务端订单号读取服务端打印凭证记录。
typedef PrintAwaitRecordsReader = Future<List<ServicePrintRecord>> Function(
String orderId);
/// 读取打印机物理在线状态。
typedef PrintAwaitAvailableReader = bool Function();
/// 主动触发服务端待办打印任务的 HTTP 补拉。
typedef PrintAwaitPendingPuller = Future<void> Function();
/// 支付或核销成功后的凭证打印等待状态机。
///
/// 打印任务是服务端异步生成的:订单确认 → 服务端生成凭证图 → MQTT 派发 →
/// 本地 `print_history` 队列 → USB 出纸。本类只做观测与展示,不修改
/// [SyncPrintTool] 的入队、重试与补报语义;`dispose` 后打印照常后台进行。
///
/// MQTT 断连是打印延迟的主要来源(断连窗口内 PRINT 消息全部积压在 broker,
/// 重连后才倾泻)。因此启动与寻单收尾都会主动触发
/// [SyncPrintTool.pullPendingTasks] 的 HTTP 补拉通道,让任务在 MQTT
/// 不可用时也能尽快落到本地队列开始出纸。
class PrintAwaitTracker extends ChangeNotifier {
/// 创建一个等待指定订单凭证打印的状态机。
PrintAwaitTracker({
required List<String> orderIds,
required this.printType,
this.locateWindow = const Duration(seconds: 6),
this.locateGraceWindow = const Duration(seconds: 4),
this.totalLimit = const Duration(seconds: 15),
this.pollInterval = const Duration(milliseconds: 500),
this.recordsInterval = const Duration(seconds: 1),
DateTime Function()? clock,
PrintAwaitTaskReader? taskReader,
PrintAwaitRecordsReader? recordsReader,
PrintAwaitAvailableReader? availableReader,
PrintAwaitPendingPuller? pendingPuller,
}) : orderIds = List.unmodifiable(orderIds),
_clock = clock ?? DateTime.now,
_taskReader = taskReader ?? _defaultTaskReader,
_recordsReader = recordsReader ??
((orderId) => HardApi.getPrinterRecords(orderId: orderId)),
_availableReader = availableReader ??
(() => AppDeviceStatusTool.status.value.printerAvailable),
_pendingPuller = pendingPuller ?? SyncPrintTool.pullPendingTasks;
/// 打印凭证类型:'1' 收银发票,'2' 核销兑换券。
final String printType;
/// 寻单窗口:窗口内服务端与本地都没有任务时进入最终确认。
///
/// 服务端生成凭证图通常需要 2~3 秒,窗口必须明显大于该耗时。
final Duration locateWindow;
/// 寻单到期后的补拉宽限:二次补拉的结果落库时间,仍无任务才判无需打印。
final Duration locateGraceWindow;
/// 等待上限;超时后任务仍在本地队列按原节奏重试,界面不再等待。
final Duration totalLimit;
/// 本地任务轮询间隔,也是状态机的驱动节拍。
final Duration pollInterval;
/// 服务端凭证记录的轮询间隔,仅寻单阶段使用。
final Duration recordsInterval;
/// 需要等待打印凭证的服务端订单号;核销多单时包含全部勾选订单。
final List<String> orderIds;
final PrintAwaitTaskReader _taskReader;
final PrintAwaitRecordsReader _recordsReader;
final PrintAwaitAvailableReader _availableReader;
final PrintAwaitPendingPuller _pendingPuller;
/// 可注入时钟;测试用 fake_async 时必须同步虚拟时间,否则计时永不推进。
final DateTime Function() _clock;
static const _tag = '打印等待';
static Future<List<PrintHistoryEntity>> _defaultTaskReader(
String orderId,
) async {
final dao = Get.find<AppControl>().dataBase.getPrintHistoryDao;
return dao.findAllByOrder(orderId);
}
/// 判断当前终端是否绑定了逻辑打印机;绑定信息来自服务端 bootstrap 配置。
///
/// 读取失败、超时或测试环境未注册 AppControl 时一律视为未绑定:
/// 打印等待只是附加提醒,不能因设置读取挂起阻塞核销/收银的界面收尾。
static Future<bool> isPrinterBound() async {
if (!Get.isRegistered<AppControl>()) return false;
try {
final setting = await AppSettingTool.getSysSetting()
.timeout(const Duration(seconds: 2));
return setting.printerHardId != null;
} catch (error, stack) {
logger.w('|$_tag|读取打印机绑定失败error:|$error|stack:$stack|');
return false;
}
}
PrintAwaitPhase _phase = PrintAwaitPhase.preparing;
Timer? _timer;
DateTime? _startedAt;
DateTime _lastRecordsAt = DateTime.fromMillisecondsSinceEpoch(0);
bool _ticking = false;
bool _disposed = false;
bool _recordsLocated = false;
DateTime? _finalPullAt;
/// 当前等待阶段;进入终态后不再变化。
PrintAwaitPhase get phase => _phase;
/// 是否已经启动;重复调用 [start] 不会重新计时。
bool get isStarted => _startedAt != null;
/// 是否已确认本单存在打印任务(本地队列或服务端记录)。
///
/// 收银角标据此在 preparing 阶段就浮现:确认有票后无需继续静默,
/// 让收银员尽早看到“正在准备发票”而不是等到打印中才出现。
bool get taskLocated => _recordsLocated;
/// 启动等待;打印机物理不可用时直接进入 offline 终态,不占用顾客时间。
void start() {
if (_disposed || isStarted) return;
_startedAt = _clock();
if (!_availableReader()) {
_finish(PrintAwaitPhase.offline);
return;
}
logger.i('|$_tag|开始等待|orders:|$orderIds|type:|$printType|');
// 不等 MQTT:立即走 HTTP 补拉,断连窗口内的待办任务可以秒级落库开打。
_pullPending('启动补拉');
_timer = Timer.periodic(pollInterval, (_) => _tick());
_tick();
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_timer?.cancel();
_timer = null;
super.dispose();
}
void _pullPending(String reason) {
unawaited(_pendingPuller().catchError((Object error, StackTrace stack) {
logger.w('|$_tag|$reason|失败error:|$error|stack:$stack|');
}));
}
Future<void> _tick() async {
if (_disposed || _ticking || _phase.isTerminal) return;
_ticking = true;
try {
final now = _clock();
final startedAt = _startedAt!;
final elapsed = now.difference(startedAt);
// 本地队列是出纸真相:MQTT 任务落库后状态只会 NONE -> SUCCESS/SYNC/ERROR。
final tasks = <PrintHistoryEntity>[];
for (final orderId in orderIds) {
try {
tasks.addAll(await _taskReader(orderId));
} catch (error, stack) {
logger.w(
'|$_tag|读取本地打印任务失败orderId:|$orderId|error:|$error|stack:$stack|');
}
}
final matchedTasks =
tasks.where((task) => task.printType == printType).toList();
var recordPrinted = false;
var recordFailed = false;
var recordsLocatedNow = false;
// 服务端记录只在寻单阶段轮询;本地任务出现后本地队列即唯一真相。
if (matchedTasks.isEmpty &&
elapsed < locateWindow + locateGraceWindow &&
now.difference(_lastRecordsAt) >= recordsInterval) {
_lastRecordsAt = now;
for (final orderId in orderIds) {
try {
final records = await _recordsReader(orderId);
final matched = records
.where((record) => record.printType == printType)
.toList();
recordsLocatedNow = recordsLocatedNow || matched.isNotEmpty;
for (final record in matched) {
recordPrinted = recordPrinted || record.status == '2';
recordFailed = recordFailed || record.status == '3';
}
} catch (error, stack) {
// 服务端查询失败只影响寻单体验,保留 preparing 继续下一轮。
logger.w(
'|$_tag|查询打印凭证记录失败orderId:|$orderId|error:|$error|stack:$stack|');
}
}
}
// 首次确认本单有票时立即通知宿主:收银角标在 preparing 阶段就该浮现。
if (recordsLocatedNow && !_recordsLocated) {
_recordsLocated = true;
logger.i('|$_tag|已确认本单存在打印任务|orders:|$orderIds|');
notifyListeners();
}
if (elapsed >= totalLimit) {
_finish(PrintAwaitPhase.timeout);
return;
}
if (matchedTasks.isNotEmpty) {
final allAccepted = matchedTasks.every(
(task) =>
task.status == PrintStatus.SUCCESS ||
task.status == PrintStatus.SYNC,
);
final anyFailed =
matchedTasks.any((task) => task.status == PrintStatus.ERROR);
if (allAccepted) {
_finish(PrintAwaitPhase.done);
} else if (anyFailed) {
_finish(PrintAwaitPhase.failed);
} else if (_phase != PrintAwaitPhase.printing) {
_update(PrintAwaitPhase.printing);
}
return;
}
if (recordPrinted) {
_finish(PrintAwaitPhase.done);
return;
}
if (recordFailed) {
_finish(PrintAwaitPhase.failed);
return;
}
// 服务端 status '1' 只代表"已发布到 broker",终端是否收到取决于 MQTT;
// 本地队列没有任务就绝不能显示"打印中",否则进度与真实出纸不符。
// 此时保持 preparing 并依赖启动/寻单补拉把任务尽快拉回本地。
if (!_recordsLocated && elapsed >= locateWindow) {
final finalPullAt = _finalPullAt;
if (finalPullAt == null) {
// 寻单到期仍无痕迹:最后一次 HTTP 补拉兜底,覆盖 MQTT 断连丢消息。
_finalPullAt = now;
_pullPending('寻单到期补拉');
return;
}
if (now.difference(finalPullAt) >= locateGraceWindow) {
_finish(PrintAwaitPhase.noPrintNeeded);
return;
}
}
if (_phase != PrintAwaitPhase.preparing) {
_update(PrintAwaitPhase.preparing);
}
} finally {
_ticking = false;
}
}
void _update(PrintAwaitPhase next) {
if (_disposed || next == _phase) return;
logger.i('|$_tag|阶段变化|orders:|$orderIds|from:|${_phase.name}|to:|${next.name}|');
_phase = next;
notifyListeners();
}
void _finish(PrintAwaitPhase terminal) {
_timer?.cancel();
_timer = null;
_update(terminal);
}
}