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> Function( String orderId); /// 按服务端订单号读取服务端打印凭证记录。 typedef PrintAwaitRecordsReader = Future> Function( String orderId); /// 读取打印机物理在线状态。 typedef PrintAwaitAvailableReader = bool Function(); /// 主动触发服务端待办打印任务的 HTTP 补拉。 typedef PrintAwaitPendingPuller = Future Function(); /// 支付或核销成功后的凭证打印等待状态机。 /// /// 打印任务是服务端异步生成的:订单确认 → 服务端生成凭证图 → MQTT 派发 → /// 本地 `print_history` 队列 → USB 出纸。本类只做观测与展示,不修改 /// [SyncPrintTool] 的入队、重试与补报语义;`dispose` 后打印照常后台进行。 /// /// MQTT 断连是打印延迟的主要来源(断连窗口内 PRINT 消息全部积压在 broker, /// 重连后才倾泻)。因此启动与寻单收尾都会主动触发 /// [SyncPrintTool.pullPendingTasks] 的 HTTP 补拉通道,让任务在 MQTT /// 不可用时也能尽快落到本地队列开始出纸。 class PrintAwaitTracker extends ChangeNotifier { /// 创建一个等待指定订单凭证打印的状态机。 PrintAwaitTracker({ required List 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 orderIds; final PrintAwaitTaskReader _taskReader; final PrintAwaitRecordsReader _recordsReader; final PrintAwaitAvailableReader _availableReader; final PrintAwaitPendingPuller _pendingPuller; /// 可注入时钟;测试用 fake_async 时必须同步虚拟时间,否则计时永不推进。 final DateTime Function() _clock; static const _tag = '打印等待'; static Future> _defaultTaskReader( String orderId, ) async { final dao = Get.find().dataBase.getPrintHistoryDao; return dao.findAllByOrder(orderId); } /// 判断当前终端是否绑定了逻辑打印机;绑定信息来自服务端 bootstrap 配置。 /// /// 读取失败、超时或测试环境未注册 AppControl 时一律视为未绑定: /// 打印等待只是附加提醒,不能因设置读取挂起阻塞核销/收银的界面收尾。 static Future isPrinterBound() async { if (!Get.isRegistered()) 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 _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 = []; 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); } }