feat:核销打印兑换券
This commit is contained in:
1 parent
fb90b477b9
commit
d517f45f38
11 files changed
+1560
-75
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
+795
@@ -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
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user