From d517f45f38b6dcfd01255eba3b2f1a5f1f4c8573 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 4 Sep 2026 17:34:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=A0=B8=E9=94=80=E6=89=93=E5=8D=B0?= =?UTF-8?q?=E5=85=91=E6=8D=A2=E5=88=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/api/hard_api.dart | 61 ++ lib/common/models/api/hard_api_models.dart | 123 +++ lib/common/models/api/hard_api_models.g.dart | 63 ++ .../reservation_verification_gateway.dart | 63 +- .../verification_records/controller.dart | 172 ++++ .../terminal/verification_records/view.dart | 21 + .../verification_order_detail_drawer.dart | 795 ++++++++++++++++++ .../widgets/voucher_reprint_dialog.dart | 124 +++ pubspec.lock | 74 +- ...verification_order_detail_drawer_test.dart | 95 +++ .../verification_record_models_test.dart | 44 + 11 files changed, 1560 insertions(+), 75 deletions(-) create mode 100644 lib/pages/terminal/verification_records/widgets/voucher_reprint_dialog.dart diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index b9302c0..fd79ded 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -156,6 +156,23 @@ abstract class HardApi { return res.data == true; } + /// 一次提交多笔同一用户预约订单,服务端在同一事务内核销并合并生成一张兑换券。 + static Future cancelReservationOrders({ + required List orderIds, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/reservation-menu/cancel-batch', + data: {'orderIds': orderIds}, + options: dio.Options( + headers: { + if (setting.serviceId != null) 'DEVICE-ID': setting.serviceId, + }, + ), + ); + return res.data == true; + } + /// 分页查询当前设备的预约核销记录,设备范围由服务端再次校验。 static Future verificationRecordPage({ required int current, @@ -303,6 +320,50 @@ abstract class HardApi { return res.data != null; } + /// 在线查询当前业务终端为指定订单生成的打印凭证,不读取终端本地打印记录。 + static Future> getPrinterRecords({ + required String orderId, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定'); + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/printer/records', + queryParameters: {'orderId': int.parse(orderId)}, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data == null) return const []; + if (res.data is! List) throw const FormatException('打印凭证记录格式错误'); + return (res.data as List) + .map((item) => ServicePrintRecord.fromJson( + Map.from(item as Map), + )) + .toList(growable: false); + } + + /// 提交当前业务终端已打印兑换券的人工重打原因并取得新任务。 + static Future reprintVoucher({ + required String historyId, + required String reason, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/reprint', + data: { + 'historyId': int.parse(historyId), + 'reprintReason': reason, + }, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data == null) return null; + if (res.data is! Map) throw const FormatException('兑换券重打任务格式错误'); + return ServicePrintRecord.fromJson( + Map.from(res.data as Map), + ); + } + /// 打印成功 static Future printInvoice(dynamic formData) async { final res = await HttpManage().dio.get( diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 5c0e554..029ad3e 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -102,9 +102,16 @@ class ServiceOrderEvidenceImage { const ServiceOrderEvidenceImage({ required this.id, required this.evidenceType, + this.evidenceTypeName, required this.evidenceRole, + this.evidenceRoleName, required this.sequenceNo, required this.status, + this.statusName, + this.similarity, + this.similarityThreshold, + this.hardId, + this.capturedAt, this.imageHash, this.imageUrl, this.failureReason, @@ -117,15 +124,39 @@ class ServiceOrderEvidenceImage { /// 服务端稳定证据类型编码。 final String evidenceType; + /// 服务端按稳定编码转换的证据类型中文名称。 + final String? evidenceTypeName; + /// 服务端稳定图片角色编码。 final String evidenceRole; + /// 服务端按稳定编码转换的图片角色中文名称。 + final String? evidenceRoleName; + /// 同一角色下的图片序号。 final int sequenceNo; /// 服务端证据状态,取 PENDING、AVAILABLE 或 UNAVAILABLE。 final String status; + /// 服务端按稳定编码转换的证据状态中文名称。 + final String? statusName; + + /// 人脸识别相似度快照,范围为 0 到 1,非人脸凭证允许为空。 + @JsonKey(fromJson: JsonTool.anyToNullableDouble) + final double? similarity; + + /// 人脸识别采用的阈值快照,范围为 0 到 1。 + @JsonKey(fromJson: JsonTool.anyToNullableDouble) + final double? similarityThreshold; + + /// 采集该凭证的服务端设备 ID。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? hardId; + + /// 终端真正完成识别的本地采集时间。 + final DateTime? capturedAt; + /// 服务端已登记的图片 SHA-256。 final String? imageHash; @@ -143,6 +174,98 @@ class ServiceOrderEvidenceImage { Map toJson() => _$ServiceOrderEvidenceImageToJson(this); } +/// 服务端打印任务的只读快照,用于核销完整详情展示和兑换券重打定位。 +@JsonSerializable() +class ServicePrintRecord { + /// 创建一条不写入终端本地数据库的服务端打印任务快照。 + const ServicePrintRecord({ + required this.id, + required this.orderId, + this.hardId, + this.hardName, + this.printerHardId, + this.printerHardName, + required this.printType, + required this.printNo, + required this.retryVersion, + required this.attemptCount, + required this.status, + this.failureStage, + this.lastError, + this.printedAt, + this.reprintReason, + this.pic, + this.createTime, + }); + + /// 服务端 hard_invoice 打印任务主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String id; + + /// 打印凭证所属的服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String orderId; + + /// 产生该任务的食堂或超市业务终端 ID。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? hardId; + + /// 产生该任务的食堂或超市业务终端名称。 + final String? hardName; + + /// 服务端绑定的逻辑打印机资产 ID。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? printerHardId; + + /// 服务端绑定的逻辑打印机资产名称。 + final String? printerHardName; + + /// 凭证类型编码,1 为发票,2 为核销兑换券。 + @JsonKey(defaultValue: '1') + final String printType; + + /// 同一订单和凭证类型下从 1 开始的打印序号。 + @JsonKey(defaultValue: 1) + final int printNo; + + /// 失败任务的人工重试版本,用于终端过滤过期 MQTT 消息。 + @JsonKey(defaultValue: 0) + final int retryVersion; + + /// 终端已经执行的 USB 打印尝试次数。 + @JsonKey(defaultValue: 0) + final int attemptCount; + + /// 服务端打印任务状态编码,取 0 到 4。 + @JsonKey(defaultValue: '') + final String status; + + /// 最近一次失败阶段的稳定编码,成功和未处理任务允许为空。 + final String? failureStage; + + /// 最近一次可展示的失败摘要,不包含认证或支付凭证。 + final String? lastError; + + /// 终端确认完整写入 USB 打印机的服务端记录时间。 + final DateTime? printedAt; + + /// 人工重新打印时填写的原因,首次打印允许为空。 + final String? reprintReason; + + /// 后端生成的打印凭证图片地址,生成前或生成失败时允许为空。 + final String? pic; + + /// 服务端打印任务创建时间。 + final DateTime? createTime; + + /// 从终端专用在线接口解析打印任务。 + factory ServicePrintRecord.fromJson(Map json) => + _$ServicePrintRecordFromJson(json); + + /// 转换为测试和诊断使用的 JSON。 + Map toJson() => _$ServicePrintRecordToJson(this); +} + /// 按终端订单号对账返回的服务端订单和全部证据位置。 @JsonSerializable() class ServiceOrderEvidenceResolve { diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index 8de6482..1484834 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -70,9 +70,19 @@ ServiceOrderEvidenceImage _$ServiceOrderEvidenceImageFromJson( ServiceOrderEvidenceImage( id: JsonTool.anyToRequiredString(json['id']), evidenceType: json['evidenceType'] as String, + evidenceTypeName: json['evidenceTypeName'] as String?, evidenceRole: json['evidenceRole'] as String, + evidenceRoleName: json['evidenceRoleName'] as String?, sequenceNo: (json['sequenceNo'] as num).toInt(), status: json['status'] as String, + statusName: json['statusName'] as String?, + similarity: JsonTool.anyToNullableDouble(json['similarity']), + similarityThreshold: + JsonTool.anyToNullableDouble(json['similarityThreshold']), + hardId: JsonTool.anyToString(json['hardId']), + capturedAt: json['capturedAt'] == null + ? null + : DateTime.parse(json['capturedAt'] as String), imageHash: json['imageHash'] as String?, imageUrl: json['imageUrl'] as String?, failureReason: json['failureReason'] as String?, @@ -83,14 +93,67 @@ Map _$ServiceOrderEvidenceImageToJson( { 'id': instance.id, 'evidenceType': instance.evidenceType, + 'evidenceTypeName': instance.evidenceTypeName, 'evidenceRole': instance.evidenceRole, + 'evidenceRoleName': instance.evidenceRoleName, 'sequenceNo': instance.sequenceNo, 'status': instance.status, + 'statusName': instance.statusName, + 'similarity': instance.similarity, + 'similarityThreshold': instance.similarityThreshold, + 'hardId': instance.hardId, + 'capturedAt': instance.capturedAt?.toIso8601String(), 'imageHash': instance.imageHash, 'imageUrl': instance.imageUrl, 'failureReason': instance.failureReason, }; +ServicePrintRecord _$ServicePrintRecordFromJson(Map json) => + ServicePrintRecord( + id: JsonTool.anyToRequiredString(json['id']), + orderId: JsonTool.anyToRequiredString(json['orderId']), + hardId: JsonTool.anyToString(json['hardId']), + hardName: json['hardName'] as String?, + printerHardId: JsonTool.anyToString(json['printerHardId']), + printerHardName: json['printerHardName'] as String?, + printType: json['printType'] as String? ?? '1', + printNo: (json['printNo'] as num?)?.toInt() ?? 1, + retryVersion: (json['retryVersion'] as num?)?.toInt() ?? 0, + attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0, + status: json['status'] as String? ?? '', + failureStage: json['failureStage'] as String?, + lastError: json['lastError'] as String?, + printedAt: json['printedAt'] == null + ? null + : DateTime.parse(json['printedAt'] as String), + reprintReason: json['reprintReason'] as String?, + pic: json['pic'] as String?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), + ); + +Map _$ServicePrintRecordToJson(ServicePrintRecord instance) => + { + 'id': instance.id, + 'orderId': instance.orderId, + 'hardId': instance.hardId, + 'hardName': instance.hardName, + 'printerHardId': instance.printerHardId, + 'printerHardName': instance.printerHardName, + 'printType': instance.printType, + 'printNo': instance.printNo, + 'retryVersion': instance.retryVersion, + 'attemptCount': instance.attemptCount, + 'status': instance.status, + 'failureStage': instance.failureStage, + 'lastError': instance.lastError, + 'printedAt': instance.printedAt?.toIso8601String(), + 'reprintReason': instance.reprintReason, + 'pic': instance.pic, + 'createTime': instance.createTime?.toIso8601String(), + }; + ServiceOrderEvidenceResolve _$ServiceOrderEvidenceResolveFromJson( Map json) => ServiceOrderEvidenceResolve( diff --git a/lib/pages/terminal/menu_query/reservation_verification_gateway.dart b/lib/pages/terminal/menu_query/reservation_verification_gateway.dart index c405f9b..2eae32f 100644 --- a/lib/pages/terminal/menu_query/reservation_verification_gateway.dart +++ b/lib/pages/terminal/menu_query/reservation_verification_gateway.dart @@ -38,52 +38,39 @@ abstract interface class ReservationVerificationGateway { }); } -/// 服务端核销接口只接收单个订单 ID,终端按操作员勾选顺序逐笔提交并汇总结果。 +/// 服务端一次接收整批订单 ID;终端只提交一次请求,确保一个操作对应一张合并兑换券。 class TerminalReservationVerificationGateway implements ReservationVerificationGateway { - /// 对所选订单执行单笔幂等核销;发生网络异常前已成功的订单会作为部分成功返回。 + /// 对所选订单执行一次原子批量核销;服务端失败时整批回滚,避免出现多张兑换券或部分成功。 @override Future cancel({ required List orderIds, String? faceEvidenceSessionId, }) async { final normalizedOrderIds = orderIds.toSet().toList(growable: false); - final succeededOrderIds = []; - final failedOrderIds = []; - var pendingEvidenceSessionId = faceEvidenceSessionId; - for (var index = 0; index < normalizedOrderIds.length; index++) { - final orderId = normalizedOrderIds[index]; - try { - final evidenceSessionId = pendingEvidenceSessionId; - if (evidenceSessionId != null) { - // 本地证据模型按订单保存,同一次多选核销将现场证据绑定到首笔成功订单,其余订单沿用同一次操作确认。 - await OrderEvidenceUploadTool.prepareSessionForExistingOrder( - recognitionSessionId: evidenceSessionId, - serverOrderId: '$orderId', - ); - } - final canceled = await HardApi.cancelReservationOrder(orderId: orderId); - if (!canceled) { - failedOrderIds.add(orderId); - continue; - } - succeededOrderIds.add(orderId); - if (evidenceSessionId != null) { - pendingEvidenceSessionId = null; - await _markEvidenceReady( - recognitionSessionId: evidenceSessionId, - orderId: orderId, - ); - } - } catch (error, stack) { - if (succeededOrderIds.isEmpty) rethrow; - // 网络或服务异常后继续提交会扩大结果不确定性,保留当前及剩余订单供刷新后再次核销。 - failedOrderIds.addAll(normalizedOrderIds.skip(index)); - logger.w( - '|预约查询|批量核销部分成功|成功:${succeededOrderIds.length}|未成功:${failedOrderIds.length}|error:$error|stack:$stack|', - ); - break; - } + if (normalizedOrderIds.isEmpty) { + return const ReservationVerificationResult( + succeededOrderIds: [], + failedOrderIds: [], + ); + } + if (faceEvidenceSessionId != null) { + // 本地证据模型仍以首笔订单作为绑定锚点;服务端批量核销整批成功后再标记该订单可补传。 + await OrderEvidenceUploadTool.prepareSessionForExistingOrder( + recognitionSessionId: faceEvidenceSessionId, + serverOrderId: '${normalizedOrderIds.first}', + ); + } + final canceled = await HardApi.cancelReservationOrders( + orderIds: normalizedOrderIds, + ); + final succeededOrderIds = canceled ? normalizedOrderIds : []; + final failedOrderIds = canceled ? [] : normalizedOrderIds; + if (canceled && faceEvidenceSessionId != null) { + await _markEvidenceReady( + recognitionSessionId: faceEvidenceSessionId, + orderId: normalizedOrderIds.first, + ); } return ReservationVerificationResult( succeededOrderIds: List.unmodifiable(succeededOrderIds), diff --git a/lib/pages/terminal/verification_records/controller.dart b/lib/pages/terminal/verification_records/controller.dart index b943344..36bd178 100644 --- a/lib/pages/terminal/verification_records/controller.dart +++ b/lib/pages/terminal/verification_records/controller.dart @@ -46,6 +46,44 @@ class VerificationRecordsController extends GetxController { /// 详情请求序号,用于丢弃关闭抽屉或快速切换后的旧响应。 int _selectedDetailRequestId = 0; + /// 完整详情中的服务端打印凭证记录,不写入终端本地数据库。 + final selectedPrintRecords = [].obs; + + /// 服务端打印凭证记录的独立加载状态。 + final selectedPrintStatus = OrderRecordDetailLoadStatus.success.obs; + + /// 打印凭证查询失败时展示的可恢复错误信息。 + final selectedPrintError = ''.obs; + + /// 打印凭证请求序号,用于丢弃重复刷新时较早返回的响应。 + int _selectedPrintRequestId = 0; + + /// 完整详情中的服务端人脸识别凭证,只保留当前订单在线返回的数据。 + final selectedEvidenceImages = [].obs; + + /// 服务端识别凭证的独立加载状态。 + final selectedEvidenceStatus = OrderRecordDetailLoadStatus.success.obs; + + /// 识别凭证查询失败时展示的可恢复错误信息。 + final selectedEvidenceError = ''.obs; + + /// 识别凭证请求序号,用于丢弃重复刷新时较早返回的响应。 + int _selectedEvidenceRequestId = 0; + + /// 是否正在提交兑换券重打,期间禁用重复点击。 + final isReprintingVoucher = false.obs; + + /// 当前订单最新一条兑换券任务;列表由服务端按创建时间倒序返回。 + ServicePrintRecord? get latestVoucherRecord { + for (final record in selectedPrintRecords) { + if (record.printType == '2') return record; + } + return null; + } + + /// 只有最新兑换券已经完成打印时才能创建下一打印序号。 + bool get canReprintVoucher => latestVoucherRecord?.status == '2'; + List get visibleRecords { final keyword = keywordController.text.trim().toLowerCase(); if (keyword.isEmpty) return records; @@ -157,6 +195,16 @@ class VerificationRecordsController extends GetxController { selectedCarList.clear(); selectedDetailError.value = ''; selectedDetailStatus.value = OrderRecordDetailLoadStatus.loading; + selectedPrintRecords.clear(); + selectedPrintError.value = ''; + selectedPrintStatus.value = OrderRecordDetailLoadStatus.loading; + selectedEvidenceImages.clear(); + selectedEvidenceError.value = ''; + selectedEvidenceStatus.value = OrderRecordDetailLoadStatus.loading; + final credentialRequests = >[ + _loadSelectedPrintRecords(record), + _loadSelectedEvidence(record), + ]; try { final results = await Future.wait([ HardApi.getOrder({'id': record.orderId}), @@ -183,6 +231,7 @@ class VerificationRecordsController extends GetxController { logger.e( '|核销记录|打开完整详情失败|orderId:${record.orderId}|error:$error|stack:$stack|'); } + await Future.wait(credentialRequests); } /// 重试当前完整详情抽屉的数据请求。 @@ -192,14 +241,137 @@ class VerificationRecordsController extends GetxController { await openOrderDetail(record); } + /// 单独刷新当前完整详情中的在线打印凭证记录。 + Future refreshPrintRecords() async { + final record = selectedRecord.value; + if (record == null) return; + await _loadSelectedPrintRecords(record); + } + + /// 单独刷新当前完整详情中的在线识别凭证。 + Future refreshEvidence() async { + final record = selectedRecord.value; + if (record == null) return; + await _loadSelectedEvidence(record); + } + + /// 提交最新已打印兑换券的人工重打,并刷新服务端记录与本机打印待办。 + Future reprintVoucher(String reason) async { + final record = selectedRecord.value; + final voucher = latestVoucherRecord; + if (record == null || voucher == null || isReprintingVoucher.value) return; + if (voucher.status != '2') { + Toast.error(msg: '最新兑换券尚未打印完成,暂不能再次重打'); + return; + } + isReprintingVoucher.value = true; + try { + final created = await HardApi.reprintVoucher( + historyId: voucher.id, + reason: reason.trim(), + ); + if (created == null) throw StateError('服务端未返回兑换券重打任务'); + await _loadSelectedPrintRecords(record); + // MQTT 是实时通道,HTTP 补拉用于覆盖消息延迟或重连窗口,二者继续由任务 ID 幂等去重。 + await SyncPrintTool.pullPendingTasks(); + Toast.success(msg: '兑换券重打任务已提交'); + } catch (error, stack) { + logger.e( + '|核销记录|兑换券重打失败|orderId:${record.orderId}|task:${voucher.id}|error:$error|stack:$stack|'); + Toast.error(msg: '兑换券重打失败,请检查网络和打印机后重试'); + } finally { + isReprintingVoucher.value = false; + } + } + /// 关闭完整详情抽屉并取消其未完成请求的 UI 更新。 void closeOrderDetail() { _selectedDetailRequestId++; + _selectedPrintRequestId++; + _selectedEvidenceRequestId++; selectedRecord.value = null; selectedOrder.value = null; selectedCarList.clear(); selectedDetailError.value = ''; selectedDetailStatus.value = OrderRecordDetailLoadStatus.success; + selectedPrintRecords.clear(); + selectedPrintError.value = ''; + selectedPrintStatus.value = OrderRecordDetailLoadStatus.success; + selectedEvidenceImages.clear(); + selectedEvidenceError.value = ''; + selectedEvidenceStatus.value = OrderRecordDetailLoadStatus.success; + isReprintingVoucher.value = false; + } + + /// 按当前订单和 DEVICE-ID 查询服务端打印记录,失败不影响订单与识别凭证展示。 + Future _loadSelectedPrintRecords( + ServiceVerificationRecord record, + ) async { + final requestId = ++_selectedPrintRequestId; + selectedPrintStatus.value = OrderRecordDetailLoadStatus.loading; + selectedPrintError.value = ''; + try { + final records = await HardApi.getPrinterRecords( + orderId: '${record.orderId}', + ); + if (requestId != _selectedPrintRequestId || + selectedRecord.value?.orderId != record.orderId) { + return; + } + selectedPrintRecords.assignAll(records); + selectedPrintStatus.value = OrderRecordDetailLoadStatus.success; + } catch (error, stack) { + if (requestId != _selectedPrintRequestId || + selectedRecord.value?.orderId != record.orderId) { + return; + } + selectedPrintRecords.clear(); + selectedPrintError.value = '未能取得该订单的打印记录,请检查网络后重试'; + selectedPrintStatus.value = OrderRecordDetailLoadStatus.failure; + logger.e( + '|核销记录|打印凭证加载失败|orderId:${record.orderId}|error:$error|stack:$stack|'); + } + } + + /// 通过终端证据对账接口在线查询人脸凭证,结果不会写入本地数据库。 + Future _loadSelectedEvidence( + ServiceVerificationRecord record, + ) async { + final requestId = ++_selectedEvidenceRequestId; + selectedEvidenceStatus.value = OrderRecordDetailLoadStatus.loading; + selectedEvidenceError.value = ''; + try { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) { + throw StateError('当前业务终端尚未绑定'); + } + final resolved = await HardApi.resolveOrderEvidence( + hardId: hardId, + orderId: '${record.orderId}', + ); + if (requestId != _selectedEvidenceRequestId || + selectedRecord.value?.orderId != record.orderId) { + return; + } + if (resolved == null) throw StateError('服务端未返回识别凭证'); + selectedEvidenceImages.assignAll( + resolved.evidenceImages.where( + (item) => item.evidenceType == OrderEvidenceType.faceRecognition.code, + ), + ); + selectedEvidenceStatus.value = OrderRecordDetailLoadStatus.success; + } catch (error, stack) { + if (requestId != _selectedEvidenceRequestId || + selectedRecord.value?.orderId != record.orderId) { + return; + } + selectedEvidenceImages.clear(); + selectedEvidenceError.value = '未能取得该订单的识别凭证,请检查网络后重试'; + selectedEvidenceStatus.value = OrderRecordDetailLoadStatus.failure; + logger.e( + '|核销记录|识别凭证加载失败|orderId:${record.orderId}|error:$error|stack:$stack|'); + } } Future _loadExpandedRecord(int orderId) async { diff --git a/lib/pages/terminal/verification_records/view.dart b/lib/pages/terminal/verification_records/view.dart index 4a12ea8..2deaefe 100644 --- a/lib/pages/terminal/verification_records/view.dart +++ b/lib/pages/terminal/verification_records/view.dart @@ -10,6 +10,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; import 'controller.dart'; import 'widgets/verification_order_detail_drawer.dart'; import 'widgets/verification_record_table.dart'; +import 'widgets/voucher_reprint_dialog.dart'; class VerificationRecordsPage extends GetView { const VerificationRecordsPage({super.key}); @@ -57,7 +58,20 @@ class VerificationRecordsPage extends GetView { order: controller.selectedOrder.value, items: controller.selectedCarList.toList(growable: false), errorMessage: controller.selectedDetailError.value, + printStatus: controller.selectedPrintStatus.value, + printRecords: + controller.selectedPrintRecords.toList(growable: false), + printErrorMessage: controller.selectedPrintError.value, + evidenceStatus: controller.selectedEvidenceStatus.value, + evidenceImages: + controller.selectedEvidenceImages.toList(growable: false), + evidenceErrorMessage: controller.selectedEvidenceError.value, + canReprintVoucher: controller.canReprintVoucher, + isReprintingVoucher: controller.isReprintingVoucher.value, onRetry: controller.retryOrderDetail, + onRefreshPrintRecords: controller.refreshPrintRecords, + onRefreshEvidence: controller.refreshEvidence, + onReprintVoucher: () => _requestVoucherReprint(context), onClose: controller.closeOrderDetail, ); }), @@ -65,6 +79,13 @@ class VerificationRecordsPage extends GetView { ); } + /// 收集人工原因后提交兑换券重打;取消弹框不会产生服务端任务。 + Future _requestVoucherReprint(BuildContext context) async { + final reason = await showVoucherReprintDialog(context); + if (reason == null || reason.isEmpty) return; + await controller.reprintVoucher(reason); + } + /// 工具栏沿用交易记录的信息层级,并移除固定为预约核销后的无效类型筛选。 Widget _buildToolbar(BuildContext context) { final theme = FTheme.of(context); diff --git a/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart b/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart index f014b32..8dfb189 100644 --- a/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart +++ b/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart @@ -1,4 +1,5 @@ import 'package:common_utils/common_utils.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; @@ -15,7 +16,18 @@ class VerificationOrderDetailDrawer extends StatelessWidget { required this.order, required this.items, required this.errorMessage, + required this.printStatus, + required this.printRecords, + required this.printErrorMessage, + required this.evidenceStatus, + required this.evidenceImages, + required this.evidenceErrorMessage, + required this.canReprintVoucher, + required this.isReprintingVoucher, required this.onRetry, + required this.onRefreshPrintRecords, + required this.onRefreshEvidence, + required this.onReprintVoucher, required this.onClose, }); @@ -34,9 +46,42 @@ class VerificationOrderDetailDrawer extends StatelessWidget { /// 查询失败时展示给操作员的恢复提示。 final String errorMessage; + /// 服务端打印凭证记录的独立加载状态。 + final OrderRecordDetailLoadStatus printStatus; + + /// 当前终端在线查询到的打印凭证记录。 + final List printRecords; + + /// 打印凭证查询失败时展示的恢复提示。 + final String printErrorMessage; + + /// 服务端识别凭证的独立加载状态。 + final OrderRecordDetailLoadStatus evidenceStatus; + + /// 当前订单在线查询到的人脸识别凭证。 + final List evidenceImages; + + /// 识别凭证查询失败时展示的恢复提示。 + final String evidenceErrorMessage; + + /// 最新兑换券已经打印完成并允许创建下一打印序号。 + final bool canReprintVoucher; + + /// 是否正在向服务端提交兑换券重打任务。 + final bool isReprintingVoucher; + /// 重新请求订单和购物车数据。 final VoidCallback onRetry; + /// 独立刷新当前订单的服务端打印凭证记录。 + final VoidCallback onRefreshPrintRecords; + + /// 独立刷新当前订单的服务端识别凭证。 + final VoidCallback onRefreshEvidence; + + /// 打开重打原因弹框并提交最新已打印兑换券。 + final VoidCallback onReprintVoucher; + /// 关闭右侧详情抽屉。 final VoidCallback onClose; @@ -218,6 +263,79 @@ class VerificationOrderDetailDrawer extends StatelessWidget { BuildContext context, FThemeData theme, ServiceHardOrder detail, + ) { + return DefaultTabController( + key: ValueKey(record.orderId), + length: 3, + child: Column( + children: [ + _tabBar(context, theme), + TabBarView( + children: [ + _overview(context, theme, detail), + _printRecords(context, theme), + _evidence(context, theme), + ], + ).expanded(), + ], + ), + ); + } + + /// 构建订单、打印凭证和识别凭证三个稳定入口,计数变化不会改变触控区域。 + Widget _tabBar(BuildContext context, FThemeData theme) { + return ColoredBox( + color: XfjDesignTokens.surface, + child: TabBar( + labelColor: XfjDesignTokens.primary, + unselectedLabelColor: XfjDesignTokens.muted, + indicatorColor: XfjDesignTokens.primary, + indicatorWeight: 3.h, + dividerColor: XfjDesignTokens.line, + labelStyle: theme.typography.base.copyWith( + fontWeight: FontWeight.w700, + ), + unselectedLabelStyle: theme.typography.base.copyWith( + fontWeight: FontWeight.w600, + ), + tabs: [ + _detailTab(FAssets.icons.fileText, '订单与商品'), + _detailTab( + FAssets.icons.printer, + '打印与核销凭证(${printRecords.length})', + ), + _detailTab( + FAssets.icons.scanFace, + '识别凭证(${evidenceImages.length})', + ), + ], + ), + ); + } + + /// 构建固定高度的图标加文字 Tab,满足横屏终端的触控尺寸。 + Widget _detailTab(SvgAsset icon, String label) { + return Tab( + height: 58.h, + child: [ + FIcon(icon, size: 19.sp), + SizedBox(width: 8.w), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + ); + } + + /// 按可用宽度横向或纵向排列原有订单信息与商品明细。 + Widget _overview( + BuildContext context, + FThemeData theme, + ServiceHardOrder detail, ) { final orderInfo = _orderInfo(detail); final productInfo = _productInfo(context, theme, detail); @@ -242,6 +360,683 @@ class VerificationOrderDetailDrawer extends StatelessWidget { ); } + /// 构建打印凭证独立视图,加载、失败和空数据均不影响订单概览。 + Widget _printRecords(BuildContext context, FThemeData theme) { + return ColoredBox( + color: XfjDesignTokens.canvas, + child: Column( + children: [ + _sectionHeader( + context, + theme, + title: '打印与核销凭证', + subtitle: '该订单关联的发票、核销兑换券及终端打印结果', + actions: [ + TerminalToolbarButton( + label: '刷新记录', + icon: FAssets.icons.refreshCw, + style: TerminalToolbarButtonStyle.secondary, + onTap: printStatus == OrderRecordDetailLoadStatus.loading + ? null + : onRefreshPrintRecords, + ), + TerminalToolbarButton( + label: isReprintingVoucher ? '提交中...' : '重新打印兑换券', + icon: FAssets.icons.printer, + minWidth: 180.w, + onTap: canReprintVoucher && !isReprintingVoucher + ? onReprintVoucher + : null, + ), + ], + ), + switch (printStatus) { + OrderRecordDetailLoadStatus.loading => + _tabLoading(context, '正在在线查询打印凭证...'), + OrderRecordDetailLoadStatus.failure => _tabFailure( + context, + title: '打印记录加载失败', + detail: printErrorMessage, + onRetry: onRefreshPrintRecords, + ), + OrderRecordDetailLoadStatus.success when printRecords.isEmpty => + _tabEmpty( + context, + icon: FAssets.icons.receiptText, + title: '暂无打印凭证', + detail: '该订单暂无发票或核销兑换券打印记录', + ), + OrderRecordDetailLoadStatus.success => ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + itemCount: printRecords.length, + separatorBuilder: (_, __) => SizedBox(height: 8.h), + itemBuilder: (context, index) => + _printRecordRow(context, theme, printRecords[index]), + ).expanded(), + }, + ], + ), + ); + } + + /// 构建单条打印记录,字段口径与 PC“打印与核销凭证”保持一致。 + Widget _printRecordRow( + BuildContext context, + FThemeData theme, + ServicePrintRecord item, + ) { + return Container( + constraints: BoxConstraints(minHeight: 176.h), + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _credentialImage( + context, + source: item.pic, + title: '${_printTypeName(item.printType)}第${item.printNo}次打印', + statusText: item.pic == null ? '尚未生成' : null, + ), + SizedBox(width: 14.w), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final columnCount = constraints.maxWidth >= 850.w ? 3 : 2; + final gap = 12.w; + final columnWidth = + (constraints.maxWidth - gap * (columnCount - 1)) / + columnCount; + return Wrap( + spacing: gap, + runSpacing: 12.h, + children: [ + _printRecordGroup( + theme, + width: columnWidth, + title: '凭证', + lines: [ + _printTypeName(item.printType), + '第 ${item.printNo} 次打印', + ], + ), + _printRecordGroup( + theme, + width: columnWidth, + title: '任务状态', + status: _printStatusChip(context, item.status), + lines: [ + '终端尝试 ${item.attemptCount} 次', + '重试版本 ${item.retryVersion}', + ], + ), + _printRecordGroup( + theme, + width: columnWidth, + title: '设备', + lines: [ + '来源终端:${_nameWithId(item.hardName, item.hardId)}', + '目标打印机:${_nameWithId(item.printerHardName, item.printerHardId)}', + ], + ), + _printRecordGroup( + theme, + width: columnWidth, + title: '任务时间', + lines: [ + '创建:${_dateTime(item.createTime)}', + '完成:${_dateTime(item.printedAt)}', + ], + ), + _printRecordGroup( + theme, + width: columnWidth, + title: '异常与重打', + lines: [ + '失败阶段:${_failureStageName(item.failureStage)}', + '错误原因:${_text(item.lastError, fallback: '--')}', + '重打原因:${_text(item.reprintReason, fallback: '--')}', + ], + errorLineIndex: item.lastError == null ? null : 1, + ), + ], + ); + }, + ), + ), + ], + ), + ); + } + + /// 构建打印记录中的一组字段,长错误和设备名称允许换行而不挤压相邻列。 + Widget _printRecordGroup( + FThemeData theme, { + required double width, + required String title, + required List lines, + Widget? status, + int? errorLineIndex, + }) { + return SizedBox( + width: width, + child: [ + Text( + title, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ), + if (status != null) ...[ + SizedBox(height: 5.h), + status, + ], + ...List.generate(lines.length, (index) { + return Padding( + padding: EdgeInsets.only(top: 4.h), + child: Text( + lines[index], + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: index == errorLineIndex + ? XfjDesignTokens.danger + : XfjDesignTokens.ink, + height: 1.3, + ), + ), + ); + }), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start), + ); + } + + /// 构建识别凭证独立视图,只展示在线对账返回的人脸参考图和现场图。 + Widget _evidence(BuildContext context, FThemeData theme) { + return ColoredBox( + color: XfjDesignTokens.canvas, + child: Column( + children: [ + _sectionHeader( + context, + theme, + title: '识别凭证', + subtitle: '订单采集的人脸参考图、现场图及识别结果', + actions: [ + TerminalToolbarButton( + label: '刷新凭证', + icon: FAssets.icons.refreshCw, + style: TerminalToolbarButtonStyle.secondary, + onTap: evidenceStatus == OrderRecordDetailLoadStatus.loading + ? null + : onRefreshEvidence, + ), + ], + ), + switch (evidenceStatus) { + OrderRecordDetailLoadStatus.loading => + _tabLoading(context, '正在在线查询识别凭证...'), + OrderRecordDetailLoadStatus.failure => _tabFailure( + context, + title: '识别凭证加载失败', + detail: evidenceErrorMessage, + onRetry: onRefreshEvidence, + ), + OrderRecordDetailLoadStatus.success when evidenceImages.isEmpty => + _tabEmpty( + context, + icon: FAssets.icons.scanFace, + title: '暂无识别凭证', + detail: '该订单没有可展示的人脸参考图或现场图', + ), + OrderRecordDetailLoadStatus.success => LayoutBuilder( + builder: (context, constraints) => GridView.builder( + padding: + EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth >= 860.w ? 2 : 1, + mainAxisExtent: 224.h, + crossAxisSpacing: 10.w, + mainAxisSpacing: 10.h, + ), + itemCount: evidenceImages.length, + itemBuilder: (context, index) => + _evidenceCard(context, theme, evidenceImages[index]), + ), + ).expanded(), + }, + ], + ), + ); + } + + /// 构建单张识别凭证及其状态、采集时间、相似度和设备信息。 + Widget _evidenceCard( + BuildContext context, + FThemeData theme, + ServiceOrderEvidenceImage item, + ) { + final statusName = _evidenceStatusName(item); + return Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: Row( + children: [ + _credentialImage( + context, + source: item.status == 'AVAILABLE' ? item.imageUrl : null, + title: _evidenceRoleName(item), + statusText: statusName, + error: item.status == 'UNAVAILABLE' || + (item.status == 'AVAILABLE' && item.imageUrl == null), + ), + SizedBox(width: 14.w), + [ + Text( + _evidenceRoleName(item), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 8.h), + _evidenceLine(theme, '状态', statusName), + _evidenceLine(theme, '采集时间', _dateTime(item.capturedAt)), + _evidenceLine(theme, '相似度', _similarity(item.similarity)), + _evidenceLine( + theme, + '设备', + item.hardId == null ? '--' : 'ID ${item.hardId}', + ), + if (_text(item.failureReason, fallback: '').isNotEmpty) + _evidenceLine( + theme, + '异常', + item.failureReason!, + error: true, + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ], + ), + ); + } + + /// 构建识别凭证的一行标签和值,并为异常文字使用语义危险色。 + Widget _evidenceLine( + FThemeData theme, + String label, + String value, { + bool error = false, + }) { + return Padding( + padding: EdgeInsets.only(bottom: 5.h), + child: Text( + '$label:$value', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: error ? XfjDesignTokens.danger : XfjDesignTokens.muted, + height: 1.3, + ), + ), + ); + } + + /// 构建凭证 Tab 顶部标题与主要操作,窄宽度时操作区自动换行。 + Widget _sectionHeader( + BuildContext context, + FThemeData theme, { + required String title, + required String subtitle, + required List actions, + }) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: const BoxDecoration( + color: XfjDesignTokens.surface, + border: Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ), + child: [ + [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 3.h), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + SizedBox(width: 12.w), + Wrap(spacing: 8.w, runSpacing: 8.h, children: actions), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center), + ); + } + + /// 构建凭证在线查询中的稳定加载反馈。 + Widget _tabLoading(BuildContext context, String message) { + return [ + SizedBox( + width: 32.w, + height: 32.w, + child: const CircularProgressIndicator( + color: XfjDesignTokens.primary, + strokeWidth: 3, + ), + ), + SizedBox(height: 14.h), + Text( + message, + style: FTheme.of(context).typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center).center().expanded(); + } + + /// 构建单个凭证分区可恢复的网络失败状态。 + Widget _tabFailure( + BuildContext context, { + required String title, + required String detail, + required VoidCallback onRetry, + }) { + return [ + FIcon( + FAssets.icons.circleAlert, + size: 42.sp, + color: XfjDesignTokens.danger, + ), + SizedBox(height: 12.h), + Text( + title, + style: FTheme.of(context).typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.h), + Text( + detail.isEmpty ? '请检查网络连接后重试' : detail, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + SizedBox(height: 16.h), + TerminalToolbarButton( + label: '重新加载', + icon: FAssets.icons.refreshCw, + style: TerminalToolbarButtonStyle.secondary, + onTap: onRetry, + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center).center().expanded(); + } + + /// 构建单个凭证分区的空数据状态。 + Widget _tabEmpty( + BuildContext context, { + required SvgAsset icon, + required String title, + required String detail, + }) { + return [ + FIcon(icon, size: 42.sp, color: XfjDesignTokens.muted), + SizedBox(height: 12.h), + Text( + title, + style: FTheme.of(context).typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 6.h), + Text( + detail, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center).center().expanded(); + } + + /// 构建带固定比例的网络凭证缩略图,并允许点击查看可缩放大图。 + Widget _credentialImage( + BuildContext context, { + required String? source, + required String title, + String? statusText, + bool error = false, + }) { + final url = _networkImageUrl(source); + final placeholder = _credentialImagePlaceholder( + context, + statusText ?? '图片地址缺失', + error: error || statusText == null, + ); + return SizedBox( + width: 132.w, + height: 152.h, + child: url == null + ? placeholder + : Semantics( + button: true, + label: '查看$title大图', + child: Material( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _showImagePreview(context, url, title), + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + placeholder: (_, __) => const CircularProgressIndicator( + color: XfjDesignTokens.primary, + strokeWidth: 2, + ).center(), + errorWidget: (_, __, ___) => _credentialImagePlaceholder( + context, + '图片加载失败', + error: true, + ), + ), + ), + ), + ), + ); + } + + /// 构建图片等待、缺失或加载异常时的可读占位反馈。 + Widget _credentialImagePlaceholder( + BuildContext context, + String message, { + required bool error, + }) { + final color = error ? XfjDesignTokens.danger : XfjDesignTokens.muted; + return Container( + padding: EdgeInsets.all(10.w), + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular(8), + ), + child: [ + FIcon( + error ? FAssets.icons.imageOff : FAssets.icons.image, + size: 28.sp, + color: color, + ), + SizedBox(height: 8.h), + Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith(color: color), + ), + ].toColumn(mainAxisAlignment: MainAxisAlignment.center), + ); + } + + /// 在应用统一弹框中展示服务端凭证原图,支持双指缩放和拖动查看。 + Future _showImagePreview( + BuildContext context, + String url, + String title, + ) { + final viewport = MediaQuery.sizeOf(context); + return showAppDialog( + context: context, + builder: (dialogContext) => AppDialog( + title: Text(title), + leading: FIcon( + FAssets.icons.image, + color: XfjDesignTokens.primary, + ).center().width(44.w).height(44.h), + width: viewport.width * 0.72, + maxWidth: viewport.width * 0.72, + height: viewport.height * 0.86, + onClose: () => Navigator.of(dialogContext).pop(), + semanticLabel: '$title图片预览弹框', + content: InteractiveViewer( + minScale: 0.7, + maxScale: 4, + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + placeholder: (_, __) => const CircularProgressIndicator( + color: XfjDesignTokens.primary, + strokeWidth: 3, + ).center(), + errorWidget: (_, __, ___) => _credentialImagePlaceholder( + dialogContext, + '图片加载失败,请关闭后重试', + error: true, + ), + ).center(), + ), + ), + ); + } + + /// 根据服务端地址类型补全 OSS 前缀,完整 HTTP 地址保持原样。 + String? _networkImageUrl(String? source) { + final value = source?.trim(); + if (value == null || value.isEmpty) return null; + return AppNetworkTool.isNetworkImage(value) + ? value + : AppNetworkTool.ossImage(value); + } + + /// 将打印类型编码转换为 PC 页面相同的中文名称。 + String _printTypeName(String type) => switch (type) { + '1' => '发票', + '2' => '兑换券', + _ => type.isEmpty ? '未知凭证' : type, + }; + + /// 构建带文字和语义颜色的打印任务状态,不单独依赖颜色传达结果。 + Widget _printStatusChip(BuildContext context, String status) { + final (label, color, background) = switch (status) { + '0' => ('待生成', XfjDesignTokens.warning, const Color(0xFFFFF8E8)), + '1' => ('待打印', XfjDesignTokens.primary, XfjDesignTokens.primarySoft), + '2' => ('已打印', XfjDesignTokens.success, const Color(0xFFEAF8F1)), + '3' => ('打印失败', XfjDesignTokens.danger, const Color(0xFFFFF1F0)), + '4' => ('已取消', XfjDesignTokens.muted, XfjDesignTokens.control), + _ => ('未知状态 $status', XfjDesignTokens.muted, XfjDesignTokens.control), + }; + return Container( + padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 4.h), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: FTheme.of(context).typography.sm.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + /// 将服务端失败阶段编码转换为可读中文,未知编码原样保留便于排查。 + String _failureStageName(String? stage) => switch (stage) { + 'BINDING' => '设备绑定', + 'IMAGE_GENERATION' => '图片生成', + 'MQTT_DISPATCH' => 'MQTT 下发', + 'TERMINAL_PRINT' => '终端打印', + null || '' => '--', + _ => stage, + }; + + /// 将设备名称和 ID 合并展示,历史名称缺失时仍保留可定位的服务端 ID。 + String _nameWithId(String? name, String? id) { + final title = _text(name, fallback: ''); + final code = _text(id, fallback: ''); + if (title.isEmpty) return code.isEmpty ? '--' : 'ID $code'; + return code.isEmpty ? title : '$title($code)'; + } + + /// 将识别凭证角色转换为 PC 页面相同的中文名称。 + String _evidenceRoleName(ServiceOrderEvidenceImage item) { + final serviceName = _text(item.evidenceRoleName, fallback: ''); + if (serviceName.isNotEmpty) return serviceName; + return switch (item.evidenceRole) { + 'FACE_REFERENCE' => '人脸参考图', + 'FACE_LIVE' => '现场识别图', + _ => item.evidenceRole, + }; + } + + /// 将识别凭证状态转换为可读中文,优先使用服务端返回名称。 + String _evidenceStatusName(ServiceOrderEvidenceImage item) { + final serviceName = _text(item.statusName, fallback: ''); + if (serviceName.isNotEmpty) return serviceName; + return switch (item.status) { + 'PENDING' => '等待补传', + 'AVAILABLE' => item.imageUrl == null ? '图片地址缺失' : '已保存', + 'UNAVAILABLE' => '图片永久缺失', + _ => item.status.isEmpty ? '未知状态' : item.status, + }; + } + + /// 将 0 到 1 的识别相似度格式化为百分比,缺失时使用统一占位符。 + String _similarity(double? value) { + if (value == null) return '--'; + return '${(value * 100).toStringAsFixed(2)}%'; + } + + /// 统一格式化接口时间,缺失时间使用双横线而不伪造本地时间。 + String _dateTime(DateTime? value) => + value == null ? '--' : DateUtil.formatDate(value); + /// 构建可独立滚动的订单与核销基础信息区域。 Widget _orderInfo(ServiceHardOrder detail) { final mealType = ProductHourType.values.fromCode( diff --git a/lib/pages/terminal/verification_records/widgets/voucher_reprint_dialog.dart b/lib/pages/terminal/verification_records/widgets/voucher_reprint_dialog.dart new file mode 100644 index 0000000..d60851d --- /dev/null +++ b/lib/pages/terminal/verification_records/widgets/voucher_reprint_dialog.dart @@ -0,0 +1,124 @@ +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'; + +/// 人工兑换券重打原因允许提交的最大字符数,与服务端错误摘要边界保持明显余量。 +const int voucherReprintReasonMaxLength = 200; + +/// 收集人工重新打印兑换券原因,取消、遮罩和系统返回均不会提交打印任务。 +Future showVoucherReprintDialog(BuildContext context) { + return showAppDialog( + context: context, + barrierDismissible: false, + canPop: false, + builder: (_) => const _VoucherReprintDialog(), + ); +} + +/// 兑换券重打原因弹框,由自身状态持有输入控制器直到退场动画结束。 +class _VoucherReprintDialog extends StatefulWidget { + /// 创建不可绕过必填校验的兑换券重打原因弹框。 + const _VoucherReprintDialog(); + + /// 创建输入状态并接管文本控制器生命周期。 + @override + State<_VoucherReprintDialog> createState() => _VoucherReprintDialogState(); +} + +/// 兑换券重打原因弹框的输入、校验与关闭状态。 +class _VoucherReprintDialogState extends State<_VoucherReprintDialog> { + /// 当前原因表单的校验键。 + final _formKey = GlobalKey(); + + /// 操作员输入的人工重打原因。 + final _reasonController = TextEditingController(); + + /// 是否已经触发关闭,避免快速连续点击重复弹出路由。 + bool _closing = false; + + /// 释放输入控制器,避免弹框关闭后继续持有编辑资源。 + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + + /// 取消本次操作并返回空结果。 + void _cancel() => _close(); + + /// 校验非空及长度后返回去除首尾空白的人工原因。 + void _submit() { + if (_closing || _formKey.currentState?.validate() != true) return; + _close(_reasonController.text.trim()); + } + + /// 只执行一次路由关闭,并在关闭前收起软键盘。 + void _close([String? result]) { + if (_closing) return; + _closing = true; + FocusManager.instance.primaryFocus?.unfocus(); + Navigator.of(context).pop(result); + } + + /// 构建带风险说明、必填输入和标准触控按钮的 Forui 弹框。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final viewport = MediaQuery.sizeOf(context); + return AppDialog( + title: Text('重新打印兑换券', style: theme.typography.xl2), + subtitle: Text( + '将创建下一打印序号,并由当前终端绑定的打印机出纸', + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + leading: FIcon( + FAssets.icons.printer, + color: XfjDesignTokens.primary, + size: 24.sp, + ).center().width(44.w).height(44.h), + width: viewport.width * 0.52, + maxWidth: viewport.width * 0.52, + onClose: _cancel, + semanticLabel: '重新打印兑换券原因弹框', + content: Form( + key: _formKey, + child: FTextField( + controller: _reasonController, + autofocus: true, + label: Text('重打原因', style: theme.typography.base), + description: Text( + '原因会随打印记录上传,便于后续追溯。', + style: theme.typography.sm, + ), + hint: '例如:纸张破损、字迹不清', + minLines: 2, + maxLines: 3, + validator: (value) { + final reason = value?.trim() ?? ''; + if (reason.isEmpty) return '请输入重打原因'; + if (reason.length > voucherReprintReasonMaxLength) { + return '重打原因不能超过$voucherReprintReasonMaxLength个字符'; + } + return null; + }, + ), + ).padding(vertical: 20.h), + actions: [ + FButton( + style: FButtonStyle.outline, + label: Text('取消', style: theme.typography.base), + onPress: _cancel, + ).width(180.w), + FButton( + label: Text( + '确认重打', + style: theme.typography.base.copyWith(color: Colors.white), + ), + onPress: _submit, + ).width(180.w), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0d9cebb..ac04131 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,10 +50,10 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.flutter-io.cn" source: hosted - version: "2.11.0" + version: "2.12.0" background_downloader: dependency: "direct main" description: @@ -82,10 +82,10 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: @@ -178,10 +178,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.4.0" charcode: dependency: transitive description: @@ -202,10 +202,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -218,10 +218,10 @@ packages: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.flutter-io.cn" source: hosted - version: "1.19.0" + version: "1.19.1" common_utils: dependency: "direct main" description: @@ -393,10 +393,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.1" + version: "1.3.2" ffi: dependency: transitive description: @@ -709,18 +709,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.flutter-io.cn" source: hosted - version: "10.0.7" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.8" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -781,10 +781,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -797,10 +797,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.flutter-io.cn" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: @@ -901,10 +901,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.flutter-io.cn" source: hosted - version: "1.9.0" + version: "1.9.1" path_parsing: dependency: transitive description: @@ -1186,10 +1186,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.flutter-io.cn" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: @@ -1266,18 +1266,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.flutter-io.cn" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -1290,10 +1290,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0" + version: "1.4.1" strings: dependency: transitive description: @@ -1338,18 +1338,18 @@ packages: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.3" + version: "0.7.4" timing: dependency: transitive description: @@ -1434,10 +1434,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.flutter-io.cn" source: hosted - version: "14.3.0" + version: "14.3.1" watcher: dependency: transitive description: @@ -1503,5 +1503,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.6.0 <4.0.0" + dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/test/common/verification_order_detail_drawer_test.dart b/test/common/verification_order_detail_drawer_test.dart index 98cc5d9..4a9d2a3 100644 --- a/test/common/verification_order_detail_drawer_test.dart +++ b/test/common/verification_order_detail_drawer_test.dart @@ -168,7 +168,18 @@ void main() { order: _order(), items: _items(), errorMessage: '', + printStatus: OrderRecordDetailLoadStatus.success, + printRecords: const [], + printErrorMessage: '', + evidenceStatus: OrderRecordDetailLoadStatus.success, + evidenceImages: const [], + evidenceErrorMessage: '', + canReprintVoucher: false, + isReprintingVoucher: false, onRetry: () {}, + onRefreshPrintRecords: () {}, + onRefreshEvidence: () {}, + onReprintVoucher: () {}, onClose: () {}, ), ], @@ -188,6 +199,90 @@ void main() { expect(find.text('鲜肉烧麦'), findsOneWidget); expect(find.text('实际扣款'), findsOneWidget); }); + + testWidgets('完整详情展示打印和识别凭证并提供兑换券重打入口', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var requestedReprint = false; + + await tester.pumpWidget( + _terminalApp( + Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: XfjDesignTokens.canvas), + VerificationOrderDetailDrawer( + record: _record(), + status: OrderRecordDetailLoadStatus.success, + order: _order(), + items: _items(), + errorMessage: '', + printStatus: OrderRecordDetailLoadStatus.success, + printRecords: [ + ServicePrintRecord( + id: '100', + orderId: '2094302933091917826', + hardId: '10', + hardName: '一号食堂终端', + printerHardId: '20', + printerHardName: '一号打印机', + printType: '2', + printNo: 1, + retryVersion: 0, + attemptCount: 1, + status: '2', + printedAt: DateTime(2026, 8, 31, 16, 41, 12), + createTime: DateTime(2026, 8, 31, 16, 41, 9), + ), + ], + printErrorMessage: '', + evidenceStatus: OrderRecordDetailLoadStatus.success, + evidenceImages: [ + ServiceOrderEvidenceImage( + id: '200', + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceLive.code, + sequenceNo: 0, + status: 'UNAVAILABLE', + similarity: 0.9234, + hardId: '10', + capturedAt: DateTime(2026, 8, 31, 16, 40, 58), + failureReason: '终端现场图片已过保留期', + ), + ], + evidenceErrorMessage: '', + canReprintVoucher: true, + isReprintingVoucher: false, + onRetry: () {}, + onRefreshPrintRecords: () {}, + onRefreshEvidence: () {}, + onReprintVoucher: () => requestedReprint = true, + onClose: () {}, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('打印与核销凭证(1)')); + await tester.pumpAndSettle(); + expect(find.text('打印与核销凭证'), findsOneWidget); + expect(find.text('兑换券'), findsOneWidget); + expect(find.text('重新打印兑换券'), findsOneWidget); + await tester.tap(find.text('重新打印兑换券')); + expect(requestedReprint, isTrue); + + await tester.tap(find.text('识别凭证(1)')); + await tester.pumpAndSettle(); + expect(find.text('识别凭证'), findsOneWidget); + expect(find.text('现场识别图'), findsOneWidget); + expect(find.textContaining('92.34%'), findsOneWidget); + expect(find.textContaining('终端现场图片已过保留期'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } /// 跳过远程加载,让完整页面测试只验证根布局和既有静态数据渲染。 diff --git a/test/common/verification_record_models_test.dart b/test/common/verification_record_models_test.dart index 5a566f3..e636807 100644 --- a/test/common/verification_record_models_test.dart +++ b/test/common/verification_record_models_test.dart @@ -83,4 +83,48 @@ void main() { expect(car.totalPrice, 1238); expect(car.toJson()['totalPrice'], '1238'); }); + + test('服务端打印记录兼容历史空类型、序号和尝试次数字段', () { + final record = ServicePrintRecord.fromJson({ + 'id': 100, + 'orderId': '200', + 'status': null, + 'printType': null, + 'printNo': null, + 'retryVersion': null, + 'attemptCount': null, + }); + + expect(record.id, '100'); + expect(record.orderId, '200'); + expect(record.printType, '1'); + expect(record.printNo, 1); + expect(record.retryVersion, 0); + expect(record.attemptCount, 0); + expect(record.status, ''); + }); + + test('识别凭证解析服务端相似度、中文名称和采集设备字段', () { + final evidence = ServiceOrderEvidenceImage.fromJson({ + 'id': 300, + 'evidenceType': 'FACE_RECOGNITION', + 'evidenceTypeName': '人脸识别', + 'evidenceRole': 'FACE_LIVE', + 'evidenceRoleName': '现场图', + 'sequenceNo': 0, + 'status': 'AVAILABLE', + 'statusName': '已保存', + 'similarity': '0.9234', + 'similarityThreshold': 0.8, + 'hardId': 10, + 'capturedAt': '2026-08-31T16:40:58', + }); + + expect(evidence.id, '300'); + expect(evidence.evidenceRoleName, '现场图'); + expect(evidence.similarity, 0.9234); + expect(evidence.similarityThreshold, 0.8); + expect(evidence.hardId, '10'); + expect(evidence.capturedAt, DateTime(2026, 8, 31, 16, 40, 58)); + }); }