From 383ea2ad4b26d66399583cd79c1f35da0bd7b404 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 22 Sep 2026 13:29:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=89=93=E5=8D=B0=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ss_d3e6bcbb-1de9-4201-bbcc-fe3d28bdecc3.md | 39 +++ .../models/database/database_models.dart | 6 +- .../models/manage/printer_device_models.dart | 30 +- .../manage/printer_device_models.g.dart | 1 + .../models/plugs/sub_screen_enum_models.dart | 3 + .../database/dao/print_history_dao.dart | 8 + lib/common/services/database/database.g.dart | 8 + lib/common/utils/app/app_setting_tool.dart | 14 + lib/common/utils/index.dart | 1 + .../utils/sync/print_await_tracker.dart | 314 ++++++++++++++++++ lib/common/utils/sync/sync_print_tool.dart | 21 ++ .../app/cashier_print_await_badge.dart | 186 +++++++++++ lib/common/widgets/app/print_await_panel.dart | 228 +++++++++++++ lib/common/widgets/index.dart | 2 + .../widgets/order/mixin/order_mixin.dart | 85 +++++ lib/pages/root/login/controller.dart | 3 + .../terminal/canteen_product/controller.dart | 4 + lib/pages/terminal/canteen_product/view.dart | 125 +++---- .../reservation_verification_dialog.dart | 162 +++++++-- .../setting/widgets/setting_printer_info.dart | 71 ++++ .../supermarket_order/controller.dart | 5 + .../terminal/supermarket_order/view.dart | 111 ++++--- .../models/customer_display_models.dart | 38 ++- .../utils/customer_display_coordinator.dart | 24 ++ .../common/utils/sub_display_coordinator.dart | 1 + lib/sub_screen/page/customer_display.dart | 100 +++++- test/common/print_await_tracker_test.dart | 216 ++++++++++++ test/pages/goldens/print_await_panel_done.png | Bin 0 -> 33360 bytes .../goldens/print_await_panel_printing.png | Bin 0 -> 37586 bytes .../goldens/print_await_panel_timeout.png | Bin 0 -> 38390 bytes .../pages/print_await_panel_preview_test.dart | 118 +++++++ .../customer_display_coordinator_test.dart | 109 ++++++ 32 files changed, 1879 insertions(+), 154 deletions(-) create mode 100644 .zcode/plans/plan-sess_d3e6bcbb-1de9-4201-bbcc-fe3d28bdecc3.md create mode 100644 lib/common/utils/sync/print_await_tracker.dart create mode 100644 lib/common/widgets/app/cashier_print_await_badge.dart create mode 100644 lib/common/widgets/app/print_await_panel.dart create mode 100644 test/common/print_await_tracker_test.dart create mode 100644 test/pages/goldens/print_await_panel_done.png create mode 100644 test/pages/goldens/print_await_panel_printing.png create mode 100644 test/pages/goldens/print_await_panel_timeout.png create mode 100644 test/pages/print_await_panel_preview_test.dart 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 0000000000000000000000000000000000000000..7ff47d06ca072a45c3c056d641ad3dcbfc03f603 GIT binary patch literal 33360 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 DFQ_K4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4331a3bc43889fe4b429735385241d82de72bfeb GIT binary patch literal 37586 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1b96c741800a0732e4e8863842438505b478202 GIT binary patch literal 38390 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 literal 0 HcmV?d00001 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() {