feat:核销打印兑换券

This commit is contained in:
dzc committed 2026-09-04 17:34:02 +08:00
1 parent fb90b477b9
commit d517f45f38
11 files changed
+1560 -75

No files matched your search

+61
View File
@@ -156,6 +156,23 @@ abstract class HardApi {
return res.data == true;
}
/// 一次提交多笔同一用户预约订单,服务端在同一事务内核销并合并生成一张兑换券。
static Future<bool> cancelReservationOrders({
required List<int> 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<ServiceVerificationRecordPage?> verificationRecordPage({
required int current,
@@ -303,6 +320,50 @@ abstract class HardApi {
return res.data != null;
}
/// 在线查询当前业务终端为指定订单生成的打印凭证,不读取终端本地打印记录。
static Future<List<ServicePrintRecord>> 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<String, dynamic>.from(item as Map),
))
.toList(growable: false);
}
/// 提交当前业务终端已打印兑换券的人工重打原因并取得新任务。
static Future<ServicePrintRecord?> 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<String, dynamic>.from(res.data as Map),
);
}
/// 打印成功
static Future<bool> printInvoice(dynamic formData) async {
final res = await HttpManage().dio.get(
+123
View File
@@ -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<String, dynamic> 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<String, dynamic> json) =>
_$ServicePrintRecordFromJson(json);
/// 转换为测试和诊断使用的 JSON。
Map<String, dynamic> toJson() => _$ServicePrintRecordToJson(this);
}
/// 按终端订单号对账返回的服务端订单和全部证据位置。
@JsonSerializable()
class ServiceOrderEvidenceResolve {
@@ -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<String, dynamic> _$ServiceOrderEvidenceImageToJson(
<String, dynamic>{
'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<String, dynamic> 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<String, dynamic> _$ServicePrintRecordToJson(ServicePrintRecord instance) =>
<String, dynamic>{
'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<String, dynamic> json) =>
ServiceOrderEvidenceResolve(
@@ -38,52 +38,39 @@ abstract interface class ReservationVerificationGateway {
});
}
/// 服务端核销接口只接收单个订单 ID,终端按操作员勾选顺序逐笔提交并汇总结果。
/// 服务端一次接收整批订单 ID;终端只提交一次请求,确保一个操作对应一张合并兑换券。
class TerminalReservationVerificationGateway
implements ReservationVerificationGateway {
/// 对所选订单执行单笔幂等核销;发生网络异常前已成功的订单会作为部分成功返回。
/// 对所选订单执行一次原子批量核销;服务端失败时整批回滚,避免出现多张兑换券或部分成功。
@override
Future<ReservationVerificationResult> cancel({
required List<int> orderIds,
String? faceEvidenceSessionId,
}) async {
final normalizedOrderIds = orderIds.toSet().toList(growable: false);
final succeededOrderIds = <int>[];
final failedOrderIds = <int>[];
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 : <int>[];
final failedOrderIds = canceled ? <int>[] : normalizedOrderIds;
if (canceled && faceEvidenceSessionId != null) {
await _markEvidenceReady(
recognitionSessionId: faceEvidenceSessionId,
orderId: normalizedOrderIds.first,
);
}
return ReservationVerificationResult(
succeededOrderIds: List.unmodifiable(succeededOrderIds),
@@ -46,6 +46,44 @@ class VerificationRecordsController extends GetxController {
/// 详情请求序号,用于丢弃关闭抽屉或快速切换后的旧响应。
int _selectedDetailRequestId = 0;
/// 完整详情中的服务端打印凭证记录,不写入终端本地数据库。
final selectedPrintRecords = <ServicePrintRecord>[].obs;
/// 服务端打印凭证记录的独立加载状态。
final selectedPrintStatus = OrderRecordDetailLoadStatus.success.obs;
/// 打印凭证查询失败时展示的可恢复错误信息。
final selectedPrintError = ''.obs;
/// 打印凭证请求序号,用于丢弃重复刷新时较早返回的响应。
int _selectedPrintRequestId = 0;
/// 完整详情中的服务端人脸识别凭证,只保留当前订单在线返回的数据。
final selectedEvidenceImages = <ServiceOrderEvidenceImage>[].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<ServiceVerificationRecord> 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 = <Future<void>>[
_loadSelectedPrintRecords(record),
_loadSelectedEvidence(record),
];
try {
final results = await Future.wait<Object?>([
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<void> refreshPrintRecords() async {
final record = selectedRecord.value;
if (record == null) return;
await _loadSelectedPrintRecords(record);
}
/// 单独刷新当前完整详情中的在线识别凭证。
Future<void> refreshEvidence() async {
final record = selectedRecord.value;
if (record == null) return;
await _loadSelectedEvidence(record);
}
/// 提交最新已打印兑换券的人工重打,并刷新服务端记录与本机打印待办。
Future<void> 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<void> _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<void> _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<void> _loadExpandedRecord(int orderId) async {
@@ -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<VerificationRecordsController> {
const VerificationRecordsPage({super.key});
@@ -57,7 +58,20 @@ class VerificationRecordsPage extends GetView<VerificationRecordsController> {
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<VerificationRecordsController> {
);
}
/// 收集人工原因后提交兑换券重打;取消弹框不会产生服务端任务。
Future<void> _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);
@@ -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<ServicePrintRecord> printRecords;
/// 打印凭证查询失败时展示的恢复提示。
final String printErrorMessage;
/// 服务端识别凭证的独立加载状态。
final OrderRecordDetailLoadStatus evidenceStatus;
/// 当前订单在线查询到的人脸识别凭证。
final List<ServiceOrderEvidenceImage> 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: <Widget>[
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<String> lines,
Widget? status,
int? errorLineIndex,
}) {
return SizedBox(
width: width,
child: <Widget>[
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),
<Widget>[
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<Widget> 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: <Widget>[
<Widget>[
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 <Widget>[
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 <Widget>[
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 <Widget>[
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: <Widget>[
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<void> _showImagePreview(
BuildContext context,
String url,
String title,
) {
final viewport = MediaQuery.sizeOf(context);
return showAppDialog<void>(
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(
@@ -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<String?> showVoucherReprintDialog(BuildContext context) {
return showAppDialog<String>(
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<FormState>();
/// 操作员输入的人工重打原因。
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),
],
);
}
}
+37 -37
View File
@@ -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"
@@ -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);
});
}
/// 跳过远程加载,让完整页面测试只验证根布局和既有静态数据渲染。
@@ -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));
});
}