fix:线下购买下单问题
fix:打印机突然不打印的问题
This commit is contained in:
1 parent
4c76840ae1
commit
7857011b92
15 files changed
+1285
-220
No files matched your search
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
|
||||
## flutter打包
|
||||
|
||||
```
|
||||
flutter build apk --build-name 1.5.8 --build-number 158
|
||||
flutter build apk --build-name 1.5.9 --build-number 159
|
||||
--build-name为版本号(展示)
|
||||
--build-number为版本号(内部比较)
|
||||
```
|
||||
|
||||
+29
-3
@@ -181,7 +181,7 @@ public class FaceServer {
|
||||
int searchId = faceEntity.getFaceId();
|
||||
FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId));
|
||||
try {
|
||||
FaceFeatureInfo oldFeature = getFaceFeature(searchId);
|
||||
FaceFeatureInfo oldFeature = getFaceFeature(searchId, true);
|
||||
if (oldEntity == null && oldFeature != null) {
|
||||
oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData());
|
||||
}
|
||||
@@ -193,6 +193,11 @@ public class FaceServer {
|
||||
faceRegisterInfoMap.put(searchId, faceEntity);
|
||||
return code;
|
||||
}
|
||||
if (code == ErrorInfo.MOK) {
|
||||
Log.e(TAG, "人脸特征注册后校验失败searchId:" + searchId);
|
||||
} else {
|
||||
Log.e(TAG, "注册人脸特征失败searchId:" + searchId + ";code:" + code);
|
||||
}
|
||||
restoreFaceFeature(searchId, oldEntity);
|
||||
return code == ErrorInfo.MOK ? -99 : code;
|
||||
} catch (Exception exception) {
|
||||
@@ -257,13 +262,34 @@ public class FaceServer {
|
||||
return replaceFaceFeature(faceEntity);
|
||||
}
|
||||
|
||||
// 从人脸数据库中搜索人脸
|
||||
/**
|
||||
* 从虹软内存库读取人脸特征,保留记录不存在及其他查询异常的错误日志。
|
||||
*
|
||||
* @param searchId 虹软 32 位搜索标识,调用前须完成引擎初始化
|
||||
* @return 查到的特征;SDK 返回空或抛出参数异常时返回空,供调用方判定失败
|
||||
*/
|
||||
public synchronized FaceFeatureInfo getFaceFeature(int searchId) {
|
||||
return getFaceFeature(searchId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按查询用途处理记录不存在的日志,其他异常仍沿用原有日志和返回语义。
|
||||
*
|
||||
* @param searchId 虹软 32 位搜索标识,调用方须持有当前实例锁并保证引擎已初始化
|
||||
* @param allowMissing 仅写入前查询可选旧特征时为 true,注册后校验必须为 false
|
||||
* @return 查到的特征;SDK 返回空或抛出参数异常时返回空
|
||||
*/
|
||||
private FaceFeatureInfo getFaceFeature(int searchId, boolean allowMissing) {
|
||||
FaceFeatureInfo faceFeatureInfo = null;
|
||||
try {
|
||||
faceFeatureInfo = faceEngine.getFaceFeature(searchId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.e(TAG, "从人脸数据库中搜索人脸error:" + e.getMessage(), e);
|
||||
String message = e.getMessage();
|
||||
// SDK 通过“错误码:”前缀传递原因;限定完整错误码,避免吞掉其他查询异常。
|
||||
boolean missing = message != null && message.startsWith(ErrorInfo.MERR_ASF_SEARCH_NO_EXIST + ":");
|
||||
if (!allowMissing || !missing) {
|
||||
Log.e(TAG, "从人脸数据库中搜索人脸error:" + message, e);
|
||||
}
|
||||
}
|
||||
return faceFeatureInfo;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ class PrintManage extends BaseFacilityManage {
|
||||
/// 打开目标 USB 打印机并装载 ESC/POS 能力;失败只更新设备状态,不改变订单结果。
|
||||
@override
|
||||
Future<bool> open() async {
|
||||
// 上一次连接可能在授权、claim 或拔线期间失败;重新发现前先释放残留句柄。
|
||||
await close();
|
||||
final check = await super.open();
|
||||
if (!check) {
|
||||
AppDeviceStatusTool.updatePrinter(false);
|
||||
@@ -106,11 +108,20 @@ class PrintManage extends BaseFacilityManage {
|
||||
try {
|
||||
lastError = null;
|
||||
final setting = await AppSettingTool.getSysSetting();
|
||||
final target = _findTarget(await USBPrinterManager.discover(), setting);
|
||||
final printers = await USBPrinterManager.discover();
|
||||
final target = _findTarget(printers, setting);
|
||||
if (target == null) {
|
||||
lastError = setting.printerUsbVendorId == null
|
||||
final reason = setting.printerUsbVendorId == null
|
||||
? '未发现可用 USB 打印机'
|
||||
: '未发现后台配置 VID/PID 对应的 USB 打印机';
|
||||
// 保留配置和实际枚举结果,才能区分设备掉线与后台绑定参数不匹配。
|
||||
final discovered = printers
|
||||
.map((printer) => '${printer.vendorId}/${printer.productId}'
|
||||
'@${printer.deviceId}')
|
||||
.join(',');
|
||||
lastError = '$reason;配置:${setting.printerUsbVendorId}/'
|
||||
'${setting.printerUsbProductId};已发现:[${discovered.isEmpty ? '无' : discovered}]';
|
||||
logger.w('|$_tag|USB 发现失败|$lastError|');
|
||||
AppDeviceStatusTool.updatePrinter(false);
|
||||
return false;
|
||||
}
|
||||
@@ -123,6 +134,7 @@ class PrintManage extends BaseFacilityManage {
|
||||
lastError = e.toString();
|
||||
AppDeviceStatusTool.updatePrinter(false);
|
||||
logger.e("|$_tag|打开打印机失败error|$e|");
|
||||
await close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -396,15 +408,18 @@ class PrintManage extends BaseFacilityManage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭当前 USB 连接并清除“打印中”状态;重复调用不报错。
|
||||
/// 释放并清空当前连接;拔线后的释放异常只记日志,不能中断其余打印任务。
|
||||
@override
|
||||
Future<void> close() async {
|
||||
AppDeviceStatusTool.updatePrinterWorking(false);
|
||||
if (ObjectUtil.isNotEmpty(manager)) {
|
||||
if (manager != null) {
|
||||
await manager?.disconnect();
|
||||
}
|
||||
manager = null;
|
||||
final current = manager;
|
||||
manager = null;
|
||||
if (current == null) return;
|
||||
try {
|
||||
await current.disconnect();
|
||||
} catch (error, stack) {
|
||||
// 不覆盖 lastError,原始发现/写入错误才是本次任务失败的依据。
|
||||
logger.w('|$_tag|释放 USB 连接失败,后续任务将重新连接|error:$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ abstract class SyncPrintTool {
|
||||
/// 是否已有 HTTP 待办补拉正在执行,防止 MQTT 重连和页面恢复重复拉取。
|
||||
static bool _isPulling = false;
|
||||
|
||||
/// 将 MQTT 与 HTTP 的并发入队串行化,避免唯一索引插入竞争。
|
||||
static Future<void> _enqueueTail = Future<void>.value();
|
||||
/// MQTT/HTTP 入队与即时/定时回报共用的协调器,生命周期与应用主屏一致。
|
||||
static final PrintTaskCoordinator _taskCoordinator = PrintTaskCoordinator();
|
||||
|
||||
/// 三次 USB 尝试前的等待时间:首次立即执行,后两次失败后分别等待 5 秒和 30 秒。
|
||||
///
|
||||
@@ -46,7 +46,7 @@ abstract class SyncPrintTool {
|
||||
int printNo = 1,
|
||||
int retryVersion = 0,
|
||||
}) async {
|
||||
final operation = _enqueueTail.then((_) => _addTask(
|
||||
await _taskCoordinator.mutate(() => _addTask(
|
||||
orderId: orderId,
|
||||
androidOrderId: androidOrderId,
|
||||
historyId: historyId,
|
||||
@@ -56,12 +56,6 @@ abstract class SyncPrintTool {
|
||||
printNo: printNo,
|
||||
retryVersion: retryVersion,
|
||||
));
|
||||
// 队尾吞掉上一项异常以保证后续任务仍可入队,当前调用者仍等待原 operation 获得真实结果。
|
||||
_enqueueTail = operation.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace __) {},
|
||||
);
|
||||
await operation;
|
||||
}
|
||||
|
||||
/// 在串行入队区内执行任务新增或失败版本更新。
|
||||
@@ -242,36 +236,43 @@ abstract class SyncPrintTool {
|
||||
}
|
||||
task.attemptCount = index + 1;
|
||||
await dao.edit(task);
|
||||
var opened = false;
|
||||
var accepted = false;
|
||||
try {
|
||||
opened = await app.printManage.open();
|
||||
final opened = await app.printManage.open();
|
||||
if (!opened) {
|
||||
throw StateError(app.printManage.lastError ?? 'USB 打印机连接失败');
|
||||
}
|
||||
// 固定图片栅格打印:指令打印在真机版式与走纸兼容性不达标,产品决定暂不启用;
|
||||
// 任务携带的 printCmd 仅落库留存,不参与出纸。
|
||||
final accepted = await app.printManage.printImg(task.invoiceUrl);
|
||||
accepted = await app.printManage.printImg(task.invoiceUrl);
|
||||
if (!accepted) {
|
||||
throw StateError(app.printManage.lastError ?? 'USB 未完整接收打印数据');
|
||||
}
|
||||
} catch (error, stack) {
|
||||
task.lastError = _errorText(error);
|
||||
logger.w(
|
||||
'|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|');
|
||||
} finally {
|
||||
// open 返回失败也可能已经创建了部分连接;清理失败不能终止整个打印队列。
|
||||
try {
|
||||
await app.printManage.close();
|
||||
} catch (error, stack) {
|
||||
logger.w(
|
||||
'|$_tag|USB 连接清理异常|task:${task.servicePrintId}|error:$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
if (accepted) {
|
||||
task
|
||||
..status = PrintStatus.SUCCESS
|
||||
..printTime = DateUtil.formatDate(DateTime.now())
|
||||
..lastError = null
|
||||
..resultPending = true;
|
||||
// USB 已接收数据后,持久化或回报异常不能进入 USB 重试分支再次出纸。
|
||||
await dao.edit(task);
|
||||
await _report(task);
|
||||
return;
|
||||
} catch (error, stack) {
|
||||
task.lastError = _errorText(error);
|
||||
await dao.edit(task);
|
||||
logger.w(
|
||||
'|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|');
|
||||
} finally {
|
||||
if (opened) {
|
||||
await app.printManage.close();
|
||||
}
|
||||
}
|
||||
await dao.edit(task);
|
||||
}
|
||||
task
|
||||
..status = PrintStatus.ERROR
|
||||
@@ -282,33 +283,25 @@ abstract class SyncPrintTool {
|
||||
|
||||
/// 向服务端提交当前重试版本的最终结果;失败时保留 resultPending 等待网络恢复补报。
|
||||
static Future<void> _report(PrintHistoryEntity task) async {
|
||||
if (!AppNetworkTool.isAutoNetwork()) {
|
||||
return;
|
||||
}
|
||||
if (!AppNetworkTool.isAutoNetwork()) return;
|
||||
final success =
|
||||
task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC;
|
||||
try {
|
||||
final accepted = await HardApi.reportPrinterResult(
|
||||
taskId: task.servicePrintId,
|
||||
orderId: task.serviceOrderId,
|
||||
retryVersion: task.retryVersion,
|
||||
success: success,
|
||||
attemptCount: task.attemptCount,
|
||||
errorMessage: success ? null : task.lastError,
|
||||
await _taskCoordinator.report(
|
||||
task,
|
||||
dao: Get.find<AppControl>().dataBase.getPrintHistoryDao,
|
||||
sendResult: (current) => HardApi.reportPrinterResult(
|
||||
taskId: current.servicePrintId,
|
||||
orderId: current.serviceOrderId,
|
||||
retryVersion: current.retryVersion,
|
||||
success: success,
|
||||
attemptCount: current.attemptCount,
|
||||
errorMessage: success ? null : current.lastError,
|
||||
),
|
||||
);
|
||||
if (!accepted) {
|
||||
throw StateError('服务端拒绝打印结果');
|
||||
}
|
||||
task
|
||||
..resultPending = false
|
||||
..syncTime = DateTime.now();
|
||||
if (success) {
|
||||
task.status = PrintStatus.SYNC;
|
||||
}
|
||||
await Get.find<AppControl>().dataBase.getPrintHistoryDao.edit(task);
|
||||
} catch (error, stack) {
|
||||
logger.w(
|
||||
'|$_tag|打印结果等待补报|task:${task.servicePrintId}|error:$error|stack:$stack|');
|
||||
'|$_tag|打印结果等待补报|task:${task.servicePrintId}|version:${task.retryVersion}|success:$success|attempt:${task.attemptCount}|error:$error|stack:$stack|');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,3 +311,68 @@ abstract class SyncPrintTool {
|
||||
return text.length <= 500 ? text : text.substring(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/// 协调打印任务的本地版本更新和结果回报,使用现有 DAO,不改变数据库与网络协议。
|
||||
class PrintTaskCoordinator {
|
||||
/// 本地读改写队尾;入队与回报确认必须共用,防止版本检查后被其他写入穿插。
|
||||
Future<void> _mutationTail = Future<void>.value();
|
||||
|
||||
/// 正在提交的“任务 ID:重试版本”;完成或异常后移除,允许后续补报。
|
||||
final Set<String> _reportsInFlight = <String>{};
|
||||
|
||||
/// 串行执行本地读改写;网络请求与 USB 等待不得放进此队列。
|
||||
Future<void> mutate(Future<void> Function() action) {
|
||||
final operation = _mutationTail.then((_) => action());
|
||||
// 当前调用保留异常,后续入队和回报确认仍可继续执行。
|
||||
_mutationTail = operation.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace __) {},
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
/// 回报尚未确认的当前版本;旧快照与并发重复回报跳过,拒绝或异常保留待补报状态。
|
||||
///
|
||||
/// [sendResult] 使用统一 API 返回服务端是否接受,不在此方法执行任何 USB 写入。
|
||||
Future<void> report(
|
||||
PrintHistoryEntity task, {
|
||||
required PrintHistoryDao dao,
|
||||
required Future<bool> Function(PrintHistoryEntity task) sendResult,
|
||||
}) async {
|
||||
final reportKey = '${task.servicePrintId}:${task.retryVersion}';
|
||||
if (!_reportsInFlight.add(reportKey)) return;
|
||||
final success =
|
||||
task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC;
|
||||
try {
|
||||
final current = await dao.findByServicePrintId(task.servicePrintId);
|
||||
// 定时扫描拿到的是快照;等待前面任务回报时,该记录可能已经重试或完成确认。
|
||||
if (current == null ||
|
||||
!current.resultPending ||
|
||||
current.retryVersion != task.retryVersion ||
|
||||
current.status != task.status) {
|
||||
return;
|
||||
}
|
||||
final accepted = await sendResult(current);
|
||||
if (!accepted) {
|
||||
throw StateError('服务端拒绝打印结果');
|
||||
}
|
||||
await mutate(() async {
|
||||
final latest = await dao.findByServicePrintId(task.servicePrintId);
|
||||
// 与新版本入队共用本地写入队列,确保检查版本到落库之间不会被插入一次重试。
|
||||
if (latest == null ||
|
||||
!latest.resultPending ||
|
||||
latest.retryVersion != task.retryVersion ||
|
||||
latest.status != task.status) {
|
||||
return;
|
||||
}
|
||||
latest
|
||||
..resultPending = false
|
||||
..syncTime = DateTime.now();
|
||||
if (success) latest.status = PrintStatus.SYNC;
|
||||
await dao.edit(latest);
|
||||
});
|
||||
} finally {
|
||||
_reportsInFlight.remove(reportKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ class CashierWorkspace extends StatelessWidget {
|
||||
required this.onClearCart,
|
||||
required this.onPay,
|
||||
required this.onCancel,
|
||||
this.checkoutInProgress = false,
|
||||
this.searchController,
|
||||
this.searchFocusNode,
|
||||
this.searchHint = '搜索商品、自编码或扫码',
|
||||
@@ -135,6 +136,9 @@ class CashierWorkspace extends StatelessWidget {
|
||||
final ValueChanged<ShopCardEntity> onRemoveCartItem;
|
||||
final VoidCallback onClearCart;
|
||||
final VoidCallback onPay;
|
||||
|
||||
/// 本次结算准备或弹框仍在进行时禁用主支付入口,由业务 Controller 持有状态。
|
||||
final bool checkoutInProgress;
|
||||
final VoidCallback onCancel;
|
||||
final TextEditingController? searchController;
|
||||
final FocusNode? searchFocusNode;
|
||||
@@ -217,6 +221,7 @@ class CashierWorkspace extends StatelessWidget {
|
||||
onWeight: onWeight,
|
||||
onClear: onClearCart,
|
||||
onPay: onPay,
|
||||
checkoutInProgress: checkoutInProgress,
|
||||
onCancel: onCancel,
|
||||
onHold: onHold,
|
||||
onMore: onMore,
|
||||
@@ -956,7 +961,9 @@ class CashierPagination extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 收银购物车及支付操作区,结算期间展示业务锁并禁用重复支付。
|
||||
class CashierCartPanel extends StatelessWidget {
|
||||
/// 创建购物车展示;结算锁由所属业务页面响应式传入。
|
||||
const CashierCartPanel({
|
||||
super.key,
|
||||
required this.title,
|
||||
@@ -970,6 +977,7 @@ class CashierCartPanel extends StatelessWidget {
|
||||
required this.onClear,
|
||||
required this.onPay,
|
||||
required this.onCancel,
|
||||
this.checkoutInProgress = false,
|
||||
this.onWeight,
|
||||
this.onHold,
|
||||
this.onMore,
|
||||
@@ -988,12 +996,16 @@ class CashierCartPanel extends StatelessWidget {
|
||||
final ValueChanged<ShopCardEntity>? onWeight;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onPay;
|
||||
|
||||
/// 结算开始即展示进行中状态,防止本地建单尚未完成时按钮仍可重复点击。
|
||||
final bool checkoutInProgress;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback? onHold;
|
||||
final VoidCallback? onMore;
|
||||
final bool showHoldAction;
|
||||
final bool showMoreAction;
|
||||
|
||||
/// 按购物车金额和结算状态构建操作区,空购物车或已有结算时禁止支付。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
@@ -1215,9 +1227,11 @@ class CashierCartPanel extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CashierButton(
|
||||
label: '立即支付 ${_moneyText(payable)}',
|
||||
// 没有有效数量或重量时使用真实禁用语义,避免零数量条目进入支付流程。
|
||||
onTap: hasPayableItem ? onPay : null,
|
||||
label: checkoutInProgress
|
||||
? '正在结算...'
|
||||
: '立即支付 ${_moneyText(payable)}',
|
||||
// 状态在所属页面的 Obx 中读取,首次异步准备期间也立即反馈并禁用。
|
||||
onTap: hasPayableItem && !checkoutInProgress ? onPay : null,
|
||||
height: 56.h,
|
||||
textStyle: theme.typography.lg.copyWith(
|
||||
color: Colors.white,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 收银购物车与订单共用流程,统一结算互斥、本地订单及支付后的双屏和打印协调。
|
||||
mixin OrderMixin {
|
||||
static const _tag = "OrderMixin";
|
||||
|
||||
@@ -72,6 +73,9 @@ mixin OrderMixin {
|
||||
// 本地订单id
|
||||
int? sqlOrderId;
|
||||
|
||||
/// 当前购物车正在结算;从首次异步准备前锁定,直到弹框关闭或扫码支付收尾才释放。
|
||||
final isCheckoutInProgress = false.obs;
|
||||
|
||||
/// 最近一次远程下单明确返回的业务错误;网络异常或尚未下单时为空。
|
||||
String? lastCreateOrderBusinessMessage;
|
||||
|
||||
@@ -182,6 +186,22 @@ mixin OrderMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// 串行处理同一购物车的结算入口,防止连点或扫码枪并发覆盖当前本地订单号。
|
||||
///
|
||||
/// 必须在读取快照、生成订单号之前调用;拒绝排队重复操作,失败和取消后允许新交易。
|
||||
Future<void> runCheckout(Future<void> Function() checkout) async {
|
||||
if (isCheckoutInProgress.value) return;
|
||||
isCheckoutInProgress.value = true;
|
||||
sqlOrderId = null;
|
||||
try {
|
||||
await checkout();
|
||||
} finally {
|
||||
// 只清理内存中的交易引用,历史订单仍保留原状态,禁止下一次失败建单沿用旧 ID。
|
||||
sqlOrderId = null;
|
||||
isCheckoutInProgress.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建本地订单
|
||||
Future<int?> createSqlOrder() async {
|
||||
final orderSourceTypeId = getOrderSourceTypeId();
|
||||
|
||||
@@ -93,6 +93,12 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
/// 支付提交正在执行时为 true,用于禁用重复提交和弹框关闭。
|
||||
final isSubmitting = false.obs;
|
||||
|
||||
/// 取消或切换正在释放识别资源时锁定操作,避免异步清理期间再次启动或重复关闭路由。
|
||||
final isClosing = false.obs;
|
||||
|
||||
/// 当前控制器是否已持有结算弹框路由;路由完全退出前禁止重入初始化。
|
||||
bool _dialogOpen = false;
|
||||
|
||||
/// 当前嵌入收银工作区的人脸会话;为空时不渲染摄像头面板。
|
||||
final faceSessionHandle = Rxn<FaceRecognitionSessionHandle>();
|
||||
|
||||
@@ -129,6 +135,9 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
/// 饭卡读取请求序号;取消、关闭或再次发起后,旧异步回调不得替换当前人员。
|
||||
int _walletCardDispatchRequestId = 0;
|
||||
|
||||
/// 聚合码创建及轮询序号;取消或关闭后,旧请求不得展示二维码或完成新交易。
|
||||
int _qrCodeScanDispatchRequestId = 0;
|
||||
|
||||
int? _payableAmount;
|
||||
int? _totalAmount;
|
||||
int? _discountAmount;
|
||||
@@ -163,7 +172,11 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
/// 支付卡片由自定义 InkWell 绘制,点击必须直接更新业务 Rx,不能只依赖未挂载的
|
||||
/// Forui 单选组件刷新,否则 Controller 已收到选择但弹框仍显示旧选中态。
|
||||
void selectPayDisplayType(OrderPayDisplayType payType) {
|
||||
if (orderPayDisplayType.value == payType || isSubmitting.value) return;
|
||||
if (orderPayDisplayType.value == payType ||
|
||||
isSubmitting.value ||
|
||||
isClosing.value) {
|
||||
return;
|
||||
}
|
||||
if (isRecognition.value) {
|
||||
pendingPayDisplayType.value = payType;
|
||||
closeRecognitionConfirmationVisible.value = false;
|
||||
@@ -208,6 +221,9 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
/// 释放支付方式选择器与可能仍在运行的人脸会话。
|
||||
@override
|
||||
void onClose() {
|
||||
_faceDispatchRequestId++;
|
||||
_walletCardDispatchRequestId++;
|
||||
_qrCodeScanDispatchRequestId++;
|
||||
unawaited(_cancelPendingEvidence());
|
||||
unawaited(_closeFaceSession(cancel: true));
|
||||
super.onClose();
|
||||
@@ -253,6 +269,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
void _closeStatus() {
|
||||
_faceDispatchRequestId++;
|
||||
_walletCardDispatchRequestId++;
|
||||
_qrCodeScanDispatchRequestId++;
|
||||
payUser.value = null;
|
||||
userWalletList.value = [];
|
||||
currentWallet.value = null;
|
||||
@@ -280,8 +297,9 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
|
||||
// |开始识别|点击
|
||||
/// 只在空闲状态启动当前识别方式,拦截按钮重建前的连点及支付提交期间的直接调用。
|
||||
Future<void> doRecognition() async {
|
||||
if (isRecognition.value || isSubmitting.value || isClosing.value) return;
|
||||
switch (orderPayDisplayType.value) {
|
||||
case OrderPayDisplayType.qrCode:
|
||||
await _dispatchQrCode();
|
||||
@@ -302,7 +320,12 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
|
||||
/// 防重复提交当前支付信息,并仅在业务回调确认成功后关闭本次结算弹框。
|
||||
Future<void> doSubmit() async {
|
||||
if (payUser.value == null || isSubmitting.value) return;
|
||||
if (payUser.value == null ||
|
||||
isSubmitting.value ||
|
||||
isRecognition.value ||
|
||||
isClosing.value) {
|
||||
return;
|
||||
}
|
||||
if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) {
|
||||
await doRecognition();
|
||||
return;
|
||||
@@ -334,6 +357,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
|
||||
/// 请求关闭支付弹框;识别进行中时改为展示行内确认区域。
|
||||
Future<void> doCancel() async {
|
||||
if (isClosing.value) return;
|
||||
if (isSubmitting.value) {
|
||||
await AppRuntimeTool.vibrate(durationMs: 60);
|
||||
return;
|
||||
@@ -343,10 +367,15 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
closeRecognitionConfirmationVisible.value = true;
|
||||
return;
|
||||
}
|
||||
await _cancelPendingEvidence();
|
||||
_closeStatus();
|
||||
onCancel?.call();
|
||||
Navigator.of(Get.context!).pop();
|
||||
isClosing.value = true;
|
||||
try {
|
||||
await _cancelPendingEvidence();
|
||||
_closeStatus();
|
||||
onCancel?.call();
|
||||
Navigator.of(Get.context!).pop();
|
||||
} finally {
|
||||
isClosing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭支付弹框前取消尚未绑定订单的本次人脸证据,避免把身份核验图片留在本地队列。
|
||||
@@ -354,7 +383,9 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
final sessionId = faceRecognitionResult.value?.sessionId;
|
||||
if (sessionId == null || sessionId.isEmpty) return;
|
||||
await OrderEvidenceUploadTool.cancelSession(sessionId);
|
||||
faceRecognitionResult.value = null;
|
||||
if (faceRecognitionResult.value?.sessionId == sessionId) {
|
||||
faceRecognitionResult.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消关闭或切换请求,继续当前识别会话。
|
||||
@@ -365,22 +396,32 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
|
||||
/// 确认取消当前识别并关闭整个支付弹框。
|
||||
Future<void> confirmRecognitionAndClose() async {
|
||||
if (isSubmitting.value) return;
|
||||
await _stopCurrentRecognition();
|
||||
_closeStatus();
|
||||
onCancel?.call();
|
||||
if (Get.context != null) Navigator.of(Get.context!).pop();
|
||||
if (isSubmitting.value || isClosing.value) return;
|
||||
isClosing.value = true;
|
||||
try {
|
||||
await _stopCurrentRecognition();
|
||||
_closeStatus();
|
||||
onCancel?.call();
|
||||
if (Get.context != null) Navigator.of(Get.context!).pop();
|
||||
} finally {
|
||||
isClosing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认取消当前识别并切换到用户刚选择的支付方式。
|
||||
Future<void> confirmRecognitionAndSwitch() async {
|
||||
final pending = pendingPayDisplayType.value;
|
||||
if (pending == null || isSubmitting.value) return;
|
||||
await _stopCurrentRecognition();
|
||||
closeRecognitionConfirmationVisible.value = false;
|
||||
pendingPayDisplayType.value = null;
|
||||
_applyPayDisplayType(pending);
|
||||
payTypeRadioC.value = {pending};
|
||||
if (pending == null || isSubmitting.value || isClosing.value) return;
|
||||
isClosing.value = true;
|
||||
try {
|
||||
await _stopCurrentRecognition();
|
||||
closeRecognitionConfirmationVisible.value = false;
|
||||
pendingPayDisplayType.value = null;
|
||||
_applyPayDisplayType(pending);
|
||||
payTypeRadioC.value = {pending};
|
||||
} finally {
|
||||
isClosing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前支付方式停止硬件、轮询或人脸会话,供切换和关闭流程共用。
|
||||
@@ -399,11 +440,14 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
}
|
||||
if (activeRecognitionType == OrderPayDisplayType.face) {
|
||||
_faceDispatchRequestId++;
|
||||
_faceCallbackLock = false;
|
||||
faceUserLoading.value = false;
|
||||
await _cancelPendingEvidence();
|
||||
await _closeFaceSession(cancel: true);
|
||||
} else if (activeRecognitionType == OrderPayDisplayType.qrCode) {
|
||||
BarcodeScanManage.instance.dispose();
|
||||
} else if (activeRecognitionType == OrderPayDisplayType.qrCodeScan) {
|
||||
_qrCodeScanDispatchRequestId++;
|
||||
SubScreenPlugin.sendMsgToViceScreen(
|
||||
SubScreenAction.aggregateOrderPayCancel.name,
|
||||
);
|
||||
@@ -506,6 +550,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
Future<void> reidentifyCurrentUser() async {
|
||||
if (!canReidentifyCurrentPayType ||
|
||||
isSubmitting.value ||
|
||||
isClosing.value ||
|
||||
isRecognition.value) {
|
||||
return;
|
||||
}
|
||||
@@ -725,20 +770,28 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
FaceStream event,
|
||||
String sessionId,
|
||||
) async {
|
||||
if (_faceCallbackLock || faceSessionHandle.value?.sessionId != sessionId) {
|
||||
if (_faceCallbackLock ||
|
||||
!isRecognition.value ||
|
||||
isSubmitting.value ||
|
||||
isClosing.value ||
|
||||
faceSessionHandle.value?.sessionId != sessionId) {
|
||||
return;
|
||||
}
|
||||
final requestId = _faceDispatchRequestId;
|
||||
_faceCallbackLock = true;
|
||||
faceSessionHandle.value = null;
|
||||
faceWorkspaceMessage.value = '识别成功,正在读取人员与钱包信息';
|
||||
faceUserLoading.value = true;
|
||||
try {
|
||||
await _faceDiscernAfter(event);
|
||||
await _faceDiscernAfter(event, requestId);
|
||||
} finally {
|
||||
faceUserLoading.value = false;
|
||||
isRecognition.value = false;
|
||||
isReidentifying.value = false;
|
||||
_faceCallbackLock = false;
|
||||
// 旧请求结束不能解开新一轮识别的锁,也不能清掉新弹框的加载状态。
|
||||
if (requestId == _faceDispatchRequestId) {
|
||||
faceUserLoading.value = false;
|
||||
isRecognition.value = false;
|
||||
isReidentifying.value = false;
|
||||
_faceCallbackLock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,8 +815,8 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
// android人脸识别成功回调
|
||||
Future<void> _faceDiscernAfter(FaceStream event) async {
|
||||
/// 查询本轮人脸对应人员、证据和钱包;每次异步返回后校验归属,取消后不得写回人员。
|
||||
Future<void> _faceDiscernAfter(FaceStream event, int requestId) async {
|
||||
final reidentifying = isReidentifying.value;
|
||||
String? createdEvidenceSessionId;
|
||||
try {
|
||||
@@ -772,6 +825,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
recognition: event,
|
||||
sysType: appSetting.sysType,
|
||||
);
|
||||
if (!_ownsFaceDispatch(requestId)) return;
|
||||
if (nextUser == null) {
|
||||
throw StateError('该人脸样本对应人员已失效,请重新同步或重新录入人脸');
|
||||
}
|
||||
@@ -784,6 +838,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
throw StateError('识别结果缺少人脸样本编号,请重新同步人脸');
|
||||
}
|
||||
final face = await app.dataBase.getFaceDao.findBySampleId(sampleId);
|
||||
if (!_ownsFaceDispatch(requestId)) return;
|
||||
if (face == null || face.userId != nextUser.userId) {
|
||||
throw StateError('本地人脸样本与识别人员不一致,请重新同步人脸');
|
||||
}
|
||||
@@ -793,6 +848,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
recognition: event,
|
||||
face: face,
|
||||
);
|
||||
if (!_ownsFaceDispatch(requestId)) return;
|
||||
|
||||
var nextWallets = <ServiceWallet>[];
|
||||
if (isNetwork) {
|
||||
@@ -810,6 +866,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
} else {
|
||||
logger.i("|_tag|人脸识别|无网络|,用户信息:|${nextUser.toJson()}|");
|
||||
}
|
||||
if (!_ownsFaceDispatch(requestId)) return;
|
||||
final nextWallet = _defaultWalletForUser(nextUser, nextWallets);
|
||||
if (nextWallet != null) {
|
||||
_applyWalletToUser(nextUser, nextWallet);
|
||||
@@ -826,6 +883,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
logger.w('|$_tag|旧人脸证据任务取消失败|errorType:${error.runtimeType}|');
|
||||
}
|
||||
}
|
||||
if (!_ownsFaceDispatch(requestId)) return;
|
||||
// 人员和钱包使用同一批识别结果一次替换,避免界面短暂混用新旧人员数据。
|
||||
userWalletList.assignAll(nextWallets);
|
||||
currentWallet.value = nextWallet;
|
||||
@@ -837,6 +895,15 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
onDiscernUser?.call(nextUser);
|
||||
faceWorkspaceMessage.value = '';
|
||||
} catch (e, task) {
|
||||
if (!_ownsFaceDispatch(requestId)) return;
|
||||
logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|");
|
||||
// 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。
|
||||
if (!reidentifying) payUser.value = null;
|
||||
final message = e.toString().replaceFirst('Bad state: ', '').trim();
|
||||
faceWorkspaceMessage.value =
|
||||
message.isEmpty ? '人员或钱包信息读取失败,请重新识别' : message;
|
||||
} finally {
|
||||
// 包含取消后的提前返回;已写入但未被当前有效交易接纳的证据必须回收。
|
||||
if (createdEvidenceSessionId != null) {
|
||||
try {
|
||||
await OrderEvidenceUploadTool.cancelSession(
|
||||
@@ -849,12 +916,6 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.e("|$_tag|人脸识别,意外的错误error:|$e|task:|$task|");
|
||||
// 重新识别失败时继续保留原人员,避免一次设备或网络异常破坏可支付状态。
|
||||
if (!reidentifying) payUser.value = null;
|
||||
final message = e.toString().replaceFirst('Bad state: ', '').trim();
|
||||
faceWorkspaceMessage.value =
|
||||
message.isEmpty ? '人员或钱包信息读取失败,请重新识别' : message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,12 +946,20 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
// **************************************************************************
|
||||
/// 创建并展示聚合订单码,轮询到服务端终态后再通知收银业务完成本地订单。
|
||||
Future<void> _dispatchQrCodeScan() async {
|
||||
if (isRecognition.value) return;
|
||||
final requestId = ++_qrCodeScanDispatchRequestId;
|
||||
// 必须在网络建单前上锁,不能等二维码返回后才禁用发起按钮。
|
||||
isRecognition.value = true;
|
||||
// 建单尚未返回时不能取消或改付款方式,避免订单请求与当前购物车状态脱离。
|
||||
isSubmitting.value = true;
|
||||
var aggregateQrCodeDisplayed = false;
|
||||
var paymentConfirmed = false;
|
||||
try {
|
||||
// 收银业务先创建 Hardware 订单,Thirdparty 聚合会话必须与该业务订单持久化绑定。
|
||||
await onQrCodeScanRecognition?.call(
|
||||
(orderRes) async {
|
||||
if (!_ownsQrCodeScanDispatch(requestId)) return;
|
||||
isSubmitting.value = false;
|
||||
logger.i(
|
||||
"|$_tag|聚合订单码创建成功,orderId:|${orderRes.orderId}|aggregateNo:|${orderRes.aggregateNo}|",
|
||||
);
|
||||
@@ -911,9 +980,8 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
params: qrCodeScanEvent.toJson(),
|
||||
);
|
||||
aggregateQrCodeDisplayed = true;
|
||||
isRecognition.value = true;
|
||||
final status = await _queryAggregateOrderPayStatus(orderRes);
|
||||
if (!isRecognition.value) return;
|
||||
final status = await _queryAggregateOrderPayStatus(orderRes, requestId);
|
||||
if (!_ownsQrCodeScanDispatch(requestId)) return;
|
||||
if (status == null || !status.paySuccess) {
|
||||
isRecognition.value = false;
|
||||
SubScreenPlugin.sendMsgToViceScreen(
|
||||
@@ -923,6 +991,8 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
return;
|
||||
}
|
||||
paymentConfirmed = true;
|
||||
// 已确认扣款后的本地保存与清空购物车不能被取消或切换打断。
|
||||
isSubmitting.value = true;
|
||||
SubScreenPlugin.sendMsgToViceScreen(
|
||||
SubScreenAction.aggregateOrderPaySuccess.name,
|
||||
);
|
||||
@@ -933,6 +1003,7 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
},
|
||||
);
|
||||
} catch (error, stack) {
|
||||
if (requestId != _qrCodeScanDispatchRequestId) return;
|
||||
logger.e("|$_tag|二维码扫一扫,意外的错误error:|$error|task:|$stack|");
|
||||
isRecognition.value = false;
|
||||
if (paymentConfirmed) {
|
||||
@@ -950,9 +1021,19 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
: '二维码扫一扫错误,请重试',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (requestId == _qrCodeScanDispatchRequestId) {
|
||||
isRecognition.value = false;
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断聚合码创建和轮询是否仍归属于当前未取消的交易。
|
||||
bool _ownsQrCodeScanDispatch(int requestId) {
|
||||
return requestId == _qrCodeScanDispatchRequestId && isRecognition.value;
|
||||
}
|
||||
|
||||
/// 校验聚合码只能承载独立 HTTPS 收银路径,禁止混入钱包码或平台原生码内容。
|
||||
bool _isAggregateCashierUrl(String value) {
|
||||
final uri = Uri.tryParse(value.trim());
|
||||
@@ -965,23 +1046,26 @@ class SelectPayTypeDialogController extends GetxController {
|
||||
/// 在服务端有效期内轮询聚合支付状态,短暂网络失败不会改变支付结论。
|
||||
Future<ServiceHardAggregateOrderCodePayStatus?> _queryAggregateOrderPayStatus(
|
||||
ServiceHardAggregateOrderCodePay order,
|
||||
int requestId,
|
||||
) async {
|
||||
final deadline = order.expireTime.add(const Duration(seconds: 10));
|
||||
var consecutiveErrors = 0;
|
||||
while (!DateTime.now().isAfter(deadline)) {
|
||||
if (!isRecognition.value) {
|
||||
if (!_ownsQrCodeScanDispatch(requestId)) {
|
||||
logger.i(
|
||||
"|$_tag|聚合支付查询已由收银员取消,orderId:|${order.orderId}|",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!_ownsQrCodeScanDispatch(requestId)) return null;
|
||||
ServiceHardAggregateOrderCodePayStatus? status;
|
||||
try {
|
||||
status = await HardApi.getAggregateOrderCodePayStatus(
|
||||
orderId: order.orderId,
|
||||
aggregateNo: order.aggregateNo,
|
||||
);
|
||||
if (!_ownsQrCodeScanDispatch(requestId)) return null;
|
||||
} catch (error) {
|
||||
consecutiveErrors++;
|
||||
logger.w(
|
||||
@@ -1136,13 +1220,15 @@ Future<void> selectPayTypeDialog({
|
||||
}) async {
|
||||
if (snapshot.isEmpty) return;
|
||||
final payTypeC = Get.find<SelectPayTypeDialogController>();
|
||||
final context = Get.context!;
|
||||
payTypeC.initPayType(snapshot: snapshot);
|
||||
if (payTypeC._dialogOpen) return;
|
||||
payTypeC._dialogOpen = true;
|
||||
final snapshotNotifier = ValueNotifier<CashierCheckoutSnapshot>(snapshot);
|
||||
final viewport = MediaQuery.sizeOf(context);
|
||||
final dialogWidth = viewport.width * 0.92;
|
||||
final dialogHeight = viewport.height * 0.90;
|
||||
try {
|
||||
final context = Get.context!;
|
||||
payTypeC.initPayType(snapshot: snapshot);
|
||||
final viewport = MediaQuery.sizeOf(context);
|
||||
final dialogWidth = viewport.width * 0.92;
|
||||
final dialogHeight = viewport.height * 0.90;
|
||||
await showAppDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -1191,6 +1277,7 @@ Future<void> selectPayTypeDialog({
|
||||
);
|
||||
} finally {
|
||||
snapshotNotifier.dispose();
|
||||
payTypeC._dialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3176,6 +3263,7 @@ class _CheckoutPaymentActions extends StatelessWidget {
|
||||
return Obx(() {
|
||||
final snapshotRevision = controller.checkoutSnapshotRevision.value;
|
||||
final submitting = controller.isSubmitting.value;
|
||||
final closing = controller.isClosing.value;
|
||||
final recognizing = controller.isRecognition.value;
|
||||
final hasUser = controller.payUser.value != null;
|
||||
final selectedType = controller.orderPayDisplayType.value;
|
||||
@@ -3194,13 +3282,13 @@ class _CheckoutPaymentActions extends StatelessWidget {
|
||||
child: <Widget>[
|
||||
_PaymentDialogButton(
|
||||
label: '取消',
|
||||
onTap: submitting ? null : controller.doCancel,
|
||||
onTap: submitting || closing ? null : controller.doCancel,
|
||||
soft: true,
|
||||
).expanded(),
|
||||
SizedBox(width: 12.w),
|
||||
_PaymentDialogButton(
|
||||
label: label,
|
||||
onTap: !paymentEnabled || recognizing || submitting
|
||||
onTap: !paymentEnabled || recognizing || submitting || closing
|
||||
? null
|
||||
: aggregateOrderPay
|
||||
? controller.doRecognition
|
||||
|
||||
@@ -241,21 +241,26 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
});
|
||||
}
|
||||
|
||||
// |下单|点击购买
|
||||
/// 锁定本次结算后创建本地订单;整个弹框生命周期只使用这一笔订单。
|
||||
Future<void> doPay() async {
|
||||
// 上一单的打印角标不能压在新订单上,新结算开始即收起。
|
||||
dismissPrintAwait();
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
final snapshot = await _createCheckoutSnapshot();
|
||||
// 创建本地订单
|
||||
await createSqlOrder();
|
||||
await selectPayTypeDialog(
|
||||
snapshot: snapshot,
|
||||
onQuantityChanged: _changeCheckoutQuantity,
|
||||
);
|
||||
// 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。
|
||||
unawaited(startPrintAwaitAfterCheckout());
|
||||
await runCheckout(() async {
|
||||
// 锁必须覆盖快照中的数据库查询,不能等弹框显示后才阻止第二次点击。
|
||||
dismissPrintAwait();
|
||||
if (!_validateShopCar()) return;
|
||||
final snapshot = await _createCheckoutSnapshot();
|
||||
if (snapshot.isEmpty) return;
|
||||
final orderId = await createSqlOrder();
|
||||
if (orderId == null) {
|
||||
Toast.error(msg: '本地订单创建失败,请重试');
|
||||
return;
|
||||
}
|
||||
await selectPayTypeDialog(
|
||||
snapshot: snapshot,
|
||||
onQuantityChanged: _changeCheckoutQuantity,
|
||||
);
|
||||
// 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。
|
||||
unawaited(startPrintAwaitAfterCheckout());
|
||||
});
|
||||
}
|
||||
|
||||
/// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额与库存的展示快照。
|
||||
@@ -455,6 +460,7 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
sendSubScreen();
|
||||
}
|
||||
|
||||
/// 绑定当前食堂购物车的识别与支付回调,本地订单写入成功后才允许远程下单。
|
||||
void _listenPayTypeSelectCallback() {
|
||||
final payTypeSelect = Get.find<SelectPayTypeDialogController>();
|
||||
// |支付方式|点击|开始识别|
|
||||
@@ -507,18 +513,23 @@ class CanteenProductController extends GetxController with OrderMixin {
|
||||
status: '支付中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
// 最后计算一次额外的价格
|
||||
await _computedAmountMealPrice();
|
||||
await updateSqlOrder();
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
final orderResult = await createApiOrder();
|
||||
EasyLoading.dismiss();
|
||||
// 在线下单失败(包括服务端库存不足)时保留购物车,收银员可直接调整数量后重试;
|
||||
// 离线订单没有远程返回值,但本地订单已经进入离线待同步状态,仍需正常收尾。
|
||||
if (isNetwork && orderResult == null) return false;
|
||||
putOffSendSubScreen();
|
||||
await _clearShopCard();
|
||||
return true;
|
||||
try {
|
||||
// 最后计算一次额外的价格;本地更新失败不能继续扣款。
|
||||
await _computedAmountMealPrice();
|
||||
if (!await updateSqlOrder()) {
|
||||
Toast.error(msg: '本地订单保存失败,请重试');
|
||||
return false;
|
||||
}
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
final orderResult = await createApiOrder();
|
||||
// 在线失败保留购物车;离线订单由原有本地支付流程收尾。
|
||||
if (isNetwork && orderResult == null) return false;
|
||||
putOffSendSubScreen();
|
||||
await _clearShopCard();
|
||||
return true;
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
};
|
||||
// |支付方式|点击|取消|
|
||||
payTypeSelect.onCancel = () {};
|
||||
|
||||
@@ -86,6 +86,7 @@ class CanteenProductPage extends GetView<CanteenProductController> {
|
||||
},
|
||||
onClearCart: controller.doCancelPay,
|
||||
onPay: controller.doPay,
|
||||
checkoutInProgress: controller.isCheckoutInProgress.value,
|
||||
onCancel: controller.doCancelPay,
|
||||
showHoldAction: showHoldAction,
|
||||
showMoreAction: showMoreAction,
|
||||
|
||||
@@ -85,18 +85,22 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
}
|
||||
}
|
||||
|
||||
// |下单|点击购买
|
||||
/// 锁定本次结算后创建本地订单,与扫码枪支付共用同一交易锁。
|
||||
Future<void> doPay() async {
|
||||
// 上一单的打印角标不能压在新订单上,新结算开始即收起。
|
||||
dismissPrintAwait();
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
final snapshot = _createCheckoutSnapshot();
|
||||
// 创建本地订单
|
||||
await createSqlOrder();
|
||||
await selectPayTypeDialog(snapshot: snapshot);
|
||||
// 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。
|
||||
unawaited(startPrintAwaitAfterCheckout());
|
||||
await runCheckout(() async {
|
||||
dismissPrintAwait();
|
||||
if (!_validateShopCar()) return;
|
||||
final snapshot = _createCheckoutSnapshot();
|
||||
if (snapshot.isEmpty) return;
|
||||
final orderId = await createSqlOrder();
|
||||
if (orderId == null) {
|
||||
Toast.error(msg: '本地订单创建失败,请重试');
|
||||
return;
|
||||
}
|
||||
await selectPayTypeDialog(snapshot: snapshot);
|
||||
// 结算弹框已关闭,此时启动发票打印角标不会与弹框 pop 竞争导航栈。
|
||||
unawaited(startPrintAwaitAfterCheckout());
|
||||
});
|
||||
}
|
||||
|
||||
/// 在打开支付弹框前冻结当前商品、数量和整数分金额,避免背景状态影响本次结算展示。
|
||||
@@ -324,6 +328,7 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
// 支付的回调 start
|
||||
// **************************************************************************
|
||||
|
||||
/// 绑定超市识别与支付回调,本地订单写入成功后才允许远程下单。
|
||||
void _listenPayTypeSelectCallback() {
|
||||
final payTypeSelect = Get.find<SelectPayTypeDialogController>();
|
||||
// |支付方式|点击|开始识别|
|
||||
@@ -374,14 +379,20 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
status: '支付中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
await updateSqlOrder();
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
final orderResult = await createApiOrder();
|
||||
EasyLoading.dismiss();
|
||||
if (isNetwork && orderResult == null) return false;
|
||||
_clearShopCard();
|
||||
putOffSendSubScreen();
|
||||
return true;
|
||||
try {
|
||||
if (!await updateSqlOrder()) {
|
||||
Toast.error(msg: '本地订单保存失败,请重试');
|
||||
return false;
|
||||
}
|
||||
final isNetwork = AppNetworkTool.isAutoNetwork();
|
||||
final orderResult = await createApiOrder();
|
||||
if (isNetwork && orderResult == null) return false;
|
||||
await _clearShopCard();
|
||||
putOffSendSubScreen();
|
||||
return true;
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
};
|
||||
// |支付方式|点击|取消|
|
||||
payTypeSelect.onCancel = () {};
|
||||
@@ -529,52 +540,51 @@ class SupermarketOrderController extends GetxController with OrderMixin {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 处理扫码枪支付
|
||||
/// 扫码枪支付与结算弹框互斥,禁止硬件重复上报在同一购物车上另建订单。
|
||||
Future<void> handlePayQrCode(String event) async {
|
||||
OrderPayType orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY;
|
||||
|
||||
if (event.length == 18 && RegExp(r'^(2[5-9]|30)').hasMatch(event)) {
|
||||
// 支付宝支付
|
||||
orderPayType = OrderPayType.ALI_SELF_QR_CODE_PAY;
|
||||
} else if (event.length == 18 && event.startsWith("13")) {
|
||||
// 微信支付
|
||||
orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY;
|
||||
} else if (event.length == 32) {
|
||||
// 钱包二维码支付
|
||||
orderPayType = OrderPayType.WALLET_QR_CODE;
|
||||
}
|
||||
// 创建本地订单
|
||||
this.orderType = OrderType.BELOW;
|
||||
this.orderPayType.value = orderPayType;
|
||||
this.orderPayDisplayType.value = OrderPayDisplayType.qrCode;
|
||||
this.globalQrCode = ServiceQRCode(
|
||||
id: event,
|
||||
itemId: "-1",
|
||||
typeId: "-1",
|
||||
userId: "-1",
|
||||
orderType: OrderType.BELOW,
|
||||
payType: orderPayType,
|
||||
exceedTime: -1,
|
||||
extra: "-1",
|
||||
);
|
||||
payUser.value = AppUserTool.generateExternalUser();
|
||||
final validShopCar = _validateShopCar();
|
||||
if (!validShopCar) return;
|
||||
EasyLoading.show(
|
||||
status: '支付中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
try {
|
||||
await createSqlOrder();
|
||||
await updateSqlOrder();
|
||||
await createApiOrder();
|
||||
} catch (error, stack) {
|
||||
logger.e("|$_tag|,处理扫码枪支付失败|$error|stack|$stack|");
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
_clearShopCard();
|
||||
putOffSendSubScreen();
|
||||
}
|
||||
await runCheckout(() async {
|
||||
OrderPayType orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY;
|
||||
if (event.length == 18 && RegExp(r'^(2[5-9]|30)').hasMatch(event)) {
|
||||
orderPayType = OrderPayType.ALI_SELF_QR_CODE_PAY;
|
||||
} else if (event.length == 18 && event.startsWith("13")) {
|
||||
orderPayType = OrderPayType.WECHANT_SELF_QR_CODE_PAY;
|
||||
} else if (event.length == 32) {
|
||||
orderPayType = OrderPayType.WALLET_QR_CODE;
|
||||
}
|
||||
orderType = OrderType.BELOW;
|
||||
this.orderPayType.value = orderPayType;
|
||||
orderPayDisplayType.value = OrderPayDisplayType.qrCode;
|
||||
globalQrCode = ServiceQRCode(
|
||||
id: event,
|
||||
itemId: "-1",
|
||||
typeId: "-1",
|
||||
userId: "-1",
|
||||
orderType: OrderType.BELOW,
|
||||
payType: orderPayType,
|
||||
exceedTime: -1,
|
||||
extra: "-1",
|
||||
);
|
||||
payUser.value = AppUserTool.generateExternalUser();
|
||||
if (!_validateShopCar()) return;
|
||||
EasyLoading.show(
|
||||
status: '支付中...',
|
||||
maskType: EasyLoadingMaskType.black,
|
||||
);
|
||||
try {
|
||||
if (await createSqlOrder() == null || !await updateSqlOrder()) {
|
||||
Toast.error(msg: '本地订单保存失败,请重试');
|
||||
return;
|
||||
}
|
||||
await createApiOrder();
|
||||
} catch (error, stack) {
|
||||
logger.e("|$_tag|,处理扫码枪支付失败|$error|stack|$stack|");
|
||||
} finally {
|
||||
EasyLoading.dismiss();
|
||||
// 扫码枪沿用原有结束后清空购物车的语义,交易锁覆盖清理,避免重复硬件事件另建单。
|
||||
await _clearShopCard();
|
||||
putOffSendSubScreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
|
||||
@@ -16,6 +16,7 @@ class SupermarketOrderPage extends GetView<SupermarketOrderController> {
|
||||
final bool showHoldAction;
|
||||
final bool showMoreAction;
|
||||
|
||||
/// 构建超市收银工作区,并响应结算锁以即时禁用重复支付入口。
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<SupermarketOrderController>(
|
||||
@@ -60,6 +61,7 @@ class SupermarketOrderPage extends GetView<SupermarketOrderController> {
|
||||
),
|
||||
onClearCart: controller.doCancelPay,
|
||||
onPay: controller.doPay,
|
||||
checkoutInProgress: controller.isCheckoutInProgress.value,
|
||||
onCancel: controller.doCancelPay,
|
||||
onHold: controller.addHoldBill,
|
||||
onMore: controller.doMore,
|
||||
|
||||
+92
-28
@@ -12,9 +12,12 @@ import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** 项目内 USB 打印插件;只有 Bulk OUT 接受全部字节后才向 Dart 返回成功。 */
|
||||
class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
@@ -27,9 +30,15 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
/** Android USB Host 系统服务。 */
|
||||
private lateinit var usbManager: UsbManager
|
||||
|
||||
/** 以 VID-PID 为键复用已经 claim 的打印机连接。 */
|
||||
/** 以 VID-PID 为键缓存连接;仅 USB 工作线程访问,设备实例变化时必须替换。 */
|
||||
private val connections = mutableMapOf<String, UsbPrinterConnection>()
|
||||
|
||||
/** 串行执行连接、写入与释放,避免跨线程竞争缓存或在主线程等待 Bulk 写入。 */
|
||||
private var usbExecutor: ExecutorService? = null
|
||||
|
||||
/** 原生 USB 生命周期诊断标签,不记录打印内容。 */
|
||||
private val logTag = "DragoUsbPrinter"
|
||||
|
||||
/** 将后台写入结果切回主线程提交给 Flutter。 */
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
@@ -37,18 +46,21 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
usbExecutor = Executors.newSingleThreadExecutor()
|
||||
channel = MethodChannel(binding.binaryMessenger, "drago_usb_printer")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
/** 保持现有四类方法通道契约,将硬件访问统一交给串行工作线程。 */
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getUSBDeviceList" -> result.success(listPrinterDevices())
|
||||
"connect" -> runCatching { connectionFor(call).connect() }
|
||||
.fold(result::success) { result.error("USB_CONNECT", it.message, null) }
|
||||
"disconnect" -> {
|
||||
connections.remove(deviceKey(call))?.close()
|
||||
result.success(true)
|
||||
"getUSBDeviceList" -> runUsbOperation(result, "USB_DISCOVERY") { listPrinterDevices() }
|
||||
"connect" -> runUsbOperation(result, "USB_CONNECT") {
|
||||
withConnection(call) { it.connect() }
|
||||
}
|
||||
"disconnect" -> runUsbOperation(result, "USB_DISCONNECT") {
|
||||
closeConnection(deviceKey(call))
|
||||
true
|
||||
}
|
||||
"write" -> {
|
||||
val data = call.argument<ByteArray>("data")
|
||||
@@ -56,22 +68,54 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
result.error("USB_DATA", "Missing USB payload", null)
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
runCatching { connectionFor(call).writeFully(data) }
|
||||
.fold(
|
||||
{ mainHandler.post { result.success(true) } },
|
||||
{ error -> mainHandler.post { result.error("USB_WRITE", error.message, null) } },
|
||||
)
|
||||
}.start()
|
||||
runUsbOperation(result, "USB_WRITE") {
|
||||
withConnection(call) { it.writeFully(data) }
|
||||
true
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
/** 在单个工作线程完成硬件调用,结果仍回到 Flutter 要求的主线程。 */
|
||||
private fun runUsbOperation(result: MethodChannel.Result, errorCode: String, action: () -> Any?) {
|
||||
val executor = usbExecutor
|
||||
if (executor == null) {
|
||||
result.error(errorCode, "USB plugin is detached", null)
|
||||
return
|
||||
}
|
||||
executor.execute {
|
||||
runCatching(action).fold(
|
||||
{ value -> mainHandler.post { result.success(value) } },
|
||||
{ error -> mainHandler.post { result.error(errorCode, error.message, null) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接或写入失败立即淘汰句柄;下一次尝试重新 open/claim,不在原生层重发数据。 */
|
||||
private fun <T> withConnection(call: MethodCall, action: (UsbPrinterConnection) -> T): T {
|
||||
try {
|
||||
return action(connectionFor(call))
|
||||
} catch (error: Throwable) {
|
||||
closeConnection(deviceKey(call))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 先移除缓存再释放资源,释放异常不能留下可再次被取出的失效连接。 */
|
||||
private fun closeConnection(key: String) {
|
||||
val connection = connections.remove(key) ?: return
|
||||
runCatching { connection.close() }
|
||||
.onFailure { Log.w(logTag, "USB close failed: $key", it) }
|
||||
}
|
||||
|
||||
/** 返回至少包含一个 Printer Class 接口的 USB 设备摘要。 */
|
||||
private fun listPrinterDevices(): List<Map<String, String?>> = usbManager.deviceList.values
|
||||
.filter(::isPrinterDevice)
|
||||
.map { device ->
|
||||
private fun listPrinterDevices(): List<Map<String, String?>> {
|
||||
val devices = usbManager.deviceList.values
|
||||
// 后台发现也会经过这里;拔出后即便没有新写入,也不继续持有旧接口。
|
||||
connections.filterValues { cached -> devices.none(cached::matchesDevice) }
|
||||
.keys.toList().forEach(::closeConnection)
|
||||
return devices.filter(::isPrinterDevice).map { device ->
|
||||
mapOf(
|
||||
"deviceName" to device.deviceName,
|
||||
"manufacturer" to safeValue { device.manufacturerName },
|
||||
@@ -81,6 +125,7 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
"productId" to device.productId.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 VID/PID、设备类型和授权后取得可复用连接。 */
|
||||
private fun connectionFor(call: MethodCall): UsbPrinterConnection {
|
||||
@@ -93,7 +138,13 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
requestPermission(device)
|
||||
error("USB permission requested; retry after permission is granted")
|
||||
}
|
||||
return connections.getOrPut("$vendorId-$productId") {
|
||||
val key = "$vendorId-$productId"
|
||||
val cached = connections[key]
|
||||
if (cached != null && !cached.matchesDevice(device)) {
|
||||
Log.i(logTag, "USB device changed; reopening $key at ${device.deviceName}")
|
||||
closeConnection(key)
|
||||
}
|
||||
return connections.getOrPut(key) {
|
||||
UsbPrinterConnection(usbManager, device)
|
||||
}
|
||||
}
|
||||
@@ -123,11 +174,13 @@ class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
/** 兼容部分设备描述符读取异常,异常字段按空值返回。 */
|
||||
private fun safeValue(read: () -> String?): String? = runCatching(read).getOrNull()
|
||||
|
||||
/** 释放全部已 claim 的接口和连接,并注销方法通道。 */
|
||||
/** 停止接收新调用,在已排队操作结束后释放接口,避免与正在进行的写入并发关闭。 */
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
connections.values.forEach(UsbPrinterConnection::close)
|
||||
connections.clear()
|
||||
channel.setMethodCallHandler(null)
|
||||
val executor = usbExecutor
|
||||
usbExecutor = null
|
||||
executor?.execute { connections.keys.toList().forEach(::closeConnection) }
|
||||
executor?.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +201,11 @@ private class UsbPrinterConnection(
|
||||
/** 当前打印接口的 Bulk OUT 端点。 */
|
||||
private var bulkOut: UsbEndpoint? = null
|
||||
|
||||
/** VID/PID 相同不代表仍是同次 USB 插入;设备地址变化后旧句柄不能复用。 */
|
||||
fun matchesDevice(current: UsbDevice): Boolean =
|
||||
device.vendorId == current.vendorId && device.productId == current.productId &&
|
||||
device.deviceId == current.deviceId && device.deviceName == current.deviceName
|
||||
|
||||
/** 打开设备并 claim Printer Class 接口,缺少 Bulk OUT 时直接失败。 */
|
||||
@Synchronized
|
||||
fun connect(): Boolean {
|
||||
@@ -163,9 +221,11 @@ private class UsbPrinterConnection(
|
||||
it.direction == UsbConstants.USB_DIR_OUT
|
||||
} ?: error("USB Bulk OUT endpoint is missing")
|
||||
val opened = manager.openDevice(device) ?: error("Unable to open USB printer")
|
||||
if (!opened.claimInterface(targetInterface, true)) {
|
||||
try {
|
||||
check(opened.claimInterface(targetInterface, true)) { "Unable to claim USB printer interface" }
|
||||
} catch (error: Throwable) {
|
||||
opened.close()
|
||||
error("Unable to claim USB printer interface")
|
||||
throw error
|
||||
}
|
||||
connection = opened
|
||||
printerInterface = targetInterface
|
||||
@@ -173,6 +233,7 @@ private class UsbPrinterConnection(
|
||||
return true
|
||||
}
|
||||
|
||||
/** 循环处理部分写入;任意一段失败交由调用方废弃连接,禁止静默报告成功。 */
|
||||
@Synchronized
|
||||
fun writeFully(data: ByteArray): Int {
|
||||
connect()
|
||||
@@ -188,15 +249,18 @@ private class UsbPrinterConnection(
|
||||
return offset
|
||||
}
|
||||
|
||||
/** 即使拔线后的 releaseInterface 失败,也必须关闭设备并清空全部状态。 */
|
||||
@Synchronized
|
||||
fun close() {
|
||||
printerInterface?.let { connection?.releaseInterface(it) }
|
||||
connection?.close()
|
||||
val opened = connection
|
||||
val claimed = printerInterface
|
||||
connection = null
|
||||
printerInterface = null
|
||||
bulkOut = null
|
||||
try {
|
||||
if (claimed != null) opened?.releaseInterface(claimed)
|
||||
} finally {
|
||||
opened?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 分发设备发现、连接、关闭和完整写入四类原生调用。 */
|
||||
/** 循环处理 bulkTransfer 部分写入,并核对最终累计长度。 */
|
||||
/** 释放接口并关闭设备连接,重复调用保持幂等。 */
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pj_supermarket_cloud
|
||||
description: "资安信智慧后勤-测试"
|
||||
publish_to: "none"
|
||||
version: 1.5.8+158
|
||||
version: 1.5.9+159
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/terminal/canteen_product/controller.dart';
|
||||
import 'package:pj_supermarket_cloud/pages/terminal/supermarket_order/controller.dart';
|
||||
|
||||
/// 用可控制的异步边界验证结算重入,测试不访问真实订单接口、数据库或识别设备。
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
Get.testMode = true;
|
||||
|
||||
setUp(() {
|
||||
Get.put(AppControl())
|
||||
..serialCardManage = SerialCardManage()
|
||||
..isNetwork.value = true;
|
||||
Get.put<SelectPayTypeDialogController>(_PaymentController());
|
||||
});
|
||||
|
||||
tearDown(Get.reset);
|
||||
|
||||
testWidgets('食堂立即支付连点只创建一单,弹框关闭前持续锁定且不重置人员', (tester) async {
|
||||
final controller = _CanteenController();
|
||||
await _pumpCheckout(tester, controller);
|
||||
final button = find.textContaining('立即支付');
|
||||
// 故意不在两次点击间 pump,覆盖 UI 尚未刷新但事件已经再次到达的窗口。
|
||||
await tester.tap(button);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.createCount, 1);
|
||||
expect(controller.isCheckoutInProgress.value, isTrue);
|
||||
expect(find.text('正在结算...'), findsOneWidget);
|
||||
final payButton = find.ancestor(
|
||||
of: find.text('正在结算...'),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
expect(tester.widget<InkWell>(payButton).onTap, isNull);
|
||||
|
||||
controller.pendingOrder.complete(5144);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('收银结算'), findsOneWidget);
|
||||
expect(controller.sqlOrderId, 5144);
|
||||
await controller.doPay();
|
||||
expect(controller.createCount, 1);
|
||||
|
||||
final payment = Get.find<SelectPayTypeDialogController>();
|
||||
final user = AppUserTool.generateExternalUser();
|
||||
payment.payUser.value = user;
|
||||
await selectPayTypeDialog(snapshot: _snapshot());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('收银结算'), findsOneWidget);
|
||||
expect(payment.payUser.value, same(user));
|
||||
|
||||
await payment.doCancel();
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.isCheckoutInProgress.value, isFalse);
|
||||
expect(controller.sqlOrderId, isNull);
|
||||
expect(find.textContaining('立即支付'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('本地建单失败不打开弹框、不复用旧订单,重试可正常进入', (tester) async {
|
||||
final controller = _CanteenController()..sqlOrderId = 5143;
|
||||
await _pumpCheckout(tester, controller);
|
||||
final first = controller.doPay();
|
||||
await tester.pump();
|
||||
expect(controller.sqlOrderId, isNull);
|
||||
controller.pendingOrder.complete(null);
|
||||
await first;
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('收银结算'), findsNothing);
|
||||
expect(controller.isCheckoutInProgress.value, isFalse);
|
||||
expect(controller.shopCardList, hasLength(1));
|
||||
|
||||
controller.pendingOrder = Completer<int?>();
|
||||
final second = controller.doPay();
|
||||
await tester.pump();
|
||||
expect(controller.createCount, 2);
|
||||
controller.pendingOrder.complete(5145);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('收银结算'), findsOneWidget);
|
||||
await Get.find<SelectPayTypeDialogController>().doCancel();
|
||||
await tester.pumpAndSettle();
|
||||
await second;
|
||||
// 清除失败提示的定时退出动画,避免跨用例残留。
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
test('超市结算期间扫码枪不能替换订单或付款方式,异常后释放结算锁', () async {
|
||||
final controller = SupermarketOrderController();
|
||||
final pending = Completer<void>();
|
||||
final checkout = controller.runCheckout(() async {
|
||||
controller.sqlOrderId = 5144;
|
||||
await pending.future;
|
||||
});
|
||||
await controller.handlePayQrCode('130000000000000000');
|
||||
expect(controller.sqlOrderId, 5144);
|
||||
expect(controller.orderPayType.value, OrderPayType.FACE);
|
||||
expect(controller.globalQrCode, isNull);
|
||||
final failure = expectLater(checkout, throwsStateError);
|
||||
pending.completeError(StateError('模拟结算异常'));
|
||||
await failure;
|
||||
expect(controller.isCheckoutInProgress.value, isFalse);
|
||||
expect(controller.sqlOrderId, isNull);
|
||||
controller.selfCodeC.dispose();
|
||||
controller.focusNode.dispose();
|
||||
});
|
||||
|
||||
test('确认支付期间拒绝重复提交和识别,业务拒绝后允许同一交易重试', () async {
|
||||
final controller = Get.find<SelectPayTypeDialogController>();
|
||||
controller.payUser.value = AppUserTool.generateExternalUser();
|
||||
var submitted = 0;
|
||||
var recognized = 0;
|
||||
var pending = Completer<bool>();
|
||||
controller.onRecognition = () => recognized++;
|
||||
controller.onSubmit = (_, __, ___, ____, _____, ______) {
|
||||
submitted++;
|
||||
return pending.future;
|
||||
};
|
||||
final first = controller.doSubmit();
|
||||
await controller.doSubmit();
|
||||
await controller.doRecognition();
|
||||
expect(submitted, 1);
|
||||
expect(recognized, 0);
|
||||
expect(controller.isSubmitting.value, isTrue);
|
||||
pending.complete(false);
|
||||
await first;
|
||||
expect(controller.isSubmitting.value, isFalse);
|
||||
pending = Completer<bool>();
|
||||
final retry = controller.doSubmit();
|
||||
expect(submitted, 2);
|
||||
pending.complete(false);
|
||||
await retry;
|
||||
});
|
||||
|
||||
test('聚合码创建请求未返回时即互斥,结束后可重试', () async {
|
||||
final controller = Get.find<SelectPayTypeDialogController>();
|
||||
controller.orderPayDisplayType.value = OrderPayDisplayType.qrCodeScan;
|
||||
final pending = Completer<void>();
|
||||
var created = 0;
|
||||
controller.onQrCodeScanRecognition = (_) async {
|
||||
created++;
|
||||
await pending.future;
|
||||
};
|
||||
final first = controller.doRecognition();
|
||||
await controller.doRecognition();
|
||||
controller.selectPayDisplayType(OrderPayDisplayType.cash);
|
||||
expect(created, 1);
|
||||
expect(controller.isRecognition.value, isTrue);
|
||||
expect(controller.isSubmitting.value, isTrue);
|
||||
expect(
|
||||
controller.orderPayDisplayType.value, OrderPayDisplayType.qrCodeScan);
|
||||
pending.complete();
|
||||
await first;
|
||||
expect(controller.isRecognition.value, isFalse);
|
||||
expect(controller.isSubmitting.value, isFalse);
|
||||
await controller.doRecognition();
|
||||
expect(created, 2);
|
||||
});
|
||||
|
||||
test('开始人脸识别在数据库准备阶段即拒绝连点,准备失败后可重试', () async {
|
||||
final faces = _PreparingFaceDao();
|
||||
Get.find<AppControl>().dataBase =
|
||||
_LookupDatabase(_DelayedUserDao(), faces: faces);
|
||||
Get.put<FaceRecognitionSessionService>(_PreparationFaceService());
|
||||
final controller = Get.find<SelectPayTypeDialogController>();
|
||||
controller.orderPayDisplayType.value = OrderPayDisplayType.face;
|
||||
final first = controller.doRecognition();
|
||||
await controller.doRecognition();
|
||||
expect(faces.queryCount, 1);
|
||||
expect(controller.isRecognition.value, isTrue);
|
||||
faces.pending.complete(0);
|
||||
await first;
|
||||
expect(controller.isRecognition.value, isFalse);
|
||||
expect(controller.faceWorkspaceMessage.value, contains('人脸数据库为空'));
|
||||
await controller.doRecognition();
|
||||
expect(faces.queryCount, 2);
|
||||
});
|
||||
|
||||
test('识别进行中不能确认付款,结束识别清理期间也不能启动新识别', () async {
|
||||
final controller = Get.find<SelectPayTypeDialogController>();
|
||||
controller.payUser.value = AppUserTool.generateExternalUser();
|
||||
var submitted = 0;
|
||||
var recognized = 0;
|
||||
controller.onSubmit = (_, __, ___, ____, _____, ______) async {
|
||||
submitted++;
|
||||
return false;
|
||||
};
|
||||
controller.onRecognition = () => recognized++;
|
||||
controller.isRecognition.value = true;
|
||||
await controller.doSubmit();
|
||||
expect(submitted, 0);
|
||||
controller.isRecognition.value = false;
|
||||
controller.isClosing.value = true;
|
||||
await controller.doRecognition();
|
||||
await controller.doSubmit();
|
||||
expect(recognized, 0);
|
||||
expect(submitted, 0);
|
||||
});
|
||||
|
||||
test('旧人脸人员查询返回后不能污染重新打开的结算或释放新识别锁', () async {
|
||||
final app = Get.find<AppControl>();
|
||||
final lookup = _DelayedUserDao();
|
||||
app
|
||||
..isNetwork.value = false
|
||||
..dataBase = _LookupDatabase(lookup);
|
||||
final controller = Get.find<SelectPayTypeDialogController>();
|
||||
controller.initPayType(snapshot: _snapshot());
|
||||
controller.orderPayDisplayType.value = OrderPayDisplayType.face;
|
||||
controller.isRecognition.value = true;
|
||||
controller.faceSessionHandle.value = FaceRecognitionSessionHandle(
|
||||
sessionId: 'old-session',
|
||||
scene: FaceRecognitionScene.cashier,
|
||||
events: const Stream<FaceRecognitionSessionEvent>.empty(),
|
||||
result: Completer<FaceStream>().future,
|
||||
);
|
||||
final first = controller.handleFaceRecognized(
|
||||
FaceStream(
|
||||
similar: 0.95,
|
||||
cost: 10,
|
||||
userId: 'old-user',
|
||||
sessionId: 'old-session'),
|
||||
'old-session',
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(controller.faceUserLoading.value, isTrue);
|
||||
// 取消并重开会推进请求序号,模拟下一位顾客已经开始识别。
|
||||
controller.initPayType(snapshot: _snapshot());
|
||||
final nextUser = AppUserTool.generateExternalUser();
|
||||
controller
|
||||
..payUser.value = nextUser
|
||||
..isRecognition.value = true
|
||||
..faceUserLoading.value = true;
|
||||
lookup.pending.complete(null);
|
||||
await first;
|
||||
expect(controller.payUser.value, same(nextUser));
|
||||
expect(controller.isRecognition.value, isTrue);
|
||||
expect(controller.faceUserLoading.value, isTrue);
|
||||
expect(controller.faceWorkspaceMessage.value, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
/// 构造不依赖设备初始化的食堂业务控制器,保留真实 doPay 和快照流程。
|
||||
class _CanteenController extends CanteenProductController {
|
||||
/// 注入两份菜品合计 46 元的购物车和本地商品快照。
|
||||
_CanteenController() {
|
||||
shopCardList.add(_cartItem());
|
||||
totalPrice.value = 4600;
|
||||
final now = DateTime.now();
|
||||
canteenSpuList.add(CanteenSpuEntity(
|
||||
serviceUniqueCode: 'product-1',
|
||||
classifyServiceUniqueCode: 'class-1',
|
||||
saleDate: now,
|
||||
name: '测试套餐',
|
||||
price: 2300,
|
||||
isDefault: false,
|
||||
status: 1,
|
||||
sort: 1,
|
||||
isPriceIncreased: false,
|
||||
createTime: now,
|
||||
updateTime: now,
|
||||
));
|
||||
}
|
||||
|
||||
/// 测试控制本地建单何时返回,用于模拟 SQLite 延迟和失败。
|
||||
Completer<int?> pendingOrder = Completer<int?>();
|
||||
|
||||
/// 本地建单入口实际调用次数。
|
||||
int createCount = 0;
|
||||
|
||||
/// 只替换持久化边界,成功时与生产方法一样设置本地订单 ID。
|
||||
@override
|
||||
Future<int?> createSqlOrder() async {
|
||||
createCount++;
|
||||
final id = await pendingOrder.future;
|
||||
if (id != null) sqlOrderId = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定支付配置以隔离设置存储,保留真实识别与支付入口方法。
|
||||
class _PaymentController extends SelectPayTypeDialogController {
|
||||
/// 不读取设备数据库,测试中的支付方式由用例显式指定。
|
||||
@override
|
||||
// ignore: must_call_super
|
||||
void onInit() {
|
||||
appSetting = SysSetting(sysType: SysType.CANTEEN)
|
||||
..usePayDisplayType = const [OrderPayDisplayType.cash]
|
||||
..defaultPayDisplayType = OrderPayDisplayType.cash;
|
||||
orderPayDisplayType.value = OrderPayDisplayType.cash;
|
||||
orderPayType.value = OrderPayType.CASH;
|
||||
}
|
||||
}
|
||||
|
||||
/// 延迟返回旧识别人员的本地查询。
|
||||
class _DelayedUserDao implements UserDao {
|
||||
/// 由用例在结算已经切换后结束的查询。
|
||||
final pending = Completer<UserEntity?>();
|
||||
|
||||
/// 只模拟识别链路需要的人员查询。
|
||||
@override
|
||||
Future<UserEntity?> findByUserId(String userId) => pending.future;
|
||||
|
||||
/// 测试禁止调用无关数据库写入。
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// 人脸样本为空的用例只需要 DAO 句柄,不允许执行样本读写。
|
||||
class _UnusedFaceDao implements FaceDao {
|
||||
/// 非预期的样本操作直接使测试失败。
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// 延迟人脸库准备查询,覆盖首次 await 期间再次点击的竞争窗口。
|
||||
class _PreparingFaceDao extends _UnusedFaceDao {
|
||||
/// 测试主动结束的本地人脸计数查询。
|
||||
final pending = Completer<int?>();
|
||||
|
||||
/// 真实识别入口访问数据库的次数。
|
||||
int queryCount = 0;
|
||||
|
||||
/// 返回可控制的准备结果,零条记录会在开启设备前结束识别。
|
||||
@override
|
||||
Future<int?> findCount(String userName) {
|
||||
queryCount++;
|
||||
return pending.future;
|
||||
}
|
||||
}
|
||||
|
||||
/// 准备阶段测试只需要服务句柄,不初始化原生相机监听。
|
||||
class _PreparationFaceService extends FaceRecognitionSessionService {
|
||||
/// 不连接原生事件通道,测试在数据库校验阶段终止。
|
||||
@override
|
||||
// ignore: must_call_super
|
||||
void onInit() {}
|
||||
|
||||
/// 本用例没有原生会话或订阅,无需向设备发送挂起指令。
|
||||
@override
|
||||
// ignore: must_call_super
|
||||
void onClose() {}
|
||||
}
|
||||
|
||||
/// 仅提供延迟人员查询和人脸 DAO 的测试数据库。
|
||||
class _LookupDatabase implements AppDatabase {
|
||||
/// 使用当前用例的可控人员查询。
|
||||
_LookupDatabase(this.users, {FaceDao? faces})
|
||||
: faces = faces ?? _UnusedFaceDao();
|
||||
|
||||
/// 本轮识别专属的延迟查询。
|
||||
final UserDao users;
|
||||
|
||||
/// 当前用例选择的人脸库查询边界。
|
||||
final FaceDao faces;
|
||||
|
||||
/// 供离线识别查询人员缓存。
|
||||
@override
|
||||
UserDao get getUserDao => users;
|
||||
|
||||
/// 无样本编号时不会读取样本,仅满足识别入口获取句柄的约定。
|
||||
@override
|
||||
FaceDao get getFaceDao => faces;
|
||||
|
||||
/// 禁止测试访问其他数据库资源。
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// 创建合计 4600 分的固定购物车条目。
|
||||
ShopCardEntity _cartItem() => ShopCardEntity(
|
||||
orderId: -1,
|
||||
productServiceUniqueCode: 'product-1',
|
||||
productName: '测试套餐',
|
||||
productPic: '',
|
||||
productPrice: 2300,
|
||||
nature: ProductNature.DEFAULT,
|
||||
productNumber: 2,
|
||||
productWidget: 0,
|
||||
totalPrice: 4600,
|
||||
isPriceIncreased: false,
|
||||
);
|
||||
|
||||
/// 创建弹框重入和识别生命周期测试使用的非空快照。
|
||||
CashierCheckoutSnapshot _snapshot() => CashierCheckoutSnapshot.capture(
|
||||
cartItems: [_cartItem()],
|
||||
originalAmount: 4600,
|
||||
discountAmount: 0,
|
||||
surchargeAmount: 0,
|
||||
payableAmount: 4600,
|
||||
);
|
||||
|
||||
/// 使用真实购物车按钮和响应式锁构造终端尺寸的测试导航环境。
|
||||
Future<void> _pumpCheckout(
|
||||
WidgetTester tester, _CanteenController controller) async {
|
||||
addTearDown(controller.searchC.dispose);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(ScreenUtilInit(
|
||||
designSize: const Size(1920, 1080),
|
||||
builder: (_, __) => GetMaterialApp(
|
||||
builder: (_, child) => FTheme(data: FThemes.zinc.light, child: child!),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 500,
|
||||
child: Obx(() => CashierCartPanel(
|
||||
title: '已点菜品',
|
||||
items: controller.shopCardList.toList(),
|
||||
user: null,
|
||||
totalPrice: 4600,
|
||||
extraPrice: 0,
|
||||
productCount: 2,
|
||||
onQuantityChanged: (_, __) {},
|
||||
onRemove: (_) {},
|
||||
onClear: () {},
|
||||
onPay: controller.doPay,
|
||||
onCancel: () {},
|
||||
checkoutInProgress: controller.isCheckoutInProgress.value,
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drago_pos_printer/drago_pos_printer.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||||
|
||||
/// 用可控制的回报延迟与打印异常验证恢复流程,不连接服务器或实际 USB 设备。
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
Get.testMode = true;
|
||||
tearDown(Get.reset);
|
||||
|
||||
test('即时回报与补报同时到达时,同一版本只发送一次', () async {
|
||||
final coordinator = PrintTaskCoordinator();
|
||||
final task = _task(PrintStatus.SUCCESS);
|
||||
final dao = _PrintDao([task]);
|
||||
final started = Completer<void>();
|
||||
final response = Completer<bool>();
|
||||
var requests = 0;
|
||||
Future<bool> send(PrintHistoryEntity _) {
|
||||
requests++;
|
||||
started.complete();
|
||||
return response.future;
|
||||
}
|
||||
|
||||
final first = coordinator.report(task, dao: dao, sendResult: send);
|
||||
await started.future;
|
||||
await coordinator.report(_copy(task), dao: dao, sendResult: send);
|
||||
expect(requests, 1);
|
||||
response.complete(true);
|
||||
await first;
|
||||
final saved = await dao.findByServicePrintId('9001');
|
||||
expect(saved!.status, PrintStatus.SYNC);
|
||||
expect(saved.resultPending, isFalse);
|
||||
});
|
||||
|
||||
test('旧失败回报晚到时不能覆盖刚入队的新版本', () async {
|
||||
final coordinator = PrintTaskCoordinator();
|
||||
final task = _task(PrintStatus.ERROR)..attemptCount = 3;
|
||||
final dao = _PrintDao([task]);
|
||||
final started = Completer<void>();
|
||||
final response = Completer<bool>();
|
||||
final reporting = coordinator.report(task, dao: dao, sendResult: (_) {
|
||||
started.complete();
|
||||
return response.future;
|
||||
});
|
||||
await started.future;
|
||||
await coordinator
|
||||
.mutate(() => dao.edit(_task(PrintStatus.NONE, version: 1)));
|
||||
response.complete(true);
|
||||
await reporting;
|
||||
|
||||
final saved = await dao.findByServicePrintId('9001');
|
||||
expect(saved!.retryVersion, 1);
|
||||
expect(saved.status, PrintStatus.NONE);
|
||||
expect(saved.attemptCount, 0);
|
||||
expect(saved.syncTime, isNull);
|
||||
});
|
||||
|
||||
test('旧版本与新版本回报乱序返回时保留新版本打印成功', () async {
|
||||
final coordinator = PrintTaskCoordinator();
|
||||
final old = _task(PrintStatus.ERROR);
|
||||
final dao = _PrintDao([old]);
|
||||
final started = Completer<void>();
|
||||
final response = Completer<bool>();
|
||||
final reporting = coordinator.report(old, dao: dao, sendResult: (_) {
|
||||
started.complete();
|
||||
return response.future;
|
||||
});
|
||||
await started.future;
|
||||
final latest = _task(PrintStatus.SUCCESS, version: 1);
|
||||
await coordinator.mutate(() => dao.edit(latest));
|
||||
await coordinator.report(latest, dao: dao, sendResult: (_) async => true);
|
||||
response.complete(true);
|
||||
await reporting;
|
||||
|
||||
final saved = await dao.findByServicePrintId('9001');
|
||||
expect(saved!.retryVersion, 1);
|
||||
expect(saved.status, PrintStatus.SYNC);
|
||||
expect(saved.resultPending, isFalse);
|
||||
});
|
||||
|
||||
test('定时扫描留下的旧快照不再回报或清除新版本待补报标记', () async {
|
||||
final coordinator = PrintTaskCoordinator();
|
||||
final dao = _PrintDao([_task(PrintStatus.SUCCESS, version: 1)]);
|
||||
var requests = 0;
|
||||
await coordinator.report(_task(PrintStatus.ERROR), dao: dao,
|
||||
sendResult: (_) async {
|
||||
requests++;
|
||||
return true;
|
||||
});
|
||||
expect(requests, 0);
|
||||
expect((await dao.findByServicePrintId('9001'))!.resultPending, isTrue);
|
||||
});
|
||||
|
||||
test('服务端拒绝后保留结果且释放回报锁,后续仍可补报', () async {
|
||||
final coordinator = PrintTaskCoordinator();
|
||||
final task = _task(PrintStatus.SUCCESS);
|
||||
final dao = _PrintDao([task]);
|
||||
await expectLater(
|
||||
coordinator.report(task, dao: dao, sendResult: (_) async => false),
|
||||
throwsStateError,
|
||||
);
|
||||
expect((await dao.findByServicePrintId('9001'))!.resultPending, isTrue);
|
||||
await coordinator.report(task, dao: dao, sendResult: (_) async => true);
|
||||
expect((await dao.findByServicePrintId('9001'))!.resultPending, isFalse);
|
||||
});
|
||||
|
||||
test('回报成功但本地确认写入失败时保留补报能力', () async {
|
||||
final coordinator = PrintTaskCoordinator();
|
||||
final task = _task(PrintStatus.SUCCESS);
|
||||
final dao = _PrintDao([task])..failStatus = PrintStatus.SYNC;
|
||||
await expectLater(
|
||||
coordinator.report(task, dao: dao, sendResult: (_) async => true),
|
||||
throwsStateError,
|
||||
);
|
||||
expect((await dao.findByServicePrintId('9001'))!.resultPending, isTrue);
|
||||
dao.failStatus = null;
|
||||
await coordinator.report(task, dao: dao, sendResult: (_) async => true);
|
||||
expect((await dao.findByServicePrintId('9001'))!.status, PrintStatus.SYNC);
|
||||
});
|
||||
|
||||
test('第一单关闭连接抛错后,后续订单仍能打印且每单只写一次', () async {
|
||||
final dao = _PrintDao([
|
||||
_task(PrintStatus.NONE),
|
||||
_task(PrintStatus.NONE, id: '9002'),
|
||||
]);
|
||||
final printer = _Printer()..closeFailures = 1;
|
||||
_installApp(dao, printer);
|
||||
await SyncPrintTool.doCheckPrint();
|
||||
expect(printer.writes, 2);
|
||||
expect(printer.closes, 2);
|
||||
expect((await dao.getAll(PrintStatus.SUCCESS.name)), hasLength(2));
|
||||
});
|
||||
|
||||
test('打开连接失败后也清理残留,下一次尝试恢复打印', () {
|
||||
final dao = _PrintDao([_task(PrintStatus.NONE)]);
|
||||
final printer = _Printer()..openFailures = 1;
|
||||
_installApp(dao, printer);
|
||||
fakeAsync((clock) {
|
||||
var completed = false;
|
||||
SyncPrintTool.doCheckPrint().then((_) => completed = true);
|
||||
clock.flushMicrotasks();
|
||||
expect(printer.closes, 1);
|
||||
expect(printer.writes, 0);
|
||||
clock.elapse(const Duration(seconds: 5));
|
||||
clock.flushMicrotasks();
|
||||
expect(completed, isTrue);
|
||||
expect(printer.writes, 1);
|
||||
expect(printer.closes, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('USB 已接收后本地保存失败,不进入本轮 USB 自动重试', () async {
|
||||
final dao = _PrintDao([_task(PrintStatus.NONE)])
|
||||
..failStatus = PrintStatus.SUCCESS;
|
||||
final printer = _Printer();
|
||||
_installApp(dao, printer);
|
||||
await SyncPrintTool.doCheckPrint();
|
||||
expect(printer.writes, 1);
|
||||
expect(printer.closes, 1);
|
||||
});
|
||||
|
||||
test('设备管理器释放异常后仍清空句柄,重复关闭不再访问旧连接', () async {
|
||||
final printer = PrintManage();
|
||||
final connection = _BrokenUsbManager();
|
||||
printer.manager = connection;
|
||||
_installApp(_PrintDao([]), printer);
|
||||
await printer.close();
|
||||
await printer.close();
|
||||
expect(printer.manager, isNull);
|
||||
expect(connection.closes, 1);
|
||||
});
|
||||
}
|
||||
|
||||
/// 创建独立任务快照,版本与状态由用例指定,订单 ID 仅供本地模拟。
|
||||
PrintHistoryEntity _task(PrintStatus status,
|
||||
{String id = '9001', int version = 0}) =>
|
||||
PrintHistoryEntity(
|
||||
id: int.parse(id),
|
||||
orderId: '8001',
|
||||
serviceOrderId: '8001',
|
||||
servicePrintId: id,
|
||||
invoiceUrl: 'https://example.test/voucher.png',
|
||||
retryVersion: version,
|
||||
resultPending: status != PrintStatus.NONE,
|
||||
status: status,
|
||||
createTime: DateTime(2026, 10, 30),
|
||||
);
|
||||
|
||||
/// 模拟 Floor 每次查询返回新对象,避免对象共享掩盖整行覆盖问题。
|
||||
PrintHistoryEntity _copy(PrintHistoryEntity task) =>
|
||||
PrintHistoryEntity.fromJson(task.toJson());
|
||||
|
||||
/// 只提供本轮打印任务所需的 DAO,所有写入均保存副本。
|
||||
class _PrintDao implements PrintHistoryDao {
|
||||
/// 使用给定快照初始化本地打印任务。
|
||||
_PrintDao(List<PrintHistoryEntity> initial)
|
||||
: rows = {for (final task in initial) task.servicePrintId: _copy(task)};
|
||||
|
||||
/// 模拟 servicePrintId 唯一约束的持久化快照。
|
||||
final Map<String, PrintHistoryEntity> rows;
|
||||
|
||||
/// 模拟目标状态落库失败;为空时全部写入成功。
|
||||
PrintStatus? failStatus;
|
||||
|
||||
/// 返回单个任务副本,不与调用方共享可变对象。
|
||||
@override
|
||||
Future<PrintHistoryEntity?> findByServicePrintId(
|
||||
String servicePrintId) async {
|
||||
final row = rows[servicePrintId];
|
||||
return row == null ? null : _copy(row);
|
||||
}
|
||||
|
||||
/// 模拟整行更新及指定状态写入失败,不吞掉异常。
|
||||
@override
|
||||
Future<void> edit(PrintHistoryEntity entity) async {
|
||||
if (entity.status == failStatus) throw StateError('模拟持久化失败');
|
||||
rows[entity.servicePrintId] = _copy(entity);
|
||||
}
|
||||
|
||||
/// 返回指定状态的副本,保持真实打印队列每轮查库的行为。
|
||||
@override
|
||||
Future<List<PrintHistoryEntity>> getAll(String status) async => rows.values
|
||||
.where((task) => task.status.name == status)
|
||||
.map(_copy)
|
||||
.toList();
|
||||
|
||||
/// 禁止测试调用未建模的数据库能力。
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// 将真实 AppControl 与模拟打印设备、DAO 连接,关闭网络以避免硬件用例调用 HTTP。
|
||||
void _installApp(_PrintDao dao, PrintManage printer) {
|
||||
Get.put<AppControl>(_App())
|
||||
..dataBase = _Database(dao)
|
||||
..printManage = printer
|
||||
..isNetwork.value = false;
|
||||
}
|
||||
|
||||
/// 测试不启动应用服务,销毁时也不调用无关原生插件。
|
||||
class _App extends AppControl {
|
||||
/// 设备释放由用例显式触发,避免 tearDown 引入额外关闭次数。
|
||||
@override
|
||||
// ignore: must_call_super
|
||||
void onClose() {}
|
||||
}
|
||||
|
||||
/// 只暴露打印 DAO,不创建真实数据库或更改表结构。
|
||||
class _Database implements AppDatabase {
|
||||
/// 保存本用例的任务存储。
|
||||
_Database(this.prints);
|
||||
|
||||
/// 本用例的任务存储实例。
|
||||
final PrintHistoryDao prints;
|
||||
|
||||
/// 返回打印队列使用的 DAO。
|
||||
@override
|
||||
PrintHistoryDao get getPrintHistoryDao => prints;
|
||||
|
||||
/// 其他数据库能力被调用时立即失败。
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// 记录调用次数并模拟短暂掉线与释放异常,不发送打印数据。
|
||||
class _Printer extends PrintManage {
|
||||
/// 剩余的连接失败次数。
|
||||
int openFailures = 0;
|
||||
|
||||
/// 剩余的释放异常次数。
|
||||
int closeFailures = 0;
|
||||
|
||||
/// 实际请求写入的次数,用于检查是否多打。
|
||||
int writes = 0;
|
||||
|
||||
/// 调用释放的次数,包括打开失败后的清理。
|
||||
int closes = 0;
|
||||
|
||||
/// 模拟连接在若干次失败后恢复。
|
||||
@override
|
||||
Future<bool> open() async {
|
||||
if (openFailures <= 0) return true;
|
||||
openFailures--;
|
||||
lastError = '模拟 USB 断开';
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 模拟 USB 完整接受一张小票的数据。
|
||||
@override
|
||||
Future<bool> printImg(String img) async {
|
||||
writes++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 模拟拔线后的连接释放异常。
|
||||
@override
|
||||
Future<void> close() async {
|
||||
closes++;
|
||||
if (closeFailures <= 0) return;
|
||||
closeFailures--;
|
||||
throw StateError('模拟 USB 释放失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 不创建上游 FFI 资源,仅模拟 USB 管理器的释放失败。
|
||||
class _BrokenUsbManager implements USBPrinterManager {
|
||||
/// 已尝试释放次数。
|
||||
int closes = 0;
|
||||
|
||||
/// 每次释放均抛错,以验证上层仍会清空句柄。
|
||||
@override
|
||||
Future<void> disconnect({Duration? timeout}) async {
|
||||
closes++;
|
||||
throw StateError('USB 已断开');
|
||||
}
|
||||
|
||||
/// 测试禁止执行真实连接或写入。
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
|
||||
}
|
||||
Reference in new issue
Block a user