315 lines
12 KiB
Dart
315 lines
12 KiB
Dart
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);
|
||
}
|
||
}
|