feat:打印优化

This commit is contained in:
dzc committed 2026-09-22 13:29:10 +08:00
1 parent 22c4d4b9b9
commit 383ea2ad4b
32 files changed
+1879 -154

No files matched your search

@@ -0,0 +1,314 @@
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);
}
}
@@ -131,6 +131,25 @@ abstract class SyncPrintTool {
existing.status == PrintStatus.ERROR &&
incomingVersion > existing.retryVersion;
/// 服务端打印方式与终端固定图片打印不一致时,上报 IMAGE 纠偏配置。
///
/// 纠偏失败只记日志:打印链路本身不受影响,下一次补拉会再次尝试。
static Future<void> _healPrintModeIfNeeded(SysSetting setting) async {
if (!AppSettingTool.needsPrintModeHeal(setting)) return;
final hardId = setting.serviceId;
if (hardId == null || hardId.isEmpty) return;
try {
await HardApi.reportPrinterInfo(
hardId: hardId,
request:
const PrinterInfoReportRequest(printMode: PrinterOutputMode.image),
);
logger.i('|$_tag|服务端打印方式已纠偏为图片打印|printer:|${setting.printerHardId}|');
} catch (error, stack) {
logger.w('|$_tag|图片打印方式纠偏上报失败error:|$error|stack:$stack|');
}
}
/// 在登录、MQTT 重连、应用恢复和周期检查时补拉当前业务终端代理的待办。
static Future<void> pullPendingTasks() async {
if (_isPulling || !AppNetworkTool.isAutoNetwork()) {
@@ -144,6 +163,8 @@ abstract class SyncPrintTool {
await HardApi.getPrinterBinding(),
);
if (setting.printerHardId == null) return;
// 终端固定图片打印;服务端若被误配为指令打印,这里上报纠偏让其跳过指令流生成。
unawaited(_healPrintModeIfNeeded(setting));
final tasks = await HardApi.getPrinterTasks();
for (final task in tasks) {
if (task.historyId == null ||