feat:打印优化
This commit is contained in:
1 parent
22c4d4b9b9
commit
383ea2ad4b
32 files changed
+1879
-154
No files matched your search
@@ -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 字段),发票照常打印。
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, dynamic> toJson() => _$PrinterInfoReportRequestToJson(this);
|
||||
|
||||
@@ -12,4 +12,5 @@ Map<String, dynamic> _$PrinterInfoReportRequestToJson(
|
||||
'printerModel': instance.printerModel,
|
||||
'usbVendorId': instance.usbVendorId,
|
||||
'usbProductId': instance.usbProductId,
|
||||
'printMode': instance.printMode,
|
||||
};
|
||||
@@ -52,6 +52,9 @@ enum SubScreenAction implements BaseEnum {
|
||||
|
||||
/// 通知副屏当前订单已经完成支付,适用于饭卡、钱包、人脸和付款码等非聚合码支付。
|
||||
orderPaySuccess("orderPaySuccess", "订单支付成功"),
|
||||
|
||||
/// 支付成功后同步发票打印进度到副屏成功页;打印期间副屏暂停成功页倒计时。
|
||||
orderPrintStatus("orderPrintStatus", "订单发票打印状态"),
|
||||
;
|
||||
|
||||
@override
|
||||
|
||||
@@ -37,6 +37,14 @@ abstract class PrintHistoryDao {
|
||||
'SELECT * FROM print_history WHERE servicePrintId = :servicePrintId LIMIT 1')
|
||||
Future<PrintHistoryEntity?> findByServicePrintId(String servicePrintId);
|
||||
|
||||
/// 按服务端订单号或 Android 订单号取全部本地任务,供打印等待页轮询进度。
|
||||
///
|
||||
/// 入队时 androidOrderCode 优先进 orderId 列、服务端单号固定在 serviceOrderId 列,
|
||||
/// 因此两个列都必须匹配调用方传入的服务端单号。
|
||||
@Query(
|
||||
'SELECT * FROM print_history WHERE orderId = :orderId OR serviceOrderId = :orderId ORDER BY id DESC')
|
||||
Future<List<PrintHistoryEntity>> findAllByOrder(String orderId);
|
||||
|
||||
/// 查询等待补报成功或失败结果的任务。
|
||||
@Query(
|
||||
'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC')
|
||||
|
||||
@@ -1690,6 +1690,14 @@ class _$PrintHistoryDao extends PrintHistoryDao {
|
||||
arguments: [servicePrintId]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PrintHistoryEntity>> findAllByOrder(String orderId) async {
|
||||
return _queryAdapter.queryList(
|
||||
'SELECT * FROM print_history WHERE orderId = ?1 OR serviceOrderId = ?1 ORDER BY id DESC',
|
||||
mapper: (Map<String, Object?> 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<List<PrintHistoryEntity>> getPendingResults() async {
|
||||
return _queryAdapter.queryList(
|
||||
|
||||
@@ -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<bool> bindSys(int serviceId) async {
|
||||
var androidId = await AppSysTool.getAndroidId();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 凭证打印等待的稳定阶段,核销成功页与收银角标共用同一套语义。
|
||||
enum PrintAwaitPhase {
|
||||
/// 服务端尚未确认本单存在打印任务,或发票图仍在生成。
|
||||
preparing,
|
||||
|
||||
/// 本地打印队列已出现本单任务,USB 正在或即将出纸。
|
||||
printing,
|
||||
|
||||
/// 全部匹配任务均已被 USB 完整接收,凭证应当正在出纸。
|
||||
done,
|
||||
|
||||
/// 寻单与补拉确认本单不存在打印任务,无需打印。
|
||||
noPrintNeeded,
|
||||
|
||||
/// 达到等待上限仍在打印,任务保留在本地队列继续重试。
|
||||
timeout,
|
||||
|
||||
/// 服务端或本地队列明确判定打印失败,需要人工在记录页补打。
|
||||
failed,
|
||||
|
||||
/// 终端绑定了打印机但硬件探测不可用,连接后队列会自动补打。
|
||||
offline;
|
||||
|
||||
/// 该阶段是否不会再变化;宿主界面据此安排自动收尾。
|
||||
bool get isTerminal =>
|
||||
this == done ||
|
||||
this == noPrintNeeded ||
|
||||
this == timeout ||
|
||||
this == failed ||
|
||||
this == offline;
|
||||
}
|
||||
|
||||
/// 按服务端订单号读取本地打印任务快照。
|
||||
typedef PrintAwaitTaskReader = Future<List<PrintHistoryEntity>> Function(
|
||||
String orderId);
|
||||
|
||||
/// 按服务端订单号读取服务端打印凭证记录。
|
||||
typedef PrintAwaitRecordsReader = Future<List<ServicePrintRecord>> Function(
|
||||
String orderId);
|
||||
|
||||
/// 读取打印机物理在线状态。
|
||||
typedef PrintAwaitAvailableReader = bool Function();
|
||||
|
||||
/// 主动触发服务端待办打印任务的 HTTP 补拉。
|
||||
typedef PrintAwaitPendingPuller = Future<void> Function();
|
||||
|
||||
/// 支付或核销成功后的凭证打印等待状态机。
|
||||
///
|
||||
/// 打印任务是服务端异步生成的:订单确认 → 服务端生成凭证图 → MQTT 派发 →
|
||||
/// 本地 `print_history` 队列 → USB 出纸。本类只做观测与展示,不修改
|
||||
/// [SyncPrintTool] 的入队、重试与补报语义;`dispose` 后打印照常后台进行。
|
||||
///
|
||||
/// MQTT 断连是打印延迟的主要来源(断连窗口内 PRINT 消息全部积压在 broker,
|
||||
/// 重连后才倾泻)。因此启动与寻单收尾都会主动触发
|
||||
/// [SyncPrintTool.pullPendingTasks] 的 HTTP 补拉通道,让任务在 MQTT
|
||||
/// 不可用时也能尽快落到本地队列开始出纸。
|
||||
class PrintAwaitTracker extends ChangeNotifier {
|
||||
/// 创建一个等待指定订单凭证打印的状态机。
|
||||
PrintAwaitTracker({
|
||||
required List<String> orderIds,
|
||||
required this.printType,
|
||||
this.locateWindow = const Duration(seconds: 6),
|
||||
this.locateGraceWindow = const Duration(seconds: 4),
|
||||
this.totalLimit = const Duration(seconds: 15),
|
||||
this.pollInterval = const Duration(milliseconds: 500),
|
||||
this.recordsInterval = const Duration(seconds: 1),
|
||||
DateTime Function()? clock,
|
||||
PrintAwaitTaskReader? taskReader,
|
||||
PrintAwaitRecordsReader? recordsReader,
|
||||
PrintAwaitAvailableReader? availableReader,
|
||||
PrintAwaitPendingPuller? pendingPuller,
|
||||
}) : orderIds = List.unmodifiable(orderIds),
|
||||
_clock = clock ?? DateTime.now,
|
||||
_taskReader = taskReader ?? _defaultTaskReader,
|
||||
_recordsReader = recordsReader ??
|
||||
((orderId) => HardApi.getPrinterRecords(orderId: orderId)),
|
||||
_availableReader = availableReader ??
|
||||
(() => AppDeviceStatusTool.status.value.printerAvailable),
|
||||
_pendingPuller = pendingPuller ?? SyncPrintTool.pullPendingTasks;
|
||||
|
||||
/// 打印凭证类型:'1' 收银发票,'2' 核销兑换券。
|
||||
final String printType;
|
||||
|
||||
/// 寻单窗口:窗口内服务端与本地都没有任务时进入最终确认。
|
||||
///
|
||||
/// 服务端生成凭证图通常需要 2~3 秒,窗口必须明显大于该耗时。
|
||||
final Duration locateWindow;
|
||||
|
||||
/// 寻单到期后的补拉宽限:二次补拉的结果落库时间,仍无任务才判无需打印。
|
||||
final Duration locateGraceWindow;
|
||||
|
||||
/// 等待上限;超时后任务仍在本地队列按原节奏重试,界面不再等待。
|
||||
final Duration totalLimit;
|
||||
|
||||
/// 本地任务轮询间隔,也是状态机的驱动节拍。
|
||||
final Duration pollInterval;
|
||||
|
||||
/// 服务端凭证记录的轮询间隔,仅寻单阶段使用。
|
||||
final Duration recordsInterval;
|
||||
|
||||
/// 需要等待打印凭证的服务端订单号;核销多单时包含全部勾选订单。
|
||||
final List<String> orderIds;
|
||||
|
||||
final PrintAwaitTaskReader _taskReader;
|
||||
final PrintAwaitRecordsReader _recordsReader;
|
||||
final PrintAwaitAvailableReader _availableReader;
|
||||
final PrintAwaitPendingPuller _pendingPuller;
|
||||
|
||||
/// 可注入时钟;测试用 fake_async 时必须同步虚拟时间,否则计时永不推进。
|
||||
final DateTime Function() _clock;
|
||||
|
||||
static const _tag = '打印等待';
|
||||
|
||||
static Future<List<PrintHistoryEntity>> _defaultTaskReader(
|
||||
String orderId,
|
||||
) async {
|
||||
final dao = Get.find<AppControl>().dataBase.getPrintHistoryDao;
|
||||
return dao.findAllByOrder(orderId);
|
||||
}
|
||||
|
||||
/// 判断当前终端是否绑定了逻辑打印机;绑定信息来自服务端 bootstrap 配置。
|
||||
///
|
||||
/// 读取失败、超时或测试环境未注册 AppControl 时一律视为未绑定:
|
||||
/// 打印等待只是附加提醒,不能因设置读取挂起阻塞核销/收银的界面收尾。
|
||||
static Future<bool> isPrinterBound() async {
|
||||
if (!Get.isRegistered<AppControl>()) return false;
|
||||
try {
|
||||
final setting = await AppSettingTool.getSysSetting()
|
||||
.timeout(const Duration(seconds: 2));
|
||||
return setting.printerHardId != null;
|
||||
} catch (error, stack) {
|
||||
logger.w('|$_tag|读取打印机绑定失败error:|$error|stack:$stack|');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
PrintAwaitPhase _phase = PrintAwaitPhase.preparing;
|
||||
Timer? _timer;
|
||||
DateTime? _startedAt;
|
||||
DateTime _lastRecordsAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
bool _ticking = false;
|
||||
bool _disposed = false;
|
||||
bool _recordsLocated = false;
|
||||
DateTime? _finalPullAt;
|
||||
|
||||
/// 当前等待阶段;进入终态后不再变化。
|
||||
PrintAwaitPhase get phase => _phase;
|
||||
|
||||
/// 是否已经启动;重复调用 [start] 不会重新计时。
|
||||
bool get isStarted => _startedAt != null;
|
||||
|
||||
/// 是否已确认本单存在打印任务(本地队列或服务端记录)。
|
||||
///
|
||||
/// 收银角标据此在 preparing 阶段就浮现:确认有票后无需继续静默,
|
||||
/// 让收银员尽早看到“正在准备发票”而不是等到打印中才出现。
|
||||
bool get taskLocated => _recordsLocated;
|
||||
|
||||
/// 启动等待;打印机物理不可用时直接进入 offline 终态,不占用顾客时间。
|
||||
void start() {
|
||||
if (_disposed || isStarted) return;
|
||||
_startedAt = _clock();
|
||||
if (!_availableReader()) {
|
||||
_finish(PrintAwaitPhase.offline);
|
||||
return;
|
||||
}
|
||||
logger.i('|$_tag|开始等待|orders:|$orderIds|type:|$printType|');
|
||||
// 不等 MQTT:立即走 HTTP 补拉,断连窗口内的待办任务可以秒级落库开打。
|
||||
_pullPending('启动补拉');
|
||||
_timer = Timer.periodic(pollInterval, (_) => _tick());
|
||||
_tick();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pullPending(String reason) {
|
||||
unawaited(_pendingPuller().catchError((Object error, StackTrace stack) {
|
||||
logger.w('|$_tag|$reason|失败error:|$error|stack:$stack|');
|
||||
}));
|
||||
}
|
||||
|
||||
Future<void> _tick() async {
|
||||
if (_disposed || _ticking || _phase.isTerminal) return;
|
||||
_ticking = true;
|
||||
try {
|
||||
final now = _clock();
|
||||
final startedAt = _startedAt!;
|
||||
final elapsed = now.difference(startedAt);
|
||||
|
||||
// 本地队列是出纸真相:MQTT 任务落库后状态只会 NONE -> SUCCESS/SYNC/ERROR。
|
||||
final tasks = <PrintHistoryEntity>[];
|
||||
for (final orderId in orderIds) {
|
||||
try {
|
||||
tasks.addAll(await _taskReader(orderId));
|
||||
} catch (error, stack) {
|
||||
logger.w(
|
||||
'|$_tag|读取本地打印任务失败orderId:|$orderId|error:|$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
final matchedTasks =
|
||||
tasks.where((task) => task.printType == printType).toList();
|
||||
|
||||
var recordPrinted = false;
|
||||
var recordFailed = false;
|
||||
var recordsLocatedNow = false;
|
||||
// 服务端记录只在寻单阶段轮询;本地任务出现后本地队列即唯一真相。
|
||||
if (matchedTasks.isEmpty &&
|
||||
elapsed < locateWindow + locateGraceWindow &&
|
||||
now.difference(_lastRecordsAt) >= recordsInterval) {
|
||||
_lastRecordsAt = now;
|
||||
for (final orderId in orderIds) {
|
||||
try {
|
||||
final records = await _recordsReader(orderId);
|
||||
final matched = records
|
||||
.where((record) => record.printType == printType)
|
||||
.toList();
|
||||
recordsLocatedNow = recordsLocatedNow || matched.isNotEmpty;
|
||||
for (final record in matched) {
|
||||
recordPrinted = recordPrinted || record.status == '2';
|
||||
recordFailed = recordFailed || record.status == '3';
|
||||
}
|
||||
} catch (error, stack) {
|
||||
// 服务端查询失败只影响寻单体验,保留 preparing 继续下一轮。
|
||||
logger.w(
|
||||
'|$_tag|查询打印凭证记录失败orderId:|$orderId|error:|$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
}
|
||||
// 首次确认本单有票时立即通知宿主:收银角标在 preparing 阶段就该浮现。
|
||||
if (recordsLocatedNow && !_recordsLocated) {
|
||||
_recordsLocated = true;
|
||||
logger.i('|$_tag|已确认本单存在打印任务|orders:|$orderIds|');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (elapsed >= totalLimit) {
|
||||
_finish(PrintAwaitPhase.timeout);
|
||||
return;
|
||||
}
|
||||
if (matchedTasks.isNotEmpty) {
|
||||
final allAccepted = matchedTasks.every(
|
||||
(task) =>
|
||||
task.status == PrintStatus.SUCCESS ||
|
||||
task.status == PrintStatus.SYNC,
|
||||
);
|
||||
final anyFailed =
|
||||
matchedTasks.any((task) => task.status == PrintStatus.ERROR);
|
||||
if (allAccepted) {
|
||||
_finish(PrintAwaitPhase.done);
|
||||
} else if (anyFailed) {
|
||||
_finish(PrintAwaitPhase.failed);
|
||||
} else if (_phase != PrintAwaitPhase.printing) {
|
||||
_update(PrintAwaitPhase.printing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (recordPrinted) {
|
||||
_finish(PrintAwaitPhase.done);
|
||||
return;
|
||||
}
|
||||
if (recordFailed) {
|
||||
_finish(PrintAwaitPhase.failed);
|
||||
return;
|
||||
}
|
||||
// 服务端 status '1' 只代表"已发布到 broker",终端是否收到取决于 MQTT;
|
||||
// 本地队列没有任务就绝不能显示"打印中",否则进度与真实出纸不符。
|
||||
// 此时保持 preparing 并依赖启动/寻单补拉把任务尽快拉回本地。
|
||||
if (!_recordsLocated && elapsed >= locateWindow) {
|
||||
final finalPullAt = _finalPullAt;
|
||||
if (finalPullAt == null) {
|
||||
// 寻单到期仍无痕迹:最后一次 HTTP 补拉兜底,覆盖 MQTT 断连丢消息。
|
||||
_finalPullAt = now;
|
||||
_pullPending('寻单到期补拉');
|
||||
return;
|
||||
}
|
||||
if (now.difference(finalPullAt) >= locateGraceWindow) {
|
||||
_finish(PrintAwaitPhase.noPrintNeeded);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_phase != PrintAwaitPhase.preparing) {
|
||||
_update(PrintAwaitPhase.preparing);
|
||||
}
|
||||
} finally {
|
||||
_ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _update(PrintAwaitPhase next) {
|
||||
if (_disposed || next == _phase) return;
|
||||
logger.i('|$_tag|阶段变化|orders:|$orderIds|from:|${_phase.name}|to:|${next.name}|');
|
||||
_phase = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _finish(PrintAwaitPhase terminal) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_update(terminal);
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,25 @@ abstract class SyncPrintTool {
|
||||
existing.status == PrintStatus.ERROR &&
|
||||
incomingVersion > existing.retryVersion;
|
||||
|
||||
/// 服务端打印方式与终端固定图片打印不一致时,上报 IMAGE 纠偏配置。
|
||||
///
|
||||
/// 纠偏失败只记日志:打印链路本身不受影响,下一次补拉会再次尝试。
|
||||
static Future<void> _healPrintModeIfNeeded(SysSetting setting) async {
|
||||
if (!AppSettingTool.needsPrintModeHeal(setting)) return;
|
||||
final hardId = setting.serviceId;
|
||||
if (hardId == null || hardId.isEmpty) return;
|
||||
try {
|
||||
await HardApi.reportPrinterInfo(
|
||||
hardId: hardId,
|
||||
request:
|
||||
const PrinterInfoReportRequest(printMode: PrinterOutputMode.image),
|
||||
);
|
||||
logger.i('|$_tag|服务端打印方式已纠偏为图片打印|printer:|${setting.printerHardId}|');
|
||||
} catch (error, stack) {
|
||||
logger.w('|$_tag|图片打印方式纠偏上报失败error:|$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
/// 在登录、MQTT 重连、应用恢复和周期检查时补拉当前业务终端代理的待办。
|
||||
static Future<void> pullPendingTasks() async {
|
||||
if (_isPulling || !AppNetworkTool.isAutoNetwork()) {
|
||||
@@ -144,6 +163,8 @@ abstract class SyncPrintTool {
|
||||
await HardApi.getPrinterBinding(),
|
||||
);
|
||||
if (setting.printerHardId == null) return;
|
||||
// 终端固定图片打印;服务端若被误配为指令打印,这里上报纠偏让其跳过指令流生成。
|
||||
unawaited(_healPrintModeIfNeeded(setting));
|
||||
final tasks = await HardApi.getPrinterTasks();
|
||||
for (final task in tasks) {
|
||||
if (task.historyId == null ||
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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),
|
||||
<Widget>[
|
||||
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<PrintAwaitBadgePresentation?> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 <Widget>[
|
||||
_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: <Widget>[
|
||||
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 <Widget>[
|
||||
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),
|
||||
<Widget>[
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -84,6 +84,21 @@ mixin OrderMixin {
|
||||
|
||||
Future? putOffSendSubScreenDelayed;
|
||||
|
||||
/// 最近一笔已在线支付订单的服务端单号;供结算弹框关闭后的发票打印角标追踪。
|
||||
///
|
||||
/// 离线单与创建失败单不会记录;聚合码单在服务端确认支付后记录。
|
||||
String? lastPaidServerOrderId;
|
||||
|
||||
/// 收银页右上角的发票打印角标快照;null 表示当前没有需要展示的打印等待。
|
||||
final Rxn<PrintAwaitBadgePresentation?> printAwaitBadge =
|
||||
Rxn<PrintAwaitBadgePresentation>();
|
||||
|
||||
/// 角标背后的打印等待状态机;进入终态或开始新订单时释放。
|
||||
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<void> 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<List<Map<String, dynamic>>> buildSubScreenShopList(
|
||||
List<ShopCardEntity> cartItems,
|
||||
|
||||
@@ -226,6 +226,9 @@ class LoginController extends GetxController {
|
||||
return;
|
||||
}
|
||||
await Get.find<AppControl>().resumeAuthenticatedServices();
|
||||
// 正常重启不会经过设备绑定页,这里主动建连 MQTT,避免打印等推送
|
||||
// 依赖每分钟一次的重连定时任务,冷启动后几分钟才恢复消息。
|
||||
unawaited(AppMqTool.mqRefresh());
|
||||
Get.offAllNamed(RouteNames.terminal);
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,8 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
|
||||
// |下单|点击购买
|
||||
Future<void> 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());
|
||||
}
|
||||
|
||||
/// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额与库存的展示快照。
|
||||
|
||||
@@ -37,67 +37,72 @@ class CanteenProductPage extends GetView<CanteenProductController> {
|
||||
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: '已点菜品',
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<int> _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<void> _enterPrintAwaitPhase(List<int> 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: <Widget>[
|
||||
_summary(theme),
|
||||
SizedBox(
|
||||
key: const ValueKey('reservation-verification-workspace'),
|
||||
child: <Widget>[
|
||||
_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
|
||||
? <Widget>[
|
||||
PrintAwaitPanel(
|
||||
key: const ValueKey('reservation-verification-success-panel'),
|
||||
phase: _printAwaitPhase,
|
||||
credentialLabel: '兑换券',
|
||||
title: '核销成功',
|
||||
subtitle: '共核销 $_printAwaitOrderCount 笔预约',
|
||||
onFinish: () => _close(true),
|
||||
).expanded(),
|
||||
].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch)
|
||||
: <Widget>[
|
||||
_summary(theme),
|
||||
SizedBox(
|
||||
key: const ValueKey('reservation-verification-workspace'),
|
||||
child: <Widget>[
|
||||
_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 <Widget>[
|
||||
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),
|
||||
),
|
||||
<Widget>[
|
||||
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,
|
||||
|
||||
@@ -199,6 +199,8 @@ class _SettingPrinterInfoState extends State<SettingPrinterInfo> {
|
||||
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<SettingPrinterInfo> {
|
||||
? 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<SettingPrinterInfo> {
|
||||
: 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 {
|
||||
/// 创建包含标签、主要值和文字状态的概览项。
|
||||
|
||||
@@ -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<void> doPay() async {
|
||||
// 上一单的打印角标不能压在新订单上,新结算开始即收起。
|
||||
dismissPrintAwait();
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
final snapshot = _createCheckoutSnapshot();
|
||||
// 创建本地订单
|
||||
await createSqlOrder();
|
||||
await selectPayTypeDialog(snapshot: snapshot);
|
||||
// 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。
|
||||
unawaited(startPrintAwaitAfterCheckout());
|
||||
}
|
||||
|
||||
/// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。
|
||||
|
||||
@@ -25,60 +25,65 @@ class SupermarketOrderPage extends GetView<SupermarketOrderController> {
|
||||
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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
static const _jumpSubBack = 'jumpSubBack';
|
||||
static const _paymentActions = {
|
||||
'orderPaySuccess',
|
||||
'orderPrintStatus',
|
||||
'qrCodeScan',
|
||||
'qrCodeScanSuccess',
|
||||
'qrCodeScanCancel',
|
||||
|
||||
@@ -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});
|
||||
|
||||
/// 当前结算场景,决定标签的文案、图标和语义颜色。
|
||||
|
||||
@@ -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<List<PrintHistoryEntity>> Function(String orderId) taskReader,
|
||||
Future<List<ServicePrintRecord>> Function(String orderId)? recordsReader,
|
||||
bool Function()? availableReader,
|
||||
Future<void> 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 = <PrintAwaitPhase>[];
|
||||
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<void> pull() async {
|
||||
pullCount++;
|
||||
// 寻单到期那次补拉成功把服务端待办拉回本地队列。
|
||||
if (pullCount >= 2) hasTask = true;
|
||||
}
|
||||
|
||||
final tracker = buildTracker(
|
||||
async,
|
||||
taskReader: (_) async =>
|
||||
hasTask ? [_task(PrintStatus.NONE)] : const <PrintHistoryEntity>[],
|
||||
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 <ServicePrintRecord>[],
|
||||
);
|
||||
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 <ServicePrintRecord>[],
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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<void> _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<void> 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)));
|
||||
}
|
||||
@@ -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<Map<String, dynamic>>;
|
||||
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<String, dynamic> _productEvent() {
|
||||
|
||||
Reference in new issue
Block a user