diff --git a/.zcode/plans/plan-sess_d3e6bcbb-1de9-4201-bbcc-fe3d28bdecc3.md b/.zcode/plans/plan-sess_d3e6bcbb-1de9-4201-bbcc-fe3d28bdecc3.md new file mode 100644 index 0000000..7d2534f --- /dev/null +++ b/.zcode/plans/plan-sess_d3e6bcbb-1de9-4201-bbcc-fe3d28bdecc3.md @@ -0,0 +1,39 @@ +# 打印方式声明(终端固定图片打印)+ 服务端跳过 printCmd 生成 + +## 背景结论(已探明) + +- 终端固定图片打印,`printCmd` 只落库不消费;服务端每次生成 ESC/POS Base64 指令流是纯浪费:`hard_invoice.print_cmd` mediumtext 写库 + MQTT 消息体膨胀(服务端每 10s 重发未确认消息,膨胀被放大,日志实测单条消息 2-4KB Base64)+ `/printer/tasks`、`/printer/records` 响应膨胀。 +- 服务端目前**没有**"打印方式"概念;`imageMode`(GS_V0/ESC_STAR)是图片栅格指令格式,不是打印方式开关。 +- 终端 printCmd 链路已全部可空安全(`_addTask` 归一 null、出纸只用 pic),服务端不生成对终端**零影响**;基线 biz_supermarket_cloud 同样只用 pic,无需改动。 + +## 方案(已拍板:print_mode 默认 IMAGE,上线立即停止生成 printCmd) + +### A. 后端 biz-api-cloud-zax + +1. **迁移脚本** `db/20260922_add_hard_printer_config_print_mode.sql`(跟随 20260920 脚本的 plain ALTER 风格): + `ALTER TABLE hard_printer_config ADD COLUMN print_mode varchar(16) NOT NULL DEFAULT 'IMAGE' COMMENT '终端打印方式: IMAGE 图片打印(默认) / CMD 指令打印';` +2. `HardPrinterConfigEntity`(+对应 mapper,按现有注解/XML 方式)加 `printMode`;`defaultConfig()` 补 IMAGE;bootstrap 返回自动带出。 +3. `HardPrinterInfoReportDto` 加可选 `printMode`(仅允许 IMAGE|CMD);`printerModel/usbVendorId/usbProductId` 放宽为可选(null=保持不变),要求至少一个字段非空——支持"仅上报模式"的部分更新,旧终端全量上报完全兼容。 +4. `HardPrinterServiceImpl.reportDeviceInfo` 改为按非空字段合并(现状只覆盖型号/VID/PID)。 +5. 管理端 `saveConfig` 校验并允许保存 printMode(IMAGE|CMD)。 +6. **生成门控**(核心):`HardInvoiceServiceImpl.generateTaskImage` 按 task 的 printerHardId 读打印机配置,`printMode == IMAGE`(缺失按默认 IMAGE)时只走 `ThermalReceiptImage` 图片通道、`printCmd` 落 null;CMD 才走现有 `CompositeRenderer` 双通道。`renderInvoice`/`renderVoucher` 增加渲染通道参数。 +7. MQTT 消息、`/printer/tasks`、`/printer/records` 的 printCmd 自动为 null,载荷变小的收益顺带缓解 10s 重发洪水。 +8. 存量 `hard_invoice.print_cmd` 数据不动(可选 DBA 后续清理,不在本次)。 +9. 测试:`HardInvoiceServiceImplTest`(IMAGE→printCmd 为 null、CMD→生成)、`HardPrinterServiceImplTest`(部分更新/取值校验);`mvn -pl pjcloud-hardware/pjcloud-hardware-biz -am test` 验证。 + +### B. 终端 biz_xfj_cloud + +1. **设置→打印机**"后台打印配置"组新增"打印方式"行(复用 `SettingInfoRow` 风格 + 自定义选项芯片):`图片打印`(选中态)/`命令打印`(置灰 + "暂不支持"角标),**不可切换**;数据源 `SysSetting.printerOutputMode`(复用现有字段,`updatePrinterBinding` 从 bootstrap 的 `printerConfig.printMode` 解析,默认 IMAGE)。 +2. `PrinterInfoReportRequest` 加 `printMode`(默认 'IMAGE'),设置页"上报当前设备"时携带。 +3. **能力自动上报(自愈)**:`pullPendingTasks` 中 `updatePrinterBinding` 之后,若服务端 printMode ≠ IMAGE,`unawaited` 上报仅 `printMode:'IMAGE'`(部分更新)。当前构建固定图片打印是硬事实,可纠偏管理端误配;仅在不一致时触发,频率受补拉时机天然限流(登录/MQTT 重连/网络恢复/每 10 分钟/打印等待启动)。 +4. 更新 `SysSetting.printerOutputMode` 注释(从"兼容保留"改为"打印方式展示与服务端能力上报源,固定 IMAGE")。 + +### C. 跨项目合规 + +- 基线 biz_supermarket_cloud 同为图片打印且不消费 printCmd,服务端置空无影响,不改基线。 +- 表结构变更仅新增一列(用户已授权),不改任何现有列。 + +## 验证 + +- 后端:`mvn -pl pjcloud-hardware/pjcloud-hardware-biz -am test`(涉 mqtt-api 不改则不构建)。 +- 终端:`fvm flutter analyze` + 相关单测;构建装机后:设置页出现"打印方式=图片打印"行;支付一单,logcat 中 MQTT PRINT 消息体明显变短(无 printCmd 字段),发票照常打印。 \ No newline at end of file diff --git a/lib/common/models/database/database_models.dart b/lib/common/models/database/database_models.dart index 15fe649..7d06f91 100644 --- a/lib/common/models/database/database_models.dart +++ b/lib/common/models/database/database_models.dart @@ -46,9 +46,9 @@ class SysSetting extends Equatable { /// 当前绑定打印机的 ESC/POS 图片指令模式,默认使用 GS_V0。 String printerImageMode; - /// 打印输出方式历史配置键:IMAGE=图片栅格打印,COMMAND=指令流直写。 - /// 指令打印在真机版式与走纸兼容性不达标后已停用,终端固定图片打印且不提供切换; - /// 字段仅为兼容旧终端已持久化的 sysJson 保留,业务代码不再读取。 + /// 终端打印方式:IMAGE=图片打印(固定,凭证 PNG 渲染位图出纸),CMD=指令打印。 + /// 指令打印在真机版式与走纸兼容性不达标后已停用;取值来自服务端 bootstrap 的 + /// printerConfig.printMode,设置页展示与自愈上报读取该字段,出纸逻辑不使用。 @JsonKey(defaultValue: 'IMAGE') String printerOutputMode; diff --git a/lib/common/models/manage/printer_device_models.dart b/lib/common/models/manage/printer_device_models.dart index 9d5dda5..791ac9c 100644 --- a/lib/common/models/manage/printer_device_models.dart +++ b/lib/common/models/manage/printer_device_models.dart @@ -46,31 +46,49 @@ class PrinterUsbDeviceInfo { '0x${productId.toRadixString(16).padLeft(4, '0').toUpperCase()}'; /// 生成只包含服务端允许字段的打印机信息上报请求。 + /// + /// 终端固定图片打印,每次上报都携带打印方式声明,让服务端跳过指令流生成。 PrinterInfoReportRequest toReportRequest() => PrinterInfoReportRequest( printerModel: displayName, usbVendorId: vendorId, usbProductId: productId, + printMode: PrinterOutputMode.image, ); } +/// 终端打印方式常量:当前终端固定图片打印,指令打印已停用。 +abstract final class PrinterOutputMode { + /// 图片打印:把服务端下发的凭证 PNG 渲染成 ESC/POS 位图出纸。 + static const String image = 'IMAGE'; + + /// 指令打印:直接把服务端拼装的指令流写入 USB;真机兼容性不达标,已停用。 + static const String command = 'CMD'; +} + /// 终端向服务端上报实际连接打印机型号与 USB 标识的请求。 +/// +/// 设备字段与打印方式均可缺省,服务端按非空字段部分更新。 @JsonSerializable(createFactory: false) class PrinterInfoReportRequest { /// 创建一份不包含设备路径、序列号等敏感信息的上报请求。 const PrinterInfoReportRequest({ - required this.printerModel, - required this.usbVendorId, - required this.usbProductId, + this.printerModel, + this.usbVendorId, + this.usbProductId, + this.printMode, }); /// 由 USB 厂商和产品描述组合的打印机型号,服务端最长保存 128 个字符。 - final String printerModel; + final String? printerModel; /// USB Vendor ID 十进制值。 - final int usbVendorId; + final int? usbVendorId; /// USB Product ID 十进制值。 - final int usbProductId; + final int? usbProductId; + + /// 终端打印方式声明;图片打印终端上报 IMAGE 让服务端跳过指令流生成。 + final String? printMode; /// 转换为终端打印机信息上报接口使用的 JSON 对象。 Map toJson() => _$PrinterInfoReportRequestToJson(this); diff --git a/lib/common/models/manage/printer_device_models.g.dart b/lib/common/models/manage/printer_device_models.g.dart index da21c02..7d6ea30 100644 --- a/lib/common/models/manage/printer_device_models.g.dart +++ b/lib/common/models/manage/printer_device_models.g.dart @@ -12,4 +12,5 @@ Map _$PrinterInfoReportRequestToJson( 'printerModel': instance.printerModel, 'usbVendorId': instance.usbVendorId, 'usbProductId': instance.usbProductId, + 'printMode': instance.printMode, }; diff --git a/lib/common/models/plugs/sub_screen_enum_models.dart b/lib/common/models/plugs/sub_screen_enum_models.dart index 7d9d81f..a0d3b8f 100644 --- a/lib/common/models/plugs/sub_screen_enum_models.dart +++ b/lib/common/models/plugs/sub_screen_enum_models.dart @@ -52,6 +52,9 @@ enum SubScreenAction implements BaseEnum { /// 通知副屏当前订单已经完成支付,适用于饭卡、钱包、人脸和付款码等非聚合码支付。 orderPaySuccess("orderPaySuccess", "订单支付成功"), + + /// 支付成功后同步发票打印进度到副屏成功页;打印期间副屏暂停成功页倒计时。 + orderPrintStatus("orderPrintStatus", "订单发票打印状态"), ; @override diff --git a/lib/common/services/database/dao/print_history_dao.dart b/lib/common/services/database/dao/print_history_dao.dart index d1952c4..187d869 100644 --- a/lib/common/services/database/dao/print_history_dao.dart +++ b/lib/common/services/database/dao/print_history_dao.dart @@ -37,6 +37,14 @@ abstract class PrintHistoryDao { 'SELECT * FROM print_history WHERE servicePrintId = :servicePrintId LIMIT 1') Future findByServicePrintId(String servicePrintId); + /// 按服务端订单号或 Android 订单号取全部本地任务,供打印等待页轮询进度。 + /// + /// 入队时 androidOrderCode 优先进 orderId 列、服务端单号固定在 serviceOrderId 列, + /// 因此两个列都必须匹配调用方传入的服务端单号。 + @Query( + 'SELECT * FROM print_history WHERE orderId = :orderId OR serviceOrderId = :orderId ORDER BY id DESC') + Future> findAllByOrder(String orderId); + /// 查询等待补报成功或失败结果的任务。 @Query( 'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC') diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index e28beaa..4f30f90 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -1690,6 +1690,14 @@ class _$PrintHistoryDao extends PrintHistoryDao { arguments: [servicePrintId]); } + @override + Future> findAllByOrder(String orderId) async { + return _queryAdapter.queryList( + 'SELECT * FROM print_history WHERE orderId = ?1 OR serviceOrderId = ?1 ORDER BY id DESC', + mapper: (Map row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, printCmd: row['printCmd'] as String?, printType: row['printType'] as String, printNo: row['printNo'] as int, retryVersion: row['retryVersion'] as int, attemptCount: row['attemptCount'] as int, lastError: row['lastError'] as String?, resultPending: _boolConverter.decode(row['resultPending'] as int), status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String), createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [orderId]); + } + @override Future> getPendingResults() async { return _queryAdapter.queryList( diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 5dcfae6..62ce100 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -917,6 +917,7 @@ abstract class AppSettingTool { setting.printerPaperWidth = 80; setting.printerAutoCut = true; setting.printerImageMode = 'GS_V0'; + setting.printerOutputMode = PrinterOutputMode.image; await _setSetting(sysJson: setting); return setting; } @@ -943,10 +944,23 @@ abstract class AppSettingTool { int.tryParse('${config['paperWidth'] ?? ''}') ?? 80; setting.printerAutoCut = '${config['autoCut'] ?? '1'}' != '0'; setting.printerImageMode = config['imageMode']?.toString() ?? 'GS_V0'; + // 服务端打印方式:图片打印是当前终端唯一支持的方式,未知值按图片打印兜底。 + final printMode = config['printMode']?.toString().trim().toUpperCase(); + setting.printerOutputMode = printMode == PrinterOutputMode.command + ? PrinterOutputMode.command + : PrinterOutputMode.image; await _setSetting(sysJson: setting); return setting; } + /// 服务端声明的打印方式与终端固定能力不一致时,上报图片打印纠偏。 + /// + /// 当前构建固定图片打印;若管理端误把打印机配置成指令打印,服务端会持续 + /// 生成终端永不消费的指令流。返回是否需要纠偏。 + static bool needsPrintModeHeal(SysSetting setting) => + setting.printerHardId != null && + setting.printerOutputMode != PrinterOutputMode.image; + /// 绑定设备信息 static Future bindSys(int serviceId) async { var androidId = await AppSysTool.getAndroidId(); diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 09ca6cc..b97430d 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -51,6 +51,7 @@ export './sync/sync_idle_tool.dart'; export './sync/scheduled_task_storage_tool.dart'; export './sync/file_upload_record_tool.dart'; export './sync/order_evidence_upload_tool.dart'; +export './sync/print_await_tracker.dart'; export './sync/terminal_log_upload_tool.dart'; export './sync/terminal_heartbeat_tool.dart'; export './sync/terminal_auto_update_tool.dart'; diff --git a/lib/common/utils/sync/print_await_tracker.dart b/lib/common/utils/sync/print_await_tracker.dart new file mode 100644 index 0000000..82d8f76 --- /dev/null +++ b/lib/common/utils/sync/print_await_tracker.dart @@ -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> 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); + } +} diff --git a/lib/common/utils/sync/sync_print_tool.dart b/lib/common/utils/sync/sync_print_tool.dart index 182470a..c52139b 100644 --- a/lib/common/utils/sync/sync_print_tool.dart +++ b/lib/common/utils/sync/sync_print_tool.dart @@ -131,6 +131,25 @@ abstract class SyncPrintTool { existing.status == PrintStatus.ERROR && incomingVersion > existing.retryVersion; + /// 服务端打印方式与终端固定图片打印不一致时,上报 IMAGE 纠偏配置。 + /// + /// 纠偏失败只记日志:打印链路本身不受影响,下一次补拉会再次尝试。 + static Future _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 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 || diff --git a/lib/common/widgets/app/cashier_print_await_badge.dart b/lib/common/widgets/app/cashier_print_await_badge.dart new file mode 100644 index 0000000..da1745c --- /dev/null +++ b/lib/common/widgets/app/cashier_print_await_badge.dart @@ -0,0 +1,186 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 收银角标的展示快照:一笔已支付订单的当前打印等待阶段。 +@immutable +class PrintAwaitBadgePresentation { + /// 创建一条角标展示数据;阶段变化时由 [copyWith] 生成新快照驱动界面刷新。 + const PrintAwaitBadgePresentation({ + required this.orderId, + required this.phase, + }); + + /// 已支付订单的服务端单号,仅用于日志与调试定位。 + final String orderId; + + /// 当前打印等待阶段。 + final PrintAwaitPhase phase; + + PrintAwaitBadgePresentation copyWith({PrintAwaitPhase? phase}) => + PrintAwaitBadgePresentation( + orderId: orderId, + phase: phase ?? this.phase, + ); +} + +/// 收银页右上角的凭证打印状态角标。 +/// +/// 收银是连续作业,打印等待不能阻塞下一单;角标只做非阻塞提醒, +/// 静默寻单成功后才会出现,终态短暂停留后由宿主自动收起。 +class CashierPrintAwaitBadge extends StatelessWidget { + /// 创建打印状态角标;[presentation] 为空时不渲染任何内容。 + const CashierPrintAwaitBadge({ + super.key, + required this.presentation, + required this.credentialLabel, + this.onDismiss, + }); + + /// 当前角标数据;null 表示无打印等待需要展示。 + final PrintAwaitBadgePresentation? presentation; + + /// 凭证名称,收银固定为"发票"。 + final String credentialLabel; + + /// 点击关闭或终态点击卡片时的收起回调。 + final VoidCallback? onDismiss; + + @override + Widget build(BuildContext context) { + final data = presentation; + if (data == null) return const SizedBox.shrink(); + final theme = FTheme.of(context); + final phase = data.phase; + final accent = phase.accentColor; + return Material( + key: const ValueKey('cashier-print-await-badge'), + color: Colors.transparent, + child: InkWell( + onTap: phase.isTerminal ? onDismiss : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + child: [ + Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: phase.inProgress + ? SizedBox( + width: 20.w, + height: 20.w, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: accent, + ), + ).center() + : Icon( + phase == PrintAwaitPhase.done + ? Icons.print_rounded + : Icons.error_outline_rounded, + color: accent, + size: 22.w, + ).center(), + ), + SizedBox(width: 12.w), + [ + Text( + '$credentialLabel打印', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 3.h), + Text( + phase.statusLabel(credentialLabel), + key: const ValueKey('cashier-print-await-badge-status'), + style: theme.typography.sm.copyWith( + color: accent, + fontWeight: FontWeight.w700, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + SizedBox(width: 8.w), + InkWell( + onTap: onDismiss, + borderRadius: BorderRadius.circular(10.w), + child: FIcon( + FAssets.icons.x, + color: XfjDesignTokens.muted, + size: 16.sp, + ).center().tightSize(32.w), + ), + ] + .toRowSpace( + space: 0.w, crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 14.w, vertical: 12.h) + .decorated( + color: XfjDesignTokens.surface, + border: Border.all( + color: accent.withValues(alpha: 0.35), + width: 1.2, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + ), + ); + } +} + +/// 把打印角标悬浮在收银工作区右上角的容器;无角标数据时直接透传子树。 +/// +/// 角标状态由 [OrderMixin] 维护的响应式快照驱动,收银页面只需包一层本组件, +/// 不需要在食堂与超市两套页面复制 Stack 布局。 +class CashierPrintAwaitScope extends StatelessWidget { + /// 创建角标宿主;[badge] 为空值时工作区完全不受影响。 + const CashierPrintAwaitScope({ + super.key, + required this.badge, + required this.onDismiss, + required this.child, + }); + + /// 打印角标响应式快照。 + final Rxn badge; + + /// 角标收起回调。 + final VoidCallback onDismiss; + + /// 被悬浮的收银工作区。 + final Widget child; + + @override + Widget build(BuildContext context) { + return Obx(() { + final data = badge.value; + if (data == null) return child; + return Stack( + fit: StackFit.passthrough, + children: [ + child, + Positioned( + top: 14.h, + right: 14.w, + width: 300.w, + child: CashierPrintAwaitBadge( + presentation: data, + credentialLabel: '发票', + onDismiss: onDismiss, + ), + ), + ], + ); + }); + } +} diff --git a/lib/common/widgets/app/print_await_panel.dart b/lib/common/widgets/app/print_await_panel.dart new file mode 100644 index 0000000..ee0b0a6 --- /dev/null +++ b/lib/common/widgets/app/print_await_panel.dart @@ -0,0 +1,228 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 各打印等待阶段在界面上的主文案;[credential] 为"发票"或"兑换券"。 +extension PrintAwaitPhaseView on PrintAwaitPhase { + String statusLabel(String credential) => switch (this) { + PrintAwaitPhase.preparing => '正在准备$credential…', + PrintAwaitPhase.printing => '$credential打印中…', + PrintAwaitPhase.done => '$credential打印完成,请取走', + PrintAwaitPhase.noPrintNeeded => '本单无需打印$credential', + PrintAwaitPhase.timeout => '$credential仍在后台打印', + PrintAwaitPhase.failed => '$credential打印失败', + PrintAwaitPhase.offline => '打印机未连接', + }; + + /// 各打印等待阶段的补充说明;无可补充内容时返回 null,界面不再占位。 + String? hintLabel(String credential) => switch (this) { + PrintAwaitPhase.preparing => '正在生成并下发$credential,无需操作', + PrintAwaitPhase.printing => '打印完成自动关闭,也可直接点击完成', + PrintAwaitPhase.done => null, + PrintAwaitPhase.noPrintNeeded => null, + PrintAwaitPhase.timeout => '可稍后在记录页补打,凭证不会丢失', + PrintAwaitPhase.failed => '可稍后在记录页补打', + PrintAwaitPhase.offline => '连接打印机后将自动补打,凭证不会丢失', + }; + + /// 阶段主色;进行中使用主题蓝,完成用成功绿,其余为警示色。 + Color get accentColor => switch (this) { + PrintAwaitPhase.done => XfjDesignTokens.success, + PrintAwaitPhase.preparing || + PrintAwaitPhase.printing => + XfjDesignTokens.primary, + PrintAwaitPhase.noPrintNeeded => XfjDesignTokens.muted, + PrintAwaitPhase.timeout || + PrintAwaitPhase.failed || + PrintAwaitPhase.offline => + XfjDesignTokens.warning, + }; + + /// 打印是否仍在进行;进行中展示 spinner,终态展示结果图标。 + bool get inProgress => + this == PrintAwaitPhase.preparing || this == PrintAwaitPhase.printing; +} + +/// 支付或核销成功后的凭证打印等待面板,嵌入确认核销弹框的成功阶段。 +/// +/// 面板是纯展示组件:阶段由外部 [PrintAwaitTracker] 驱动并传入, +/// 自动收尾计时由宿主弹框负责,保证组件可独立做视觉预览测试。 +class PrintAwaitPanel extends StatelessWidget { + /// 创建打印等待面板;[phase] 变化时由宿主重建。 + const PrintAwaitPanel({ + super.key, + required this.phase, + required this.credentialLabel, + this.title = '操作成功', + this.subtitle, + this.finishLabel = '完成', + this.onFinish, + }); + + /// 当前打印等待阶段。 + final PrintAwaitPhase phase; + + /// 凭证名称:收银为"发票",核销为"兑换券"。 + final String credentialLabel; + + /// 成功主标题,例如"核销成功"。 + final String title; + + /// 成功副标题,例如"共 2 笔预约";为空时不占位。 + final String? subtitle; + + /// 结束按钮文案。 + final String finishLabel; + + /// 点击结束按钮;终态阶段按钮视觉加重,进行中允许随时跳过等待。 + final VoidCallback? onFinish; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final hint = phase.hintLabel(credentialLabel); + return [ + _heroBadge(theme), + SizedBox(height: 18.h), + Text( + title, + key: const ValueKey('print-await-panel-title'), + style: theme.typography.xl3.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + if (subtitle != null && subtitle!.trim().isNotEmpty) ...[ + SizedBox(height: 8.h), + Text( + subtitle!, + key: const ValueKey('print-await-panel-subtitle'), + style: theme.typography.base.copyWith(color: XfjDesignTokens.muted), + ), + ], + SizedBox(height: 26.h), + _statusCard(theme, hint), + SizedBox(height: 26.h), + FButton.raw( + key: const ValueKey('print-await-panel-finish'), + onPress: onFinish, + child: [ + FIcon( + FAssets.icons.circleCheck, + color: Colors.white, + size: 21.sp, + ), + Text( + finishLabel, + style: theme.typography.lg.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + ] + .toRowSpace( + space: 10.w, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .center(), + ).tight(width: 240.w, height: 64.h), + ] + .toColumn( + key: const ValueKey('print-await-panel'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .center(); + } + + /// 大号成功对勾,先给顾客确定的"业务已成功"信号,再谈打印进度。 + Widget _heroBadge(FThemeData theme) { + return Container( + width: 104.w, + height: 104.w, + decoration: BoxDecoration( + color: XfjDesignTokens.success.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_rounded, + key: const ValueKey('print-await-panel-hero'), + color: XfjDesignTokens.success, + size: 62.w, + ), + ); + } + + /// 打印状态卡片:进行中展示 spinner 与当前环节,终态展示结果与指引。 + Widget _statusCard(FThemeData theme, String? hint) { + final accent = phase.accentColor; + return [ + Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: phase.inProgress + ? SizedBox( + width: 22.w, + height: 22.w, + child: CircularProgressIndicator( + strokeWidth: 2.6, + color: accent, + ), + ).center() + : Icon( + phase == PrintAwaitPhase.done + ? Icons.print_rounded + : phase == PrintAwaitPhase.noPrintNeeded + ? Icons.check_circle_outline_rounded + : Icons.error_outline_rounded, + color: accent, + size: 24.w, + ).center(), + ), + SizedBox(width: 14.w), + [ + Text( + phase.statusLabel(credentialLabel), + key: const ValueKey('print-await-panel-status'), + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + height: 1.3, + ), + ), + if (hint != null) ...[ + SizedBox(height: 4.h), + Text( + hint, + key: const ValueKey('print-await-panel-hint'), + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + height: 1.3, + ), + ), + ], + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + ] + .toRow( + key: const ValueKey('print-await-panel-status-card'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + ) + .padding(horizontal: 22.w, vertical: 18.h) + .decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ); + } +} diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 3d7b83c..8e0d078 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -17,7 +17,9 @@ export './app/app_qr_code_dialog.dart'; export './app/reservation_schedule_card.dart'; export './app/app_radio_hour_type_dialog.dart'; export './app/app_wallet_selector.dart'; +export './app/cashier_print_await_badge.dart'; export './app/product_type_card.dart'; +export './app/print_await_panel.dart'; export './app/terminal_shell.dart'; export './app/terminal_device_status.dart'; export './app/terminal_toolbar.dart'; diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 690186f..638898d 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -84,6 +84,21 @@ mixin OrderMixin { Future? putOffSendSubScreenDelayed; + /// 最近一笔已在线支付订单的服务端单号;供结算弹框关闭后的发票打印角标追踪。 + /// + /// 离线单与创建失败单不会记录;聚合码单在服务端确认支付后记录。 + String? lastPaidServerOrderId; + + /// 收银页右上角的发票打印角标快照;null 表示当前没有需要展示的打印等待。 + final Rxn printAwaitBadge = + Rxn(); + + /// 角标背后的打印等待状态机;进入终态或开始新订单时释放。 + PrintAwaitTracker? _printAwaitTracker; + + /// 终态角标的自动收起计时;手动关闭或开始新订单时取消。 + Timer? _printAwaitHideTimer; + /// 副屏购物车快照的递增版本;异步查询套餐组成时用于丢弃已经过期的快照。 int _subScreenSnapshotRevision = 0; @@ -325,6 +340,8 @@ mixin OrderMixin { } await _refreshCanteenStockAfterPayment(); logger.i("|$_tag|完成聚合支付本地订单成功,orderId:|$sqlOrderId|"); + // 聚合支付至此才被服务端确认,发票打印任务从现在起才可能出现。 + lastPaidServerOrderId = order.serviceUniqueCode; return true; } catch (error, stack) { logger.e( @@ -560,6 +577,10 @@ mixin OrderMixin { } Toast.success(msg: "购买成功"); } + // 只记录在线且已确认支付的订单,离线单不产生服务端发票打印任务。 + if (isNetwork && !isPendingOrderCode && result != null) { + lastPaidServerOrderId = sqlOrderDetail.serviceUniqueCode; + } return result; } catch (e, stack) { if (isFacePayment && @@ -748,6 +769,70 @@ mixin OrderMixin { } } + /// 结算弹框关闭后启动发票打印角标;收银是连续作业,等待只做非阻塞提醒。 + /// + /// 未绑定打印机、离线单或服务端未建打印任务时角标不出现,行为与现状一致。 + Future startPrintAwaitAfterCheckout() async { + final orderId = lastPaidServerOrderId?.trim(); + lastPaidServerOrderId = null; + if (orderId == null || orderId.isEmpty) return; + // 角标静默寻单:只有确认本单出现打印任务后才浮现,避免逐单噪音。 + dismissPrintAwait(); + if (!await PrintAwaitTracker.isPrinterBound()) return; + final tracker = PrintAwaitTracker(orderIds: [orderId], printType: '1'); + _printAwaitTracker = tracker; + printAwaitBadge.value = null; + tracker.addListener(() => _onPrintAwaitChanged(orderId, tracker)); + tracker.start(); + } + + /// 收起打印角标并释放状态机;打印队列本身不受影响,仍按原节奏后台执行。 + void dismissPrintAwait() { + _printAwaitHideTimer?.cancel(); + _printAwaitHideTimer = null; + _printAwaitTracker?.dispose(); + _printAwaitTracker = null; + if (printAwaitBadge.value != null) { + printAwaitBadge.value = null; + } + } + + /// 同步打印阶段到角标与副屏;done 时语音提醒顾客取走发票。 + void _onPrintAwaitChanged(String orderId, PrintAwaitTracker tracker) { + if (_printAwaitTracker == null) return; + final phase = tracker.phase; + // 未确认本单有票时保持静默,避免商户未开票场景的逐单角标噪音; + // 服务端一旦确认存在打印任务(含 MQTT 断连期间),preparing 也立即浮现。 + if (phase != PrintAwaitPhase.preparing || tracker.taskLocated) { + printAwaitBadge.value = printAwaitBadge.value == null + ? PrintAwaitBadgePresentation(orderId: orderId, phase: phase) + : printAwaitBadge.value!.copyWith(phase: phase); + _sendPrintAwaitStatusToSubScreen(phase); + } + if (phase == PrintAwaitPhase.done) { + TtsManage.speak("发票已打印,请取走"); + } + if (phase.isTerminal) { + _printAwaitHideTimer?.cancel(); + _printAwaitHideTimer = Timer(const Duration(milliseconds: 2500), () { + dismissPrintAwait(); + }); + } + } + + /// 尽力把打印阶段同步到副屏成功页;副屏不可用或通道异常不影响主屏收银。 + void _sendPrintAwaitStatusToSubScreen(PrintAwaitPhase phase) { + unawaited( + AppRuntimeTool.sendViceMessage( + SubScreenAction.orderPrintStatus.code, + params: {'printStatus': phase.name}, + ).catchError((Object error, StackTrace stack) { + logger.w('|$_tag|副屏打印状态通知失败error:|$error|stack:|$stack|'); + return false; + }), + ); + } + /// 构建副屏使用的购物车商品列表;子类可在不改变下单协议的前提下补充展示字段。 Future>> buildSubScreenShopList( List cartItems, diff --git a/lib/pages/root/login/controller.dart b/lib/pages/root/login/controller.dart index 1a3abd4..1e872bf 100644 --- a/lib/pages/root/login/controller.dart +++ b/lib/pages/root/login/controller.dart @@ -226,6 +226,9 @@ class LoginController extends GetxController { return; } await Get.find().resumeAuthenticatedServices(); + // 正常重启不会经过设备绑定页,这里主动建连 MQTT,避免打印等推送 + // 依赖每分钟一次的重连定时任务,冷启动后几分钟才恢复消息。 + unawaited(AppMqTool.mqRefresh()); Get.offAllNamed(RouteNames.terminal); } diff --git a/lib/pages/terminal/canteen_product/controller.dart b/lib/pages/terminal/canteen_product/controller.dart index 4bd23aa..e5f05b7 100644 --- a/lib/pages/terminal/canteen_product/controller.dart +++ b/lib/pages/terminal/canteen_product/controller.dart @@ -249,6 +249,8 @@ class CanteenProductController extends GetxController with OrderMixin { // |下单|点击购买 Future doPay() async { + // 上一单的打印角标不能压在新订单上,新结算开始即收起。 + dismissPrintAwait(); final validShopCar = _validateShopCar(); if (!validShopCar) return; final snapshot = await _createCheckoutSnapshot(); @@ -258,6 +260,8 @@ class CanteenProductController extends GetxController with OrderMixin { snapshot: snapshot, onQuantityChanged: _changeCheckoutQuantity, ); + // 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。 + unawaited(startPrintAwaitAfterCheckout()); } /// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额与库存的展示快照。 diff --git a/lib/pages/terminal/canteen_product/view.dart b/lib/pages/terminal/canteen_product/view.dart index d01e722..f70576f 100644 --- a/lib/pages/terminal/canteen_product/view.dart +++ b/lib/pages/terminal/canteen_product/view.dart @@ -37,67 +37,72 @@ class CanteenProductPage extends GetView { builder: (_) => Obx(() { final productEntities = controller.visibleCanteenProducts; final products = productEntities.map(_toProductData).toList(); - return CashierWorkspace( - categories: controller.classifyList.toList(), - selectedCategory: controller.currentTabIndex, - onCategoryChanged: controller.doTab, - products: products, - onProductTap: (product) { - final entity = productEntities.firstWhere( - (item) => item.serviceUniqueCode == product.id, - ); - controller.doProduct(entity); - }, - onProductDetailsTap: (product) { - final entity = productEntities.firstWhere( - (item) => item.serviceUniqueCode == product.id, - ); - _showProductDrawer(context, entity); - }, - cartItems: controller.shopCardList.toList(), - user: controller.payUser.value, - totalPrice: controller.totalPrice.value, - extraPrice: controller.amountMealPrice.value, - productCount: controller.shopCardList.fold( - 0, - (total, item) => total + item.productNumber, + // 外层悬浮发票打印角标:非阻塞提醒,未绑定打印机或无打印任务时不占任何区域。 + return CashierPrintAwaitScope( + badge: controller.printAwaitBadge, + onDismiss: controller.dismissPrintAwait, + child: CashierWorkspace( + categories: controller.classifyList.toList(), + selectedCategory: controller.currentTabIndex, + onCategoryChanged: controller.doTab, + products: products, + onProductTap: (product) { + final entity = productEntities.firstWhere( + (item) => item.serviceUniqueCode == product.id, + ); + controller.doProduct(entity); + }, + onProductDetailsTap: (product) { + final entity = productEntities.firstWhere( + (item) => item.serviceUniqueCode == product.id, + ); + _showProductDrawer(context, entity); + }, + cartItems: controller.shopCardList.toList(), + user: controller.payUser.value, + totalPrice: controller.totalPrice.value, + extraPrice: controller.amountMealPrice.value, + productCount: controller.shopCardList.fold( + 0, + (total, item) => total + item.productNumber, + ), + onQuantityChanged: (item, quantity) { + final index = controller.shopCardList.indexWhere( + (current) => + current.productServiceUniqueCode == + item.productServiceUniqueCode, + ); + if (index >= 0) { + controller.doComputedInputChange(quantity, index); + } + }, + onRemoveCartItem: (item) { + final index = controller.shopCardList.indexWhere( + (current) => + current.productServiceUniqueCode == + item.productServiceUniqueCode, + ); + if (index >= 0) controller.doComputedInputChange(0, index); + }, + onClearCart: controller.doCancelPay, + onPay: controller.doPay, + onCancel: controller.doCancelPay, + showHoldAction: showHoldAction, + showMoreAction: showMoreAction, + searchController: controller.searchC, + onSearch: controller.doSearch, + onReset: controller.doResetSearch, + headerTrailing: appRadioHourTypeDialog( + context, + controller.hourTypeC, + title: '餐段', + compact: true, + checkConfiguredTime: true, + allowManualChange: allowManualMealPeriodChange, + ready: controller.initialization, + ).width(280.w), + cartTitle: '已点菜品', ), - onQuantityChanged: (item, quantity) { - final index = controller.shopCardList.indexWhere( - (current) => - current.productServiceUniqueCode == - item.productServiceUniqueCode, - ); - if (index >= 0) { - controller.doComputedInputChange(quantity, index); - } - }, - onRemoveCartItem: (item) { - final index = controller.shopCardList.indexWhere( - (current) => - current.productServiceUniqueCode == - item.productServiceUniqueCode, - ); - if (index >= 0) controller.doComputedInputChange(0, index); - }, - onClearCart: controller.doCancelPay, - onPay: controller.doPay, - onCancel: controller.doCancelPay, - showHoldAction: showHoldAction, - showMoreAction: showMoreAction, - searchController: controller.searchC, - onSearch: controller.doSearch, - onReset: controller.doResetSearch, - headerTrailing: appRadioHourTypeDialog( - context, - controller.hourTypeC, - title: '餐段', - compact: true, - checkConfiguredTime: true, - allowManualChange: allowManualMealPeriodChange, - ready: controller.initialization, - ).width(280.w), - cartTitle: '已点菜品', ); }), ); diff --git a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart index 9c2df01..a942782 100644 --- a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart +++ b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; @@ -98,6 +100,18 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { /// 是否已经触发弹框关闭,避免重复 pop。 bool _closing = false; + /// 核销成功后的兑换券打印等待状态机;null 表示尚未进入成功阶段。 + PrintAwaitTracker? _printAwaitTracker; + + /// 当前打印等待阶段,由 [_printAwaitTracker] 驱动。 + PrintAwaitPhase _printAwaitPhase = PrintAwaitPhase.preparing; + + /// 成功阶段展示的核销笔数,进入成功阶段时冻结。 + int _printAwaitOrderCount = 0; + + /// 终态打印等待的自动关闭计时;手动完成后取消。 + Timer? _printAwaitCloseTimer; + /// 当前准备核销的预约订单 ID;默认包含当前餐段的全部订单。 late final Set _selectedOrderIds; @@ -115,6 +129,10 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { /// 弹框销毁时注销快捷键句柄上的能力,避免快捷键调用已失效的回调。 @override void dispose() { + _printAwaitCloseTimer?.cancel(); + _printAwaitCloseTimer = null; + _printAwaitTracker?.dispose(); + _printAwaitTracker = null; widget.handle?.clear(); super.dispose(); } @@ -141,6 +159,9 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { .where(_selectedOrderIds.contains) .toList(growable: false); + /// 是否处于核销成功后的打印等待阶段;该阶段复用同一弹框壳。 + bool get _inPrintAwait => _printAwaitTracker != null; + /// 当前是否勾选了餐段内的全部预约订单。 bool get _allOrdersSelected => _selectedOrderIds.length == widget.meal.orderIds.length; @@ -172,16 +193,70 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { setState(() => _submitting = true); // 提交期间同步禁用快捷键句柄上的取消与确认能力,避免重复提交。 _syncHandle(); - final success = await onConfirm(_orderedSelectedOrderIds); + final submittedOrderIds = _orderedSelectedOrderIds; + final success = await onConfirm(submittedOrderIds); if (!mounted) return; if (success) { - _close(true); + await _enterPrintAwaitPhase(submittedOrderIds); return; } setState(() => _submitting = false); _syncHandle(); } + /// 核销成功后原地切换为“核销成功 + 兑换券打印等待”页。 + /// + /// 终端未绑定打印机时保持既有行为立即关闭;顾客站着等凭证,这里用弹框 + /// 原地替换而不是另开浮层,人脸识别暂停态随弹框自然保持。 + Future _enterPrintAwaitPhase(List submittedOrderIds) async { + if (!await PrintAwaitTracker.isPrinterBound()) { + _close(true); + return; + } + if (!mounted) { + _close(true); + return; + } + final tracker = PrintAwaitTracker( + orderIds: submittedOrderIds + .map((orderId) => orderId.toString()) + .toList(growable: false), + printType: '2', + ); + setState(() { + _printAwaitOrderCount = submittedOrderIds.length; + _printAwaitTracker = tracker; + _printAwaitPhase = tracker.phase; + }); + tracker.addListener(_onPrintAwaitPhaseChanged); + tracker.start(); + } + + /// 打印阶段变化时刷新成功页,并在终态安排自动关闭。 + void _onPrintAwaitPhaseChanged() { + final tracker = _printAwaitTracker; + if (tracker == null || !mounted) return; + if (tracker.phase == PrintAwaitPhase.done) { + TtsManage.speak('兑换券打印完成,请取走'); + } + if (!tracker.phase.isTerminal) { + if (_printAwaitPhase != tracker.phase) { + setState(() => _printAwaitPhase = tracker.phase); + } + return; + } + setState(() => _printAwaitPhase = tracker.phase); + // 完成与无需打印短暂停留即可;异常终态多停一会让操作员看清指引。 + final dwell = tracker.phase == PrintAwaitPhase.done || + tracker.phase == PrintAwaitPhase.noPrintNeeded + ? const Duration(milliseconds: 1500) + : const Duration(seconds: 3); + _printAwaitCloseTimer?.cancel(); + _printAwaitCloseTimer = Timer(dwell, () { + if (mounted) _close(true); + }); + } + /// 切换右侧商品明细的查看范围;空订单 ID 表示展示当前餐段全部订单。 void _displayOrder(int? orderId) { if (_submitting || orderId == _displayedOrderId) return; @@ -264,23 +339,34 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { maxWidth: dialogWidth, height: dialogHeight, contentPadding: EdgeInsets.zero, - content: [ - _summary(theme), - SizedBox( - key: const ValueKey('reservation-verification-workspace'), - child: [ - _orderNavigation(theme).width(dialogWidth * 0.28), - _cartPanel(theme).expanded(), - ] - .toRowSpace( - space: 14.w, - crossAxisAlignment: CrossAxisAlignment.stretch, - ) - .padding(horizontal: 24.w, vertical: 16.h), - ).expanded(), - const Divider(height: 1, color: XfjDesignTokens.line), - _footer(theme), - ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + content: _printAwaitTracker != null + ? [ + PrintAwaitPanel( + key: const ValueKey('reservation-verification-success-panel'), + phase: _printAwaitPhase, + credentialLabel: '兑换券', + title: '核销成功', + subtitle: '共核销 $_printAwaitOrderCount 笔预约', + onFinish: () => _close(true), + ).expanded(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + : [ + _summary(theme), + SizedBox( + key: const ValueKey('reservation-verification-workspace'), + child: [ + _orderNavigation(theme).width(dialogWidth * 0.28), + _cartPanel(theme).expanded(), + ] + .toRowSpace( + space: 14.w, + crossAxisAlignment: CrossAxisAlignment.stretch, + ) + .padding(horizontal: 24.w, vertical: 16.h), + ).expanded(), + const Divider(height: 1, color: XfjDesignTokens.line), + _footer(theme), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), ), ); } @@ -289,18 +375,27 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { Widget _header(FThemeData theme) { return [ FIcon( - widget.verificationEnabled - ? FAssets.icons.calendarCheck - : FAssets.icons.receiptText, - color: XfjDesignTokens.primary, + _inPrintAwait + ? FAssets.icons.circleCheck + : widget.verificationEnabled + ? FAssets.icons.calendarCheck + : FAssets.icons.receiptText, + color: + _inPrintAwait ? XfjDesignTokens.success : XfjDesignTokens.primary, size: 25.sp, ).center().tightSize(48.w).decorated( - color: XfjDesignTokens.primarySoft, + color: _inPrintAwait + ? XfjDesignTokens.success.withValues(alpha: 0.10) + : XfjDesignTokens.primarySoft, borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), ), [ Text( - widget.verificationEnabled ? '确认核销' : '预约详情', + _inPrintAwait + ? '核销结果' + : widget.verificationEnabled + ? '确认核销' + : '预约详情', style: theme.typography.xl2.copyWith( color: XfjDesignTokens.ink, fontWeight: FontWeight.w800, @@ -308,18 +403,25 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ), ), Text( - widget.verificationEnabled - ? '勾选需要核销的预约,并核对所选订单商品与数量。' - : '查看每笔预约的商品图片、名称与购买数量。', + _inPrintAwait + ? '兑换券正在打印,完成后自动关闭。' + : widget.verificationEnabled + ? '勾选需要核销的预约,并核对所选订单商品与数量。' + : '查看每笔预约的商品图片、名称与购买数量。', style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), ), ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), - _modeBadge(theme), + if (!_inPrintAwait) _modeBadge(theme), Material( color: Colors.transparent, child: InkWell( key: const ValueKey('reservation-order-dialog-close'), - onTap: _submitting ? null : () => _close(false), + // 成功阶段的任何退出都按核销成功收尾,不能回传取消结果。 + onTap: _inPrintAwait + ? () => _close(true) + : _submitting + ? null + : () => _close(false), borderRadius: BorderRadius.circular(12.w), child: FIcon( FAssets.icons.x, diff --git a/lib/pages/terminal/setting/widgets/setting_printer_info.dart b/lib/pages/terminal/setting/widgets/setting_printer_info.dart index 1a2b001..61b7d5a 100644 --- a/lib/pages/terminal/setting/widgets/setting_printer_info.dart +++ b/lib/pages/terminal/setting/widgets/setting_printer_info.dart @@ -199,6 +199,8 @@ class _SettingPrinterInfoState extends State { int.tryParse('${saved['usbVendorId'] ?? device.vendorId}'); setting.printerUsbProductId = int.tryParse('${saved['usbProductId'] ?? device.productId}'); + setting.printerOutputMode = + saved['printMode']?.toString() ?? PrinterOutputMode.image; await AppSettingTool.setSysSetting(setting); if (!mounted) return; setState(() { @@ -446,6 +448,13 @@ class _SettingPrinterInfoState extends State { ? setting!.printerModel : '未填写', ), + SettingInfoRow( + icon: FAssets.icons.image, + label: '打印方式', + value: '图片打印(默认)', + subtitle: '凭证图片渲染为位图出纸;命令打印暂不支持', + trailing: const _PrintModeOptions(), + ), SettingInfoRow( icon: FAssets.icons.hardDrive, label: '纸张与图片指令', @@ -529,6 +538,68 @@ class _SettingPrinterInfoState extends State { : DateUtil.formatDate(time, format: DateFormats.full); } +/// 打印方式选项芯片:图片打印固定选中,命令打印置灰展示"暂不支持"。 +/// +/// 终端固定图片打印(指令打印在真机兼容性不达标后已停用),本组件只做能力 +/// 声明展示,不提供切换交互;上报链路会向服务端同步 IMAGE 让其跳过指令流生成。 +class _PrintModeOptions extends StatelessWidget { + /// 创建打印方式只读选项组。 + const _PrintModeOptions(); + + /// 构建图片打印(选中)与命令打印(禁用)两枚芯片。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all(color: XfjDesignTokens.primary), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + FAssets.icons.circleCheck, + size: 13.sp, + color: XfjDesignTokens.primary, + ), + SizedBox(width: 4.w), + Text( + '图片打印', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), + decoration: BoxDecoration( + color: XfjDesignTokens.canvas, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Text( + '命令打印 · 暂不支持', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} + /// 连接概览中的单个信息块,数值采用等宽数字以避免刷新时布局跳动。 class _PrinterSummaryItem extends StatelessWidget { /// 创建包含标签、主要值和文字状态的概览项。 diff --git a/lib/pages/terminal/supermarket_order/controller.dart b/lib/pages/terminal/supermarket_order/controller.dart index 4a53dca..da99d0e 100644 --- a/lib/pages/terminal/supermarket_order/controller.dart +++ b/lib/pages/terminal/supermarket_order/controller.dart @@ -1,4 +1,5 @@ import 'package:common_utils/common_utils.dart'; +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; @@ -86,12 +87,16 @@ class SupermarketOrderController extends GetxController with OrderMixin { // |下单|点击购买 Future doPay() async { + // 上一单的打印角标不能压在新订单上,新结算开始即收起。 + dismissPrintAwait(); final validShopCar = _validateShopCar(); if (!validShopCar) return; final snapshot = _createCheckoutSnapshot(); // 创建本地订单 await createSqlOrder(); await selectPayTypeDialog(snapshot: snapshot); + // 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。 + unawaited(startPrintAwaitAfterCheckout()); } /// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。 diff --git a/lib/pages/terminal/supermarket_order/view.dart b/lib/pages/terminal/supermarket_order/view.dart index 2c080c6..813ca99 100644 --- a/lib/pages/terminal/supermarket_order/view.dart +++ b/lib/pages/terminal/supermarket_order/view.dart @@ -25,60 +25,65 @@ class SupermarketOrderPage extends GetView { final productEntities = controller.supermarketSpuList.toList(); final products = productEntities.map(_toProductData).toList(); final weighing = controller.widgetProduct.value != null; - return CashierWorkspace( - categories: controller.classifyList.toList(), - selectedCategory: controller.currentTabIndex, - onCategoryChanged: controller.doTab, - products: products, - onProductTap: (product) { - final entity = productEntities.firstWhere( - (item) => item.serviceUniqueCode == product.id, - ); - controller.doProduct(entity); - }, - cartItems: controller.shopCardList.toList(), - user: controller.payUser.value, - totalPrice: controller.totalPrice.value, - extraPrice: controller.amountMealPrice.value, - productCount: controller.totalProduct.value, - onQuantityChanged: (item, quantity) => - controller.doComputedInputChange( - quantity, - item.productServiceUniqueCode, + // 外层悬浮发票打印角标:非阻塞提醒,未绑定打印机或无打印任务时不占任何区域。 + return CashierPrintAwaitScope( + badge: controller.printAwaitBadge, + onDismiss: controller.dismissPrintAwait, + child: CashierWorkspace( + categories: controller.classifyList.toList(), + selectedCategory: controller.currentTabIndex, + onCategoryChanged: controller.doTab, + products: products, + onProductTap: (product) { + final entity = productEntities.firstWhere( + (item) => item.serviceUniqueCode == product.id, + ); + controller.doProduct(entity); + }, + cartItems: controller.shopCardList.toList(), + user: controller.payUser.value, + totalPrice: controller.totalPrice.value, + extraPrice: controller.amountMealPrice.value, + productCount: controller.totalProduct.value, + onQuantityChanged: (item, quantity) => + controller.doComputedInputChange( + quantity, + item.productServiceUniqueCode, + ), + onRemoveCartItem: (item) => controller.doComputedInputChange( + 0, + item.productServiceUniqueCode, + ), + onWeight: (item) => controller.computedPanelWidget( + item.productWidget, + item.productServiceUniqueCode, + ), + onClearCart: controller.doCancelPay, + onPay: controller.doPay, + onCancel: controller.doCancelPay, + onHold: controller.addHoldBill, + onMore: controller.doMore, + showHoldAction: showHoldAction, + showMoreAction: showMoreAction, + searchController: controller.selfCodeC, + searchFocusNode: controller.focusNode, + onSearch: controller.doPage, + onReset: controller.doReset, + onScan: AppRuntimeTool.useManualHardwareInput + ? controller.doManualScan + : null, + showScaleCard: true, + productAreaReplacement: weighing + ? TerminalSurface( + padding: EdgeInsets.all(16.w), + child: steelyardContainer(context), + ) + : null, + totalProductCount: controller.totalPage.value, + currentPage: controller.currentPage.value, + pageSize: controller.pageSize.value, + onPageChanged: (page) => controller.doPage(page: page), ), - onRemoveCartItem: (item) => controller.doComputedInputChange( - 0, - item.productServiceUniqueCode, - ), - onWeight: (item) => controller.computedPanelWidget( - item.productWidget, - item.productServiceUniqueCode, - ), - onClearCart: controller.doCancelPay, - onPay: controller.doPay, - onCancel: controller.doCancelPay, - onHold: controller.addHoldBill, - onMore: controller.doMore, - showHoldAction: showHoldAction, - showMoreAction: showMoreAction, - searchController: controller.selfCodeC, - searchFocusNode: controller.focusNode, - onSearch: controller.doPage, - onReset: controller.doReset, - onScan: AppRuntimeTool.useManualHardwareInput - ? controller.doManualScan - : null, - showScaleCard: true, - productAreaReplacement: weighing - ? TerminalSurface( - padding: EdgeInsets.all(16.w), - child: steelyardContainer(context), - ) - : null, - totalProductCount: controller.totalPage.value, - currentPage: controller.currentPage.value, - pageSize: controller.pageSize.value, - onPageChanged: (page) => controller.doPage(page: page), ); }), ); diff --git a/lib/sub_screen/common/models/customer_display_models.dart b/lib/sub_screen/common/models/customer_display_models.dart index 7d64aa3..5b4625b 100644 --- a/lib/sub_screen/common/models/customer_display_models.dart +++ b/lib/sub_screen/common/models/customer_display_models.dart @@ -1,5 +1,17 @@ import 'package:flutter/foundation.dart'; +/// 顾客屏成功页可展示的发票打印进度阶段,与主屏打印等待状态机同构。 +enum CustomerPrintStatus { preparing, printing, done, timeout, failed, offline } + +/// 解析主屏同步的打印阶段名;未知值返回 null 并由协调器丢弃消息。 +CustomerPrintStatus? customerPrintStatusFromName(String? name) { + if (name == null) return null; + for (final status in CustomerPrintStatus.values) { + if (status.name == name) return status; + } + return null; +} + /// 顾客屏可展示的稳定业务状态,同时用于真实副屏和主屏调试预览。 enum CustomerDisplayScenario { idle, @@ -138,6 +150,7 @@ class CustomerDisplayState { this.qrCodeData, this.countdownSeconds = 0, this.connected = true, + this.printStatus, }); factory CustomerDisplayState.idle() => const CustomerDisplayState( @@ -206,6 +219,9 @@ class CustomerDisplayState { final int countdownSeconds; final bool connected; + /// 成功页正在展示的发票打印进度;非成功场景或未开启打印提示时为 null。 + final CustomerPrintStatus? printStatus; + int get payablePrice => totalPrice + amountMealPrice; String? get maskedCustomerName { @@ -220,6 +236,7 @@ class CustomerDisplayState { CustomerDisplayScenario next, { String? qrCodeData, int countdownSeconds = 0, + CustomerPrintStatus? printStatus, }) { return CustomerDisplayState( scenario: next, @@ -231,6 +248,23 @@ class CustomerDisplayState { qrCodeData: qrCodeData, countdownSeconds: countdownSeconds, connected: connected, + printStatus: printStatus, + ); + } + + /// 仅更新发票打印进度;倒计时等展示字段原样保留。 + CustomerDisplayState withPrintStatus(CustomerPrintStatus? next) { + return CustomerDisplayState( + scenario: scenario, + businessTitle: businessTitle, + customerName: customerName, + products: products, + totalPrice: totalPrice, + amountMealPrice: amountMealPrice, + qrCodeData: qrCodeData, + countdownSeconds: countdownSeconds, + connected: connected, + printStatus: next, ); } @@ -245,7 +279,8 @@ class CustomerDisplayState { other.amountMealPrice == amountMealPrice && other.qrCodeData == qrCodeData && other.countdownSeconds == countdownSeconds && - other.connected == connected; + other.connected == connected && + other.printStatus == printStatus; } @override @@ -259,5 +294,6 @@ class CustomerDisplayState { qrCodeData, countdownSeconds, connected, + printStatus, ); } diff --git a/lib/sub_screen/common/utils/customer_display_coordinator.dart b/lib/sub_screen/common/utils/customer_display_coordinator.dart index 644ab76..7d3550c 100644 --- a/lib/sub_screen/common/utils/customer_display_coordinator.dart +++ b/lib/sub_screen/common/utils/customer_display_coordinator.dart @@ -19,6 +19,9 @@ abstract final class _SubScreenProtocol { /// 主屏确认非聚合码订单已完成支付的动作编码。 static const orderPaySuccess = 'orderPaySuccess'; + /// 主屏同步发票打印进度的动作编码,只作用于成功页。 + static const orderPrintStatus = 'orderPrintStatus'; + /// 主屏展示旧版钱包二维码的动作编码。 static const qrCodeScan = 'qrCodeScan'; @@ -160,6 +163,25 @@ class CustomerDisplayCoordinator extends ChangeNotifier { if (changed) _startCountdown(); return changed; } + if (action == _SubScreenProtocol.orderPrintStatus) { + // 打印进度只对成功页有意义;迟到或乱序消息直接丢弃,不清空有效状态。 + if (_state.scenario != CustomerDisplayScenario.success) return false; + final data = _normalizeMap(arguments); + final status = + customerPrintStatusFromName(data?['printStatus'] as String?); + if (status == null || status == _state.printStatus) return false; + final changed = _replace(_state.withPrintStatus(status)); + if (!changed) return false; + // 打印期间冻结成功页倒计时,顾客必须看到"请取走发票";终态恢复剩余倒计时。 + final holding = status == CustomerPrintStatus.preparing || + status == CustomerPrintStatus.printing; + if (holding) { + _stopCountdown(); + } else { + _startCountdown(); + } + return true; + } if (action == _SubScreenProtocol.qrCodeScanCancel || action == _SubScreenProtocol.aggregateOrderPayCancel) { if (_state.scenario != CustomerDisplayScenario.paying) return false; @@ -393,6 +415,8 @@ class CustomerDisplayCoordinator extends ChangeNotifier { ? _state.qrCodeData : null, countdownSeconds: nextSeconds, + // 倒计时刷新只改秒数,成功页正在展示的打印进度必须原样保留。 + printStatus: _state.printStatus, ), ); }); diff --git a/lib/sub_screen/common/utils/sub_display_coordinator.dart b/lib/sub_screen/common/utils/sub_display_coordinator.dart index 9f03f95..f6a2c88 100644 --- a/lib/sub_screen/common/utils/sub_display_coordinator.dart +++ b/lib/sub_screen/common/utils/sub_display_coordinator.dart @@ -37,6 +37,7 @@ class SubDisplayCoordinator extends ChangeNotifier { static const _jumpSubBack = 'jumpSubBack'; static const _paymentActions = { 'orderPaySuccess', + 'orderPrintStatus', 'qrCodeScan', 'qrCodeScanSuccess', 'qrCodeScanCancel', diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index cecc525..254f036 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -979,6 +979,11 @@ class _PaymentStatePanel extends StatelessWidget { fontWeight: FontWeight.w700, ), ), + if (state.printStatus != null) + _SuccessPrintStatusBadge( + status: state.printStatus!, + scale: scale, + ), Text.rich( TextSpan( children: [ @@ -1143,9 +1148,102 @@ class _Step extends StatelessWidget { } } +/// 成功页内的发票打印进度胶囊,让顾客在取走商品前注意到打印进度。 +class _SuccessPrintStatusBadge extends StatelessWidget { + /// 创建与当前打印阶段对应的进度胶囊。 + const _SuccessPrintStatusBadge({required this.status, required this.scale}); + + /// 主屏同步的发票打印阶段。 + final CustomerPrintStatus status; + + /// 依据副屏分辨率计算的统一缩放比例。 + final double scale; + + (Color, IconData?, String, bool) get _visual => switch (status) { + CustomerPrintStatus.preparing => ( + XfjDesignTokens.primary, + null, + '发票打印准备中…', + true + ), + CustomerPrintStatus.printing => ( + XfjDesignTokens.primary, + null, + '发票打印中,请稍候…', + true + ), + CustomerPrintStatus.done => ( + XfjDesignTokens.success, + Icons.print_rounded, + '请取走您的发票', + false + ), + CustomerPrintStatus.timeout => ( + XfjDesignTokens.warning, + Icons.schedule_rounded, + '发票仍在打印,可稍后领取', + false + ), + CustomerPrintStatus.failed => ( + XfjDesignTokens.warning, + Icons.error_outline_rounded, + '发票打印失败,请联系收银员', + false + ), + CustomerPrintStatus.offline => ( + XfjDesignTokens.warning, + Icons.power_off_rounded, + '打印机未连接,连接后自动打印', + false + ), + }; + + @override + Widget build(BuildContext context) { + final (color, icon, label, spinning) = _visual; + return Container( + key: const ValueKey('customer-success-print-status'), + padding: EdgeInsets.symmetric( + horizontal: 14 * scale, + vertical: 8 * scale, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + border: Border.all(color: color.withValues(alpha: 0.32)), + borderRadius: BorderRadius.circular(26 * scale), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (spinning) + SizedBox( + width: 22 * scale, + height: 22 * scale, + child: CircularProgressIndicator( + strokeWidth: 2.6, + color: color, + ), + ) + else + Icon(icon, color: color, size: 22 * scale), + SizedBox(width: 9 * scale), + Text( + label, + style: TextStyle( + color: color, + fontSize: _fontSize(24, scale), + height: 1.15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } +} + /// 商品面板顶部的结算阶段标签,用图标和文字同时表达当前状态。 class _StatusBadge extends StatelessWidget { - /// 创建与当前顾客屏场景对应的状态标签。 const _StatusBadge({required this.scenario, required this.scale}); /// 当前结算场景,决定标签的文案、图标和语义颜色。 diff --git a/test/common/print_await_tracker_test.dart b/test/common/print_await_tracker_test.dart new file mode 100644 index 0000000..5d497b7 --- /dev/null +++ b/test/common/print_await_tracker_test.dart @@ -0,0 +1,216 @@ +// ignore_for_file: depend_on_referenced_packages +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +PrintHistoryEntity _task(PrintStatus status) => PrintHistoryEntity( + orderId: '8001', + serviceOrderId: '8001', + servicePrintId: '9001', + invoiceUrl: 'https://example.test/invoice.png', + printType: '1', + status: status, + createTime: DateTime(2026, 1, 1), + ); + +ServicePrintRecord _record(String status) => ServicePrintRecord( + id: '9001', + orderId: '8001', + printType: '1', + printNo: 1, + retryVersion: 0, + attemptCount: 0, + status: status, + ); + +PrintAwaitTracker buildTracker( + FakeAsync async, { + required Future> Function(String orderId) taskReader, + Future> Function(String orderId)? recordsReader, + bool Function()? availableReader, + Future Function()? pendingPuller, +}) { + // fake_async 只虚拟化 Timer,不虚拟化 DateTime.now;用固定起点 + 已流逝 + // 虚拟时长拼出与定时器同步推进的注入时钟。 + final baseTime = DateTime(2026, 1, 1); + return PrintAwaitTracker( + orderIds: const ['8001'], + printType: '1', + clock: () => baseTime.add(async.elapsed), + taskReader: taskReader, + recordsReader: recordsReader ?? (_) async => const [], + availableReader: availableReader ?? (() => true), + pendingPuller: pendingPuller ?? () async {}, + ); +} + +void main() { + test('打印机物理不可用时直接进入 offline 终态', () { + fakeAsync((async) { + final tracker = buildTracker( + async, + taskReader: (_) async => [], + availableReader: () => false, + ); + tracker.start(); + expect(tracker.phase, PrintAwaitPhase.offline); + expect(tracker.phase.isTerminal, isTrue); + async.elapse(const Duration(seconds: 20)); + // offline 后不再轮询,阶段保持不变。 + expect(tracker.phase, PrintAwaitPhase.offline); + tracker.dispose(); + }); + }); + + test('本地任务从 NONE 变 SUCCESS 时经历 printing 后进入 done', () { + fakeAsync((async) { + var status = PrintStatus.NONE; + final tracker = + buildTracker(async, taskReader: (_) async => [_task(status)]); + final phases = []; + tracker.addListener(() => phases.add(tracker.phase)); + tracker.start(); + async.elapse(const Duration(seconds: 1)); + expect(tracker.phase, PrintAwaitPhase.printing); + status = PrintStatus.SUCCESS; + async.elapse(const Duration(seconds: 1)); + expect(tracker.phase, PrintAwaitPhase.done); + expect(tracker.phase.isTerminal, isTrue); + expect(phases, + containsAll([PrintAwaitPhase.printing, PrintAwaitPhase.done])); + tracker.dispose(); + }); + }); + + test('寻单与补拉宽限内都没有任务才判定本单无需打印', () { + fakeAsync((async) { + var pullCount = 0; + final tracker = buildTracker( + async, + taskReader: (_) async => [], + pendingPuller: () async => pullCount++, + ); + tracker.start(); + // 启动立即补拉一次。 + expect(pullCount, 1); + async.elapse(const Duration(seconds: 3)); + expect(tracker.phase, PrintAwaitPhase.preparing); + // 寻单窗口到期触发第二次补拉,进入宽限期。 + async.elapse(const Duration(seconds: 5)); + expect(pullCount, 2); + expect(tracker.phase, PrintAwaitPhase.preparing); + // 宽限期结束仍无任务痕迹才收尾。 + async.elapse(const Duration(seconds: 5)); + expect(pullCount, 2); + expect(tracker.phase, PrintAwaitPhase.noPrintNeeded); + tracker.dispose(); + }); + }); + + test('MQTT 断连时补拉把任务落库后进入打印中', () { + fakeAsync((async) { + var hasTask = false; + var pullCount = 0; + Future pull() async { + pullCount++; + // 寻单到期那次补拉成功把服务端待办拉回本地队列。 + if (pullCount >= 2) hasTask = true; + } + + final tracker = buildTracker( + async, + taskReader: (_) async => + hasTask ? [_task(PrintStatus.NONE)] : const [], + pendingPuller: pull, + ); + tracker.start(); + async.elapse(const Duration(seconds: 3)); + expect(tracker.phase, PrintAwaitPhase.preparing); + async.elapse(const Duration(seconds: 4)); + expect(hasTask, isTrue); + expect(tracker.phase, PrintAwaitPhase.printing); + tracker.dispose(); + }); + }); + + test('服务端记录确认有票后暴露 taskLocated 供角标提前浮现', () { + fakeAsync((async) { + var located = false; + final tracker = buildTracker( + async, + taskReader: (_) async => [], + recordsReader: (_) async => + located ? [_record('0')] : const [], + ); + tracker.start(); + expect(tracker.taskLocated, isFalse); + located = true; + async.elapse(const Duration(seconds: 2)); + expect(tracker.taskLocated, isTrue); + // 记录还是待生成状态,界面阶段保持准备中。 + expect(tracker.phase, PrintAwaitPhase.preparing); + tracker.dispose(); + }); + }); + + test('服务端已发布但本地未收到任务时保持准备中,不虚报打印中', () { + fakeAsync((async) { + var dispatched = false; + final tracker = buildTracker( + async, + taskReader: (_) async => [], + recordsReader: (_) async => + dispatched ? [_record('1')] : const [], + ); + tracker.start(); + dispatched = true; + async.elapse(const Duration(seconds: 2)); + // 服务端 status '1' 只代表已发布;本地队列没有任务就不是真的在打印。 + expect(tracker.phase, PrintAwaitPhase.preparing); + // 但任务存在已被确认,收银角标可以提前浮现"正在准备发票"。 + expect(tracker.taskLocated, isTrue); + // 有票但本地一直收不到(MQTT 断连且补拉无果)时,等待到总上限收尾。 + async.elapse(const Duration(seconds: 14)); + expect(tracker.phase, PrintAwaitPhase.timeout); + tracker.dispose(); + }); + }); + + test('任务长时间未完成时在等待上限进入 timeout', () { + fakeAsync((async) { + final tracker = buildTracker( + async, taskReader: (_) async => [_task(PrintStatus.NONE)]); + tracker.start(); + async.elapse(const Duration(seconds: 14)); + expect(tracker.phase, PrintAwaitPhase.printing); + async.elapse(const Duration(seconds: 2)); + expect(tracker.phase, PrintAwaitPhase.timeout); + tracker.dispose(); + }); + }); + + test('本地任务进入 ERROR 判定打印失败', () { + fakeAsync((async) { + final tracker = buildTracker( + async, taskReader: (_) async => [_task(PrintStatus.ERROR)]); + tracker.start(); + async.elapse(const Duration(seconds: 1)); + expect(tracker.phase, PrintAwaitPhase.failed); + tracker.dispose(); + }); + }); + + test('非期望凭证类型的任务不参与等待判定', () { + fakeAsync((async) { + final other = _task(PrintStatus.NONE)..printType = '2'; + final tracker = + buildTracker(async, taskReader: (_) async => [other]); + tracker.start(); + // 寻单窗口加补拉宽限全部走完,printType '2' 的兑换券任务对发票等待不可见。 + async.elapse(const Duration(seconds: 11)); + expect(tracker.phase, PrintAwaitPhase.noPrintNeeded); + tracker.dispose(); + }); + }); +} diff --git a/test/pages/goldens/print_await_panel_done.png b/test/pages/goldens/print_await_panel_done.png new file mode 100644 index 0000000..7ff47d0 Binary files /dev/null and b/test/pages/goldens/print_await_panel_done.png differ diff --git a/test/pages/goldens/print_await_panel_printing.png b/test/pages/goldens/print_await_panel_printing.png new file mode 100644 index 0000000..4331a3b Binary files /dev/null and b/test/pages/goldens/print_await_panel_printing.png differ diff --git a/test/pages/goldens/print_await_panel_timeout.png b/test/pages/goldens/print_await_panel_timeout.png new file mode 100644 index 0000000..e1b96c7 Binary files /dev/null and b/test/pages/goldens/print_await_panel_timeout.png differ diff --git a/test/pages/print_await_panel_preview_test.dart b/test/pages/print_await_panel_preview_test.dart new file mode 100644 index 0000000..1919bdc --- /dev/null +++ b/test/pages/print_await_panel_preview_test.dart @@ -0,0 +1,118 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show FontLoader; +import 'package:flutter_test/flutter_test.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 加载系统内置的中文字体,保证 Golden 预览图渲染真实文案而不是测试占位方块。 +Future _loadCjkFont() async { + final file = File('/System/Library/Fonts/Supplemental/Arial Unicode.ttf'); + final bytes = await file.readAsBytes(); + final loader = FontLoader('PreviewCJK') + ..addFont(Future.value(ByteData.view(bytes.buffer))); + await loader.load(); +} + +/// 以核销弹框成功阶段的视觉口径渲染打印等待面板,输出各阶段 Golden 预览图。 +void main() { + // 字体加载是真实异步引擎回调,必须放在 testWidgets 的 fake-async 区之外,否则永远挂起。 + setUpAll(_loadCjkFont); + + Future pumpPanel( + WidgetTester tester, + PrintAwaitPhase phase, + ) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(1920, 1080), + minTextAdapt: true, + builder: (_, __) { + return FTheme( + // 与 app.dart 根主题同口径:Forui 预置浅色底 + Xfj 语义令牌覆盖; + // typography 用默认构造去掉 forui 强制的 Inter 字体,让中文继承 PreviewCJK。 + data: FThemeData.inherit( + colorScheme: FThemes.zinc.light.colorScheme.copyWith( + background: XfjDesignTokens.surface, + foreground: XfjDesignTokens.ink, + primary: XfjDesignTokens.primary, + primaryForeground: Colors.white, + secondary: XfjDesignTokens.surface, + secondaryForeground: XfjDesignTokens.ink, + muted: XfjDesignTokens.canvas, + mutedForeground: XfjDesignTokens.muted, + destructive: XfjDesignTokens.danger, + destructiveForeground: Colors.white, + error: XfjDesignTokens.danger, + errorForeground: Colors.white, + border: XfjDesignTokens.line, + ), + typography: const FTypography(), + ), + child: MaterialApp( + theme: ThemeData(fontFamily: 'PreviewCJK'), + localizationsDelegates: FLocalizations.localizationsDelegates, + supportedLocales: FLocalizations.supportedLocales, + home: Scaffold( + backgroundColor: XfjDesignTokens.canvas, + body: Builder( + builder: (context) => Center( + // 模拟核销弹框内容区:92% 宽 x 90% 高、零内边白的成功阶段画布。 + child: Container( + width: 1766, + height: 972, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + borderRadius: BorderRadius.circular(16), + ), + child: PrintAwaitPanel( + phase: phase, + credentialLabel: '兑换券', + title: '核销成功', + subtitle: '共核销 2 笔预约', + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + // 固定步进推进 400ms,让 spinner 与按钮状态稳定在可截图帧。 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + testWidgets('打印等待面板-打印中预览', (tester) async { + await pumpPanel(tester, PrintAwaitPhase.printing); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/print_await_panel_printing.png'), + ); + }, timeout: const Timeout(Duration(minutes: 2))); + + testWidgets('打印等待面板-打印完成预览', (tester) async { + await pumpPanel(tester, PrintAwaitPhase.done); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/print_await_panel_done.png'), + ); + }, timeout: const Timeout(Duration(minutes: 2))); + + testWidgets('打印等待面板-超时与离线预览', (tester) async { + await pumpPanel(tester, PrintAwaitPhase.timeout); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/print_await_panel_timeout.png'), + ); + }, timeout: const Timeout(Duration(minutes: 2))); +} diff --git a/test/sub_screen/customer_display_coordinator_test.dart b/test/sub_screen/customer_display_coordinator_test.dart index dd042d3..222b231 100644 --- a/test/sub_screen/customer_display_coordinator_test.dart +++ b/test/sub_screen/customer_display_coordinator_test.dart @@ -341,6 +341,115 @@ void main() { coordinator.dispose(); }); + + testWidgets('打印中消息冻结成功页倒计时,完成后恢复并自然清屏', (tester) async { + final coordinator = CustomerDisplayCoordinator( + successCountdownSeconds: 3, + ); + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction('orderPaySuccess'); + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.state.countdownSeconds, 2); + + // 发票开始打印:成功页倒计时冻结,进度条上屏。 + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'printing'}, + ), + isTrue, + ); + expect(coordinator.state.printStatus, CustomerPrintStatus.printing); + await tester.pump(const Duration(seconds: 10)); + expect(coordinator.state.countdownSeconds, 2); + expect(coordinator.state.scenario, CustomerDisplayScenario.success); + expect(coordinator.state.printStatus, CustomerPrintStatus.printing); + + // 打印完成:恢复剩余倒计时,进度保留在成功页上直到自然清屏。 + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'done'}, + ), + isTrue, + ); + expect(coordinator.state.printStatus, CustomerPrintStatus.done); + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.state.countdownSeconds, 1); + await tester.pump(const Duration(seconds: 1)); + expect(coordinator.state.scenario, CustomerDisplayScenario.idle); + expect(coordinator.state.printStatus, isNull); + + coordinator.dispose(); + }); + + test('打印进度只作用于成功页,非成功场景与未知值被丢弃', () { + final coordinator = CustomerDisplayCoordinator(); + + // 未支付成功前收到的打印进度不允许改变核对页。 + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'printing'}, + ), + isFalse, + ); + expect(coordinator.state.printStatus, isNull); + + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction('orderPaySuccess'); + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'unknown'}, + ), + isFalse, + ); + // 重复同一阶段不会触发无意义的重建。 + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'printing'}, + ), + isTrue, + ); + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'printing'}, + ), + isFalse, + ); + + coordinator.dispose(); + }); + + testWidgets('新订单重新进入成功页时打印进度重置', (tester) async { + final coordinator = CustomerDisplayCoordinator( + successCountdownSeconds: 3, + ); + coordinator.handleAction('updateCard', _productEvent()); + coordinator.handleAction('orderPaySuccess'); + expect( + coordinator.handleAction( + 'orderPrintStatus', + const {'printStatus': 'done'}, + ), + isTrue, + ); + expect(coordinator.state.printStatus, CustomerPrintStatus.done); + + // 成功保留期内下一单直接接管,进入新的核对页。 + final nextOrder = _productEvent(); + final nextProducts = nextOrder['shopList']! as List>; + nextProducts.first['productName'] = '下一单纯牛奶'; + expect(coordinator.handleAction('updateCard', nextOrder), isTrue); + expect(coordinator.state.printStatus, isNull); + coordinator.handleAction('orderPaySuccess'); + expect(coordinator.state.printStatus, isNull); + + coordinator.dispose(); + }); } Map _productEvent() {