feat:消费机支付卡顿的问题

This commit is contained in:
dzc committed 2026-10-23 17:13:37 +08:00
1 parent 531815517a
commit 6ec171d262
21 files changed
+1470 -98

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
## flutter打包
```
flutter build apk --build-name 2.1.5 --build-number 215
flutter build apk --build-name 2.1.6 --build-number 216
--build-name为版本号(展示)
--build-number为版本号(内部比较)
```
+8 -2
View File
@@ -1,15 +1,21 @@
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:dio/dio.dart' as dio;
abstract class WalletApi {
/// 优惠列表
static Future<List<DiscountsItem>?> getDiscountsListByUserId(
static Future<List<DiscountsItem>> getDiscountsListByUserId(
dynamic params,
) async {
final res = await HttpManage().dio.get(
"/wallet/wallet-setting/ad/get-discounts-list",
queryParameters: params,
options: dio.Options(
extra: {HttpManage.rejectBusinessErrorKey: true},
),
);
if (res.data == null) return null;
if (res.data is! List) {
throw const FormatException('优惠列表返回格式异常,请重试');
}
return (res.data as List).map((e) => DiscountsItem.fromJson(e)).toList();
}
@@ -23,6 +23,18 @@ abstract class UserDao {
@update
Future<void> batchEdit(List<UserEntity> entitys);
/// 优惠成功结果只更新规则快照,不能覆盖同时刷新的钱包余额。
@Query('''
UPDATE user_record
SET discountsList = :rulesJson, discountsUpdateTime = :updatedAt
WHERE userId = :userId
''')
Future<void> updateDiscounts(
String userId,
String rulesJson,
DateTime updatedAt,
);
@Query('''
SELECT * FROM user_record
WHERE
@@ -1820,6 +1820,17 @@ class _$UserDao extends UserDao {
await _queryAdapter.queryNoReturn('DELETE FROM user_record');
}
@override
Future<void> updateDiscounts(
String userId,
String rulesJson,
DateTime updatedAt,
) async {
await _queryAdapter.queryNoReturn(
'UPDATE user_record SET discountsList = ?2, discountsUpdateTime = ?3 WHERE userId = ?1',
arguments: [userId, rulesJson, _dateTimeConverter.encode(updatedAt)]);
}
@override
Future<int?> findCount(String keyword) async {
return _queryAdapter.query(
@@ -0,0 +1,36 @@
import 'package:pj_supermarket_cloud/common/services/database/entity/canteen_spu_entity.dart';
import 'package:pj_supermarket_cloud/common/services/database/entity/shop_card_entity.dart';
/// 服务端要求重新确认价格后,用当前餐段的菜品重建结算购物车。
///
/// 不修改原购物车或商品快照。商品缺失时整次刷新失败,由收银员明确重新选择,
/// 不能静默删除已点菜品后继续支付。
abstract final class CheckoutCartRepricing {
static List<ShopCardEntity> refresh(
List<ShopCardEntity> cartItems,
List<CanteenSpuEntity> currentProducts,
) {
final productsByCode = {
for (final product in currentProducts) product.serviceUniqueCode: product,
};
return cartItems.map((item) {
final product = productsByCode[item.productServiceUniqueCode];
if (product == null) {
throw StateError('菜品“${item.productName}”已下架或不在当前餐段,请重新选择后确认支付');
}
return ShopCardEntity(
id: item.id,
orderId: item.orderId,
productServiceUniqueCode: item.productServiceUniqueCode,
productName: product.name,
productPic: product.image ?? '',
productPrice: product.price,
nature: item.nature,
productNumber: item.productNumber,
productWidget: item.productWidget,
totalPrice: product.price * item.productNumber,
isPriceIncreased: product.isPriceIncreased,
);
}).toList(growable: false);
}
}
@@ -0,0 +1,105 @@
import 'dart:async';
/// 优惠规则由人员、设备和租户共同决定,与购物车数量无关。
class CheckoutDiscountKey {
const CheckoutDiscountKey({
required this.tenantId,
required this.hardId,
required this.userId,
});
final String tenantId;
final String hardId;
final String userId;
@override
bool operator ==(Object other) =>
other is CheckoutDiscountKey &&
tenantId == other.tenantId &&
hardId == other.hardId &&
userId == other.userId;
@override
int get hashCode => Object.hash(tenantId, hardId, userId);
}
/// 仅供一次结算使用的规则查询上下文,不在不同订单之间共享成功结果。
///
/// 同上下文的并发查询合并;成功结果(包括空列表)最多复用 30 秒。失败不会缓存,
/// 过期后的失败也不会回退旧成功值。关闭或重开结算时调用 [invalidate]。
class CheckoutDiscountSession<T> {
CheckoutDiscountSession({
this.maxAge = const Duration(seconds: 30),
Duration Function()? elapsed,
}) : _elapsed = elapsed {
_watch.start();
}
final Duration maxAge;
final Duration Function()? _elapsed;
final Stopwatch _watch = Stopwatch();
final Map<CheckoutDiscountKey, _DiscountRulesEntry<T>> _successful = {};
final Map<CheckoutDiscountKey, Future<List<T>>> _running = {};
Duration _lastElapsed = Duration.zero;
CheckoutDiscountKey? _context;
int _generation = 0;
Future<List<T>> load({
required CheckoutDiscountKey key,
required Future<List<T>> Function() lookup,
Future<void> Function(List<T> rules)? onAccepted,
}) {
final context = _context;
if (context != null &&
(context.tenantId != key.tenantId || context.hardId != key.hardId)) {
// 离线规则只按 userId 保存;切换租户或设备后,旧请求不得再写同一人员行。
invalidate();
}
_context = key;
final cached = _successful[key];
if (cached != null && _now() - cached.completedAt < maxAge) {
return Future.value(cached.rules);
}
final running = _running[key];
if (running != null) return running;
final generation = _generation;
late final Future<List<T>> request;
request = Future<List<T>>.sync(lookup).then((rules) async {
final result = List<T>.unmodifiable(rules);
if (generation == _generation) {
// 持久化也属于当前结算代次;迟到请求不能覆盖后来结算的离线快照。
await onAccepted?.call(result);
if (generation == _generation) {
_successful[key] = _DiscountRulesEntry(result, _now());
}
}
return result;
}).whenComplete(() {
if (identical(_running[key], request)) _running.remove(key);
});
_running[key] = request;
return request;
}
/// 已在途的调用仍按原样完成,但不能再填充已结束结算的缓存或清掉新请求。
void invalidate() {
_generation++;
_successful.clear();
_running.clear();
_context = null;
}
Duration _now() {
final now = _elapsed?.call() ?? _watch.elapsed;
if (now < _lastElapsed) return _lastElapsed;
_lastElapsed = now;
return now;
}
}
class _DiscountRulesEntry<T> {
const _DiscountRulesEntry(this.rules, this.completedAt);
final List<T> rules;
final Duration completedAt;
}
@@ -0,0 +1,24 @@
/// 只允许最新一次结算计算写回金额。关闭、换人或清空购物车时作废在途结果。
class CheckoutPriceCoordinator {
int _revision = 0;
void invalidate() => _revision++;
Future<bool> calculate({
required Future<int> Function() load,
required bool Function() isContextCurrent,
required void Function(int) apply,
}) async {
final revision = ++_revision;
final int amount;
try {
amount = await load();
} catch (_) {
if (revision != _revision || !isContextCurrent()) return false;
rethrow;
}
if (revision != _revision || !isContextCurrent()) return false;
apply(amount);
return true;
}
}
+71 -30
View File
@@ -3,42 +3,66 @@ import 'dart:convert';
import 'package:common_utils/common_utils.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'checkout_discount_session.dart';
class DiscountsTool {
static final String TAG = "优惠券工具类";
static const String _tag = "优惠券工具类";
/// 由收银 Controller 持有,每次结算关闭或重开时失效,不能跨订单全局复用。
static CheckoutDiscountSession<DiscountsItem> createSession({
Duration maxAge = const Duration(seconds: 30),
Duration Function()? elapsed,
}) =>
CheckoutDiscountSession(maxAge: maxAge, elapsed: elapsed);
/// 更新用户优惠列表
static Future<List<DiscountsItem>?> updateUserDiscountsList({
static Future<List<DiscountsItem>> updateUserDiscountsList({
required String userId,
CheckoutDiscountSession<DiscountsItem>? session,
}) async {
final app = Get.find<AppControl>();
final userDao = app.dataBase.getUserDao;
// 没有网络时, 不更新优惠列表
if (!AppNetworkTool.isAutoNetwork()) {
final userEntity = await userDao.findByUserId(userId);
if (ObjectUtil.isEmpty(userEntity)) return null;
if (ObjectUtil.isEmpty(userEntity)) return [];
return userEntity?.discountsList ?? [];
}
final sysSetting = await AppSettingTool.getSysSetting();
final userSetting = await AppSettingTool.getUserSetting();
final configuredTenant = userSetting.tenantId;
// 与 HTTP 拦截器的 TENANT-ID 归一化规则保持一致。
final tenantId =
configuredTenant != null && int.tryParse(configuredTenant) != null
? configuredTenant
: '1';
final params = {
"userId": userId,
"hardId": sysSetting.serviceId,
};
logger.i("$TAG更新用户优惠列表, params: $params");
final discountsRes = await WalletApi.getDiscountsListByUserId(params);
if (ObjectUtil.isEmptyList(discountsRes)) return null;
logger.i("$TAG 更新用户优惠列表, res: ${jsonEncode(discountsRes)}");
// 查询用户表
final userEntity = await userDao.findByUserId(userId);
if (ObjectUtil.isEmpty(userEntity)) return discountsRes;
// 能更新优惠(防止同一个用户频繁更新)
if (userEntity!.isUpdateDiscounts()) {
userEntity.discountsList = discountsRes;
userEntity.discountsUpdateTime = DateTime.now();
await userDao.edit(userEntity);
Future<List<DiscountsItem>> lookup() async {
logger.i("$_tag更新用户优惠列表, params: $params");
// 在线失败必须向上传播,不能用空优惠或离线旧规则继续在线收费。
final discountsRes = await WalletApi.getDiscountsListByUserId(params);
logger.i("$_tag 更新用户优惠列表, res: ${jsonEncode(discountsRes)}");
return discountsRes;
}
// 更新到用户表
return discountsRes;
return (session ?? createSession()).load(
key: CheckoutDiscountKey(
tenantId: tenantId,
hardId: sysSetting.serviceId ?? '',
userId: userId,
),
lookup: lookup,
// 只更新规则列,不读取/回写整个人员行,避免把并发刷新的余额覆盖掉。
// 回调直接入队单条 UPDATE,中途不再等待其他读操作。
onAccepted: (rules) => userDao.updateDiscounts(
userId,
jsonEncode(rules),
DateTime.now(),
),
);
}
/// 计算额外增加的金额
@@ -50,27 +74,44 @@ class DiscountsTool {
required String userId,
required List<ShopCardEntity> shopCardList,
required String hourType,
CheckoutDiscountSession<DiscountsItem>? session,
}) async {
if (ObjectUtil.isEmpty(shopCardList)) {
return 0;
}
var discountsItems = await updateUserDiscountsList(userId: userId);
discountsItems = discountsItems
?.where((element) => element.hourType?.code == hourType)
.toList();
if (ObjectUtil.isEmpty(discountsItems)) return 0;
// 等待网络前冻结计算所需的整数分金额,不把旧人员规则套到后来变化的购物车。
final totalPrice =
shopCardList.fold<int>(0, (sum, e) => sum + e.totalPrice);
final increasedPrice = shopCardList
.where((e) => e.isPriceIncreased)
.fold<int>(0, (sum, e) => sum + e.totalPrice);
final discountsItems = await updateUserDiscountsList(
userId: userId,
session: session,
);
return computeExtraPriceFromRules(
discountsItems: discountsItems,
hourType: hourType,
totalPrice: totalPrice,
increasedPrice: increasedPrice,
);
}
/// 计算额外价格
/// 使用成功取得的规则重算价格;保留原有餐段、特色菜、基数和四舍五入规则。
static int computeExtraPriceFromRules({
required List<DiscountsItem> discountsItems,
required String hourType,
required int totalPrice,
required int increasedPrice,
}) {
var extraPrice = 0;
for (final item in discountsItems!) {
for (final item in discountsItems) {
if (item.hourType?.code != hourType) continue;
final extraPer = item.extraPer;
final specialDishEnable = item.specialDishEnable;
if (extraPer == null || extraPer <= 1.0) continue;
final iterable = specialDishEnable
? shopCardList
: shopCardList.where((e) => e.isPriceIncreased);
final totalPrice = iterable.fold<int>(0, (sum, e) => sum + e.totalPrice);
extraPrice += item.computedDiscountsExtraPrice(totalPrice);
extraPrice += item.computedDiscountsExtraPrice(
item.specialDishEnable ? totalPrice : increasedPrice,
);
}
return extraPrice;
}
@@ -0,0 +1,12 @@
import 'package:dio/dio.dart';
/// 新收费协议的明确拒绝发生在扣款前,可以刷新金额后由用户重新确认。
class TerminalDiscountProtocol {
static bool isPricingRejected(Object error) {
if (error is! DioException || error.response?.statusCode != 409) {
return false;
}
final data = error.response?.data;
return data is Map && data['code']?.toString() == '40901';
}
}
@@ -124,6 +124,7 @@ class PaymentTimingTrace {
}
final safeOutcome = const {
'success',
'amount_changed',
'failed',
'exception',
'offline_accepted',
+17 -1
View File
@@ -57,7 +57,23 @@ abstract class SyncUserTool {
final obj = _parseStorageUser(user: user, updateDate: true);
obj.id = detail.id;
if (obj == detail) return;
await userDao.edit(obj);
// 规则查询可能与人员刷新并发,不能用旧快照整行回写覆盖新规则。
await app.dataBase.database.update(
'user_record',
{
'nickname': obj.nickname,
'mobile': obj.mobile,
'avatar': obj.avatar,
'walletCardNumber': obj.walletCardNumber,
'walletId': obj.walletId,
'walletTypeId': obj.walletTypeId,
'walletBalance': obj.walletBalance,
'canteenId': obj.canteenId,
'updateTime': DateTimeConverter().encode(obj.updateTime!),
},
where: 'id = ?',
whereArgs: [detail.id],
);
logger.i("|$_tag|,存储用户,修改成功|${obj.toJson()}|");
}
} catch (e, stack) {
+59 -14
View File
@@ -9,6 +9,7 @@ import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/terminal_discount_protocol.dart';
import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart';
import 'package:pj_supermarket_cloud/common/utils/order/terminal_order_recovery.dart';
@@ -24,6 +25,9 @@ mixin OrderMixin {
/// 当前收银弹框成功的人脸证据结果,只有人脸支付下单时才会消费并绑定订单号。
Rxn<FaceStream> pendingFaceRecognition = Rxn<FaceStream>();
/// 明确的收费拒绝没有发生扣款,由具体收银页面刷新规则和商品快照后等待再次确认。
Future<void> onTerminalPricingRejected() async {}
final appC = Get.find<AppControl>();
/// **************** start 需要重制 start ****************
@@ -311,6 +315,23 @@ mixin OrderMixin {
/// 准备并提交原协议订单;网络响应丢失时先只读对账,确认后统一完成本地状态。
Future<T?> _createApiOrder<T>() async {
// 在任何本地写库/证据等待前固定本次支付输入,避免异步识别回调混入另一人的金额。
final submittedPayType = orderPayType.value!;
final submittedDisplayType = orderPayDisplayType.value!;
final submittedUserId = payUser.value?.userId;
final submittedWalletId = payUser.value?.walletId;
final submittedQrWalletId = globalQrCode?.id;
final submittedHourType = hourType.value.code;
final submittedOrderType = orderType;
final submittedOrderSourceTypeId = getOrderSourceTypeId();
final submittedExtraPrice = amountMealPrice.value;
final submittedPayableAmount = getTotalPrice() + submittedExtraPrice;
final submittedCart = shopCardList
.map((item) => ShopCardEntity.fromJson(item.toJson()))
.toList(growable: false);
final submittedRealUser = payUser.value != null &&
!AppUserTool.isGenerateExternalUser(payUser.value!) &&
ObjectUtil.isNotEmpty(submittedUserId);
final prepare = await PaymentTimingTrace.measureCurrent(
'order_prepare',
createApiOrderValidate,
@@ -326,15 +347,13 @@ mixin OrderMixin {
final orderDao = appC.dataBase.getOrderDao;
// 固定本次请求的支付方式,异步网络处理期间不读取可能被硬件回调更新的选择状态。
final submittedPayType = orderPayType.value!;
final submittedDisplayType = orderPayDisplayType.value!;
final anonymousPayment =
OrderPayDisplayType.isOffline(submittedDisplayType);
// 获取下单商品
List<Map<String, dynamic>> shopList = [];
if (_sysType == SysType.CANTEEN) {
shopList = shopCardList.where((v) => v.productNumber > 0).map((v) {
shopList = submittedCart.where((v) => v.productNumber > 0).map((v) {
return {
"productId": v.productServiceUniqueCode,
"productCount": v.productNumber,
@@ -344,7 +363,7 @@ mixin OrderMixin {
};
}).toList();
} else if (_sysType == SysType.SUPERMARKET) {
shopList = shopCardList.map((v) {
shopList = submittedCart.map((v) {
return {
"productId": v.productServiceUniqueCode,
"productCount": v.productNumber,
@@ -358,7 +377,8 @@ mixin OrderMixin {
// 人脸支付订单在发起请求前把识别会话绑定到终端订单号;离线单同样绑定,
// 联网同步并由服务端确认支付后,后台补传任务按订单号对账上传证据。
final isFacePayment = submittedPayType == OrderPayType.FACE;
final faceSessionId = pendingFaceRecognition.value?.sessionId?.trim();
final submittedRecognition = pendingFaceRecognition.value;
final faceSessionId = submittedRecognition?.sessionId?.trim();
var evidenceMetadataList = const <Map<String, dynamic>>[];
String? evidenceAppVersion;
if (isFacePayment && faceSessionId != null && faceSessionId.isNotEmpty) {
@@ -379,26 +399,39 @@ mixin OrderMixin {
"androidOrderId": sqlOrderId,
"androidOrderCode": sqlOrderDetail.uniqueCode,
"sysType": _sysType.name,
"userId": payUser.value?.userId ?? "-1",
"hourType": hourType.value.code,
"userId": submittedUserId ?? "-1",
"hourType": submittedHourType,
"hardwareId": serviceId,
"merchantId": serviceMerchantId!,
"orderType": orderType.name,
"orderSourceTypeId": getOrderSourceTypeId(),
"orderType": submittedOrderType.name,
"orderSourceTypeId": submittedOrderSourceTypeId,
"orderPayType": submittedPayType.name,
"list": shopList,
"extraPrice": amountMealPrice.value,
"extraPrice": submittedExtraPrice,
if (isNetwork &&
_sysType == SysType.CANTEEN &&
submittedOrderType == OrderType.BELOW &&
submittedRealUser &&
const [
OrderPayType.WALLET_DEFAULT,
OrderPayType.WALLET_QR_CODE,
OrderPayType.WALLET_CARD,
OrderPayType.FACE
].contains(submittedPayType)) ...{
"discountPricingVersion": 1,
"confirmedPayableAmount": submittedPayableAmount,
},
if (evidenceMetadataList.isNotEmpty)
"evidenceMetadataList": evidenceMetadataList,
if (evidenceAppVersion != null) "appVersion": evidenceAppVersion,
};
if (appSetting.sysType == SysType.CANTEEN &&
(submittedPayType == OrderPayType.WALLET_QR_CODE)) {
pa["walletId"] = globalQrCode?.id;
pa["walletId"] = submittedQrWalletId;
} else if (OrderPayDisplayType.isSelectWalletPayType(
submittedDisplayType)) {
if (payUser.value?.walletId != null) {
pa["walletId"] = payUser.value?.walletId;
if (submittedWalletId != null) {
pa["walletId"] = submittedWalletId;
}
}
// 证据元数据不进入日志,避免哈希和样本编号长期留存在终端日志文件中。
@@ -513,9 +546,21 @@ mixin OrderMixin {
}
return result;
} catch (e, stack) {
final pricingRejected = TerminalDiscountProtocol.isPricingRejected(e);
if (pricingRejected && submittedRecognition != null) {
pendingFaceRecognition.value = submittedRecognition;
}
if (pricingRejected) {
try {
await onTerminalPricingRejected();
} catch (refreshError, refreshStack) {
logger.e('|$_tag|刷新收费信息失败|error:$refreshError|stack:$refreshStack|');
}
}
if (isFacePayment &&
faceSessionId != null &&
faceSessionId.isNotEmpty &&
!pricingRejected &&
!OrderEvidenceUploadTool.shouldReconcileOrderFailure(e)) {
await PaymentTimingTrace.measureCurrent(
'evidence_cancel',
@@ -525,7 +570,7 @@ mixin OrderMixin {
),
);
}
Toast.error(msg: "结算订单失败");
Toast.error(msg: pricingRejected ? '收费规则或菜价已变化,请刷新后重新确认金额' : '结算订单失败');
logger.e("|$_tag|创建api订单,下单失败error:|$e|stack:|$stack|");
}
return null;
@@ -78,7 +78,7 @@ class SelectPayTypeDialogController extends GetxController {
VoidCallback? onCancel;
// 识别出用户的回调
Function(ServiceUser?)? onDiscernUser;
FutureOr<void> Function(ServiceUser?)? onDiscernUser;
/// **************** end 回调函数 end ****************
@@ -112,6 +112,30 @@ class SelectPayTypeDialogController extends GetxController {
/// 支付提交正在执行时为 true,用于禁用重复提交和弹框关闭。
final isSubmitting = false.obs;
/// 人员已识别但收费规则未确认时,不允许按旧金额提交。
final isPricing = false.obs;
final pricingError = ''.obs;
int _pricingRequestId = 0;
void markPricingFailed() {
pricingError.value = '读取收费规则失败,请重试';
}
Future<void> refreshRecognizedUserPricing() async {
final user = payUser.value;
final requestId = ++_pricingRequestId;
isPricing.value = true;
pricingError.value = '';
try {
await onDiscernUser?.call(user);
} catch (error, stack) {
if (requestId == _pricingRequestId) markPricingFailed();
logger.e('|$_tag|读取收费规则失败|error:$error|stack:$stack|');
} finally {
if (requestId == _pricingRequestId) isPricing.value = false;
}
}
/// 当前嵌入收银工作区的人脸会话;为空时不渲染摄像头面板。
final faceSessionHandle = Rxn<FaceRecognitionSessionHandle>();
@@ -209,7 +233,7 @@ 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 || isPricing.value) return;
if (isRecognition.value) {
pendingPayDisplayType.value = payType;
closeRecognitionConfirmationVisible.value = false;
@@ -221,6 +245,9 @@ class SelectPayTypeDialogController extends GetxController {
/// 应用已确认的支付方式,并清理上一方式遗留的局部提示。
void _applyPayDisplayType(OrderPayDisplayType payType) {
_pricingRequestId++;
isPricing.value = false;
pricingError.value = '';
if (payType != OrderPayDisplayType.face) {
unawaited(_cancelPendingEvidence());
}
@@ -259,6 +286,7 @@ class SelectPayTypeDialogController extends GetxController {
/// 释放支付方式选择器与可能仍在运行的人脸会话。
@override
void onClose() {
_pricingRequestId++;
_finishQrCodeScan();
unawaited(_cancelPendingEvidence());
unawaited(_closeFaceSession(cancel: true));
@@ -319,6 +347,9 @@ class SelectPayTypeDialogController extends GetxController {
/// 清理当前交易的人员、钱包、识别会话和支付凭据,供弹框关闭与再次打开共用。
void _closeStatus() {
_pricingRequestId++;
isPricing.value = false;
pricingError.value = '';
_faceDispatchRequestId++;
_walletCardDispatchRequestId++;
_faceSetupDispatchId++;
@@ -374,7 +405,11 @@ class SelectPayTypeDialogController extends GetxController {
/// 防重复提交当前支付信息,并仅在业务回调确认成功后关闭本次结算弹框。
Future<void> doSubmit() async {
if (payUser.value == null || isSubmitting.value) return;
if (payUser.value == null || isSubmitting.value || isPricing.value) return;
if (pricingError.value.isNotEmpty) {
await refreshRecognizedUserPricing();
return;
}
if (orderPayDisplayType.value == OrderPayDisplayType.qrCodeScan) {
await doRecognition();
return;
@@ -386,7 +421,8 @@ class SelectPayTypeDialogController extends GetxController {
OrderPayDisplayType.isOffline(orderPayDisplayType.value!);
if (isOffline) {
orderType = OrderType.BELOW;
onDiscernUser?.call(payUser.value);
await refreshRecognizedUserPricing();
if (pricingError.value.isNotEmpty) return;
}
final submitted = await onSubmit?.call(
payUser.value!,
@@ -506,7 +542,6 @@ class SelectPayTypeDialogController extends GetxController {
}
TtsManage.speak("请出示二维码");
isRecognition.value = true;
onDiscernUser?.call(payUser.value);
// 面板可能已持有扫码枪监听;无人监听时才由弹框启动并记住启动者身份。
if (!BarcodeScanManage.instance.isListening) {
_qrListeningStartedBySelf = true;
@@ -563,7 +598,7 @@ class SelectPayTypeDialogController extends GetxController {
}
if (requestId != _qrCodeDispatchRequestId) return;
// 与人脸、饭卡识别一致,向业务层同步识别出的人员以重算额外支付。
onDiscernUser?.call(payUser.value);
await refreshRecognizedUserPricing();
isRecognition.value = false;
});
} catch (e) {
@@ -601,6 +636,7 @@ class SelectPayTypeDialogController extends GetxController {
Future<void> reidentifyCurrentUser() async {
if (!canReidentifyCurrentPayType ||
isSubmitting.value ||
isPricing.value ||
isRecognition.value) {
return;
}
@@ -685,7 +721,7 @@ class SelectPayTypeDialogController extends GetxController {
orderPayType.value = OrderPayType.WALLET_CARD;
globalQrCode = null;
}
onDiscernUser?.call(nextUser);
await refreshRecognizedUserPricing();
TtsManage.speak("识别饭卡成功");
} catch (error, stack) {
final message = error
@@ -1192,7 +1228,7 @@ class SelectPayTypeDialogController extends GetxController {
orderType = OrderType.BELOW;
faceRecognitionResult.value = event;
createdEvidenceSessionId = null;
onDiscernUser?.call(nextUser);
await refreshRecognizedUserPricing();
faceWorkspaceMessage.value = '';
} catch (e, task) {
if (createdEvidenceSessionId != null) {
@@ -3515,18 +3551,24 @@ class _CheckoutPaymentActions extends StatelessWidget {
final snapshotRevision = controller.checkoutSnapshotRevision.value;
final submitting = controller.isSubmitting.value;
final recognizing = controller.isRecognition.value;
final pricing = controller.isPricing.value;
final priceFailed = controller.pricingError.value.isNotEmpty;
final hasUser = controller.payUser.value != null;
final selectedType = controller.orderPayDisplayType.value;
final qrCodeScanSelected = selectedType == OrderPayDisplayType.qrCodeScan;
final label = submitting
? '支付提交中...'
: recognizing
? '识别进行中'
: qrCodeScanSelected
? '发起扫码支付'
: hasUser
? '确认支付 ${_paymentMoney(controller.checkoutSnapshot.payableAmount)}'
: '开始识别';
: pricing
? '正在读取收费规则...'
: recognizing
? '识别进行中'
: priceFailed
? '重试读取收费规则'
: qrCodeScanSelected
? '发起扫码支付'
: hasUser
? '确认支付 ${_paymentMoney(controller.checkoutSnapshot.payableAmount)}'
: '开始识别';
return KeyedSubtree(
key: ValueKey('checkout-payment-actions-$snapshotRevision'),
child: <Widget>[
@@ -3538,7 +3580,7 @@ class _CheckoutPaymentActions extends StatelessWidget {
SizedBox(width: 12.w),
_PaymentDialogButton(
label: label,
onTap: !paymentEnabled || recognizing || submitting
onTap: !paymentEnabled || recognizing || submitting || pricing
? null
: qrCodeScanSelected
? controller.doRecognition
+181 -33
View File
@@ -8,6 +8,8 @@ import 'package:get/get.dart';
import 'package:forui/forui.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/checkout_cart_repricing.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/checkout_price_coordinator.dart';
import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart';
class CanteenProductController extends GetxController with OrderMixin {
@@ -25,6 +27,18 @@ class CanteenProductController extends GetxController with OrderMixin {
late SysSetting appSetting;
var _discountSession = DiscountsTool.createSession();
final _priceCoordinator = CheckoutPriceCoordinator();
bool _checkoutSubmitting = false;
bool _checkoutOpen = false;
bool _pageClosed = false;
bool _catalogRefreshRequired = false;
void _invalidateDiscounts() {
_priceCoordinator.invalidate();
_discountSession.invalidate();
}
_initData() {
update(["canteen_product"]);
}
@@ -44,6 +58,8 @@ class CanteenProductController extends GetxController with OrderMixin {
@override
void onClose() {
_pageClosed = true;
_invalidateDiscounts();
super.onClose();
SubScreenPlugin.sendMsgToViceScreen(SubScreenAction.jumpSubBack.name);
// 读卡器
@@ -128,17 +144,36 @@ class CanteenProductController extends GetxController with OrderMixin {
// |下单|点击购买
Future<void> doPay() async {
if (_checkoutOpen || _checkoutSubmitting) return;
final validShopCar = _validateShopCar();
if (!validShopCar) return;
final snapshot = _createCheckoutSnapshot();
// 创建本地订单
await createSqlOrder();
await selectPayTypeDialog(
snapshot: snapshot,
onQuantityChanged: _changeCheckoutQuantity,
// 取消结算后重开时恢复本单已识别人员与钱包展示。
initialUser: payUser.value,
);
_checkoutOpen = true;
_invalidateDiscounts();
_discountSession = DiscountsTool.createSession();
try {
// 恢复已有人员时也先确认本次结算规则,避免展示上一单的加价。
EasyLoading.show(
status: '读取收费规则...', maskType: EasyLoadingMaskType.black);
try {
if (_catalogRefreshRequired) await _refreshCheckoutCatalog();
if (!await _computedAmountMealPrice() || _pageClosed) return;
} finally {
EasyLoading.dismiss();
}
final snapshot = _createCheckoutSnapshot();
if (await createSqlOrder() == null) return;
await selectPayTypeDialog(
snapshot: snapshot,
onQuantityChanged: _changeCheckoutQuantity,
initialUser: payUser.value,
);
} catch (error, stack) {
logger.e('|$_tag|准备结算失败|error:$error|stack:$stack|');
Toast.error(msg: '读取收费规则失败,请重试');
} finally {
_checkoutOpen = false;
_invalidateDiscounts();
}
}
/// 在结算弹框中按商品编码修改真实购物车数量,并返回包含最新金额的展示快照。
@@ -148,6 +183,7 @@ class CanteenProductController extends GetxController with OrderMixin {
String productServiceUniqueCode,
int quantity,
) async {
if (_checkoutSubmitting) return null;
final index = shopCardList.indexWhere(
(item) => item.productServiceUniqueCode == productServiceUniqueCode,
);
@@ -176,6 +212,7 @@ class CanteenProductController extends GetxController with OrderMixin {
// |+|或者|-|点击更改商品数量
Future<void> doComputedInputChange(int number, int index) async {
if (_checkoutSubmitting) return;
final current = shopCardList[index];
current.productNumber = number;
current.totalPrice = _computedProductTotalPrice(
@@ -188,13 +225,14 @@ class CanteenProductController extends GetxController with OrderMixin {
});
}
computedTotalPrice();
await _computedAmountMealPrice();
await _refreshDisplayedAmount();
shopCardList.refresh();
sendSubScreen();
}
// 点击商品
void doProduct(CanteenSpuEntity current) async {
if (_checkoutSubmitting) return;
final target = shopCardList.firstWhereOrNull((e) {
return e.productServiceUniqueCode == current.serviceUniqueCode;
});
@@ -221,7 +259,7 @@ class CanteenProductController extends GetxController with OrderMixin {
);
}
computedTotalPrice();
await _computedAmountMealPrice();
await _refreshDisplayedAmount();
shopCardList.refresh();
sendSubScreen();
}
@@ -232,10 +270,15 @@ class CanteenProductController extends GetxController with OrderMixin {
payTypeSelect.onRecognition = () async {};
// 识别出用户的回调
payTypeSelect.onDiscernUser = (payUser) async {
if (payUser == null) return;
if (_checkoutSubmitting || _pageClosed) return;
if (this.payUser.value?.userId != payUser?.userId) {
_discountSession.invalidate();
}
this.payUser.value = payUser;
if (_catalogRefreshRequired) await _refreshCheckoutCatalog();
// 计算额外的价格
await _computedAmountMealPrice();
final applied = await _computedAmountMealPrice();
if (!applied || _pageClosed) return;
sendSubScreen();
// 识别人员后额外支付可能变化,用最新金额刷新弹框冻结的快照。
payTypeSelect.updateCheckoutSnapshot(_createCheckoutSnapshot());
@@ -249,6 +292,8 @@ class CanteenProductController extends GetxController with OrderMixin {
qrCode,
faceRecognition,
) async {
if (_checkoutSubmitting || _pageClosed) return false;
final confirmedPayable = payTypeSelect.checkoutSnapshot.payableAmount;
final log =
"|$_tag|回调,识别到用户:|${user.toJson()}|orderType:|$orderType|;orderPayType:|$orderPayType|;orderPayDisplayType|$orderPayDisplayType|;";
logger.i(log);
@@ -258,7 +303,7 @@ class CanteenProductController extends GetxController with OrderMixin {
this.globalQrCode = qrCode;
pendingFaceRecognition.value = faceRecognition;
payUser.value = user;
final validPayUser = _validatePayUser();
final validPayUser = _validatePayUser(includeExtraPrice: false);
if (!validPayUser) {
final sessionId = faceRecognition?.sessionId;
if (sessionId != null) {
@@ -283,14 +328,29 @@ class CanteenProductController extends GetxController with OrderMixin {
);
var outcome = 'exception';
final accepted = await timing.run(() async {
_checkoutSubmitting = true;
try {
EasyLoading.show(
status: '支付中...',
maskType: EasyLoadingMaskType.black,
);
// 最后计算一次额外的价格,计时包含优惠 HTTP 及其前后的本地工作。
await timing.measure('discounts', _computedAmountMealPrice);
await timing.measure('local_order_update', updateSqlOrder);
if (_catalogRefreshRequired) await _refreshCheckoutCatalog();
// 新鲜规则直接复用;过期或上次失败则重取。最终计算作废更早的在途回调。
final applied =
await timing.measure('discounts', _computedAmountMealPrice);
if (!applied || _pageClosed) {
throw StateError('结算信息已变化,请重新确认');
}
payTypeSelect.updateCheckoutSnapshot(_createCheckoutSnapshot());
if (!_validatePayUser()) return false;
if (totalPrice.value + amountMealPrice.value != confirmedPayable) {
outcome = 'amount_changed';
Toast.error(msg: '收费金额已更新,请确认后再次支付');
return false;
}
final updated =
await timing.measure('local_order_update', updateSqlOrder);
if (!updated) return false;
final isNetwork = AppNetworkTool.isAutoNetwork();
final orderResult = await timing.measure(
'create_order',
@@ -304,7 +364,14 @@ class CanteenProductController extends GetxController with OrderMixin {
? 'success'
: 'failed';
return accepted;
} catch (error, stack) {
_discountSession.invalidate();
outcome = 'failed';
logger.e('|$_tag|结算未完成|error:$error|stack:$stack|');
Toast.error(msg: '结算失败,请检查人员及收费规则后重试');
return false;
} finally {
_checkoutSubmitting = false;
// 优惠查询、本地写库等前置步骤抛错时也必须撤销支付遮罩。
try {
EasyLoading.dismiss();
@@ -313,23 +380,28 @@ class CanteenProductController extends GetxController with OrderMixin {
}
}
});
if (!accepted) return false;
if (!accepted) {
if (outcome != 'amount_changed') _discountSession.invalidate();
return false;
}
putOffSendSubScreen();
_clearShopCard();
await _clearShopCard();
return true;
};
// |支付方式|点击|取消|
payTypeSelect.onCancel = () {};
}
bool _validatePayUser() {
bool _validatePayUser({bool includeExtraPrice = true}) {
bool flag = true;
if (ObjectUtil.isEmpty(payUser.value?.walletBalance)) {
TtsManage.speak("用户钱包不存在");
Toast.error(msg: "校验购物车失败,用户钱包不存在");
return false;
}
if (payUser.value!.walletBalance! < totalPrice.value) {
final payable =
totalPrice.value + (includeExtraPrice ? amountMealPrice.value : 0);
if (payUser.value!.walletBalance! < payable) {
TtsManage.speak("用户余额不足");
Toast.error(msg: "校验购物车失败,用户余额不足");
return false;
@@ -353,27 +425,103 @@ class CanteenProductController extends GetxController with OrderMixin {
}
// 计算额外的价格
Future<void> _computedAmountMealPrice() async {
var extraPrice = 0;
Future<bool> _computedAmountMealPrice() async {
final currentUser = payUser.value;
final userId = currentUser?.userId;
final currentHour = hourType.value.code;
final session = _discountSession;
final cartSignature = _cartPriceSignature();
// 外部匿名人员没有餐次优惠,避免带着上一名识别人员的额外支付结算。
final isRealUser = currentUser != null &&
!AppUserTool.isGenerateExternalUser(currentUser) &&
ObjectUtil.isNotEmpty(currentUser.userId);
if (isRealUser && ObjectUtil.isNotEmpty(hourType.value)) {
extraPrice = await DiscountsTool.computedExtraPrice(
userId: currentUser.userId ?? "",
shopCardList: shopCardList,
hourType: hourType.value.code,
);
return _priceCoordinator.calculate(
load: () async => isRealUser
? DiscountsTool.computedExtraPrice(
userId: userId!,
shopCardList: shopCardList,
hourType: currentHour,
session: session,
)
: 0,
isContextCurrent: () =>
!_pageClosed &&
identical(session, _discountSession) &&
payUser.value?.userId == userId &&
hourType.value.code == currentHour &&
_cartPriceSignature() == cartSignature,
apply: (extraPrice) {
if (amountMealPrice.value == extraPrice) return;
amountMealPrice.value = extraPrice;
sendSubScreen();
},
);
}
String _cartPriceSignature() => shopCardList
.map((item) =>
'${item.productServiceUniqueCode}:${item.productNumber}:${item.totalPrice}:${item.isPriceIncreased}')
.join('|');
Future<void> _refreshDisplayedAmount() async {
try {
await _computedAmountMealPrice();
} catch (error, stack) {
logger.e('|$_tag|更新收费金额失败|error:$error|stack:$stack|');
if (_checkoutOpen && Get.isRegistered<SelectPayTypeDialogController>()) {
Get.find<SelectPayTypeDialogController>().markPricingFailed();
}
Toast.error(msg: '读取收费规则失败,支付前将重新校验');
}
// 金额没变化就不重发副屏;变化时必须整体覆盖,包括重算后归零的情况。
if (amountMealPrice.value == extraPrice) return;
amountMealPrice.value = extraPrice;
sendSubScreen();
}
/// 服务端已明确拒绝旧报价,此时尚未扣款。刷新本单菜价及规则后只更新展示。
@override
Future<void> onTerminalPricingRejected() async {
_catalogRefreshRequired = true;
_invalidateDiscounts();
try {
await _refreshCheckoutCatalog();
if (!await _computedAmountMealPrice()) {
throw StateError('结算信息已变化,请重新确认');
}
Get.find<SelectPayTypeDialogController>()
.updateCheckoutSnapshot(_createCheckoutSnapshot());
} catch (_) {
if (!_pageClosed && Get.isRegistered<SelectPayTypeDialogController>()) {
Get.find<SelectPayTypeDialogController>().markPricingFailed();
}
rethrow;
}
}
Future<void> _refreshCheckoutCatalog() async {
final currentHour = hourType.value.code;
final currentUserId = payUser.value?.userId;
if (!AppNetworkTool.isAutoNetwork() ||
!await SyncProductTool.syncTodayCanteenProduct()) {
throw StateError('刷新菜品失败,请检查网络后重试');
}
final products = await appC.dataBase.getCanteenSpuDao.getAll(
'-1',
currentHour,
DateUtil.formatDate(DateTime.now()),
);
if (_pageClosed ||
currentHour != hourType.value.code ||
currentUserId != payUser.value?.userId) {
throw StateError('结算信息已变化,请重新确认');
}
final refreshed = CheckoutCartRepricing.refresh(shopCardList, products);
shopCardList.assignAll(refreshed);
computedTotalPrice();
_catalogRefreshRequired = false;
await getProduct();
}
Future<void> _clearShopCard() async {
_invalidateDiscounts();
_catalogRefreshRequired = false;
shopCardList.clear();
payUser.value = null;
totalPrice.value = 0;
+1 -1
View File
@@ -1,7 +1,7 @@
name: pj_supermarket_cloud
description: "智慧后勤-测试"
publish_to: "none"
version: 2.1.5+215
version: 2.1.6+216
environment:
sdk: ^3.6.0
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pj_supermarket_cloud/common/models/database/database_enum_models.dart';
import 'package:pj_supermarket_cloud/common/services/database/entity/canteen_spu_entity.dart';
import 'package:pj_supermarket_cloud/common/services/database/entity/shop_card_entity.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/checkout_cart_repricing.dart';
ShopCardEntity _cart(String code, {int quantity = 3}) => ShopCardEntity(
id: 41,
orderId: 52,
productServiceUniqueCode: code,
productName: '原菜名',
productPic: 'old.jpg',
productPrice: 200,
nature: ProductNature.DEFAULT,
productNumber: quantity,
productWidget: 0,
totalPrice: 200 * quantity,
isPriceIncreased: false,
);
CanteenSpuEntity _product(
String code, {
int price = 350,
bool increased = true,
String? image = 'new.jpg',
}) =>
CanteenSpuEntity(
serviceUniqueCode: code,
classifyServiceUniqueCode: 'category',
hourType: ProductHourType.NOON,
saleDate: DateTime(2026, 10, 23),
name: '最新菜名',
price: price,
image: image,
isDefault: false,
status: 1,
sort: 0,
isPriceIncreased: increased,
createTime: DateTime(2026, 10, 23),
updateTime: DateTime(2026, 10, 23),
);
void main() {
test('按服务端编码刷新基础价、加价标记和展示,保留数量及本地订单关联', () {
final original = _cart('dish-a');
final cart = [original];
final product = _product('dish-a');
final refreshed = CheckoutCartRepricing.refresh(cart, [product]);
expect(identical(refreshed, cart), isFalse);
expect(identical(refreshed.single, original), isFalse);
final next = refreshed.single;
expect(next.id, 41);
expect(next.orderId, 52);
expect(next.productServiceUniqueCode, 'dish-a');
expect(next.productNumber, 3);
expect(next.nature, ProductNature.DEFAULT);
expect(next.productWidget, 0);
expect(next.productPrice, 350);
expect(next.totalPrice, 1050);
expect(next.isPriceIncreased, isTrue);
expect(next.productName, '最新菜名');
expect(next.productPic, 'new.jpg');
expect(original.productPrice, 200);
expect(original.totalPrice, 600);
expect(original.isPriceIncreased, isFalse);
expect(original.productName, '原菜名');
expect(original.productPic, 'old.jpg');
expect(product.price, 350);
});
test('下调价格、关闭加价标记与空图片也完整覆盖,商品顺序不受目录顺序影响', () {
final first = _cart('dish-a', quantity: 2)..isPriceIncreased = true;
final second = _cart('dish-b', quantity: 4);
final refreshed = CheckoutCartRepricing.refresh(
[first, second],
[
_product('dish-b', price: 500),
_product('dish-a', price: 100, increased: false, image: null),
],
);
expect(refreshed.map((item) => item.productServiceUniqueCode),
['dish-a', 'dish-b']);
expect(refreshed.first.productPrice, 100);
expect(refreshed.first.totalPrice, 200);
expect(refreshed.first.isPriceIncreased, isFalse);
expect(refreshed.first.productPic, '');
expect(refreshed.last.productNumber, 4);
expect(refreshed.last.totalPrice, 2000);
expect(first.isPriceIncreased, isTrue);
expect(first.productPic, 'old.jpg');
});
test('任一菜品缺失时明确失败,不删菜或部分修改原购物车', () {
final cart = [_cart('dish-a'), _cart('missing')];
final before = cart.map((item) => item.toJson()).toList();
expect(
() => CheckoutCartRepricing.refresh(cart, [_product('dish-a')]),
throwsA(isA<StateError>()
.having((error) => error.message, 'message', contains('重新选择'))),
);
expect(cart.map((item) => item.toJson()).toList(), before);
expect(cart, hasLength(2));
});
test('空购物车产生空新列表', () {
final cart = <ShopCardEntity>[];
final refreshed = CheckoutCartRepricing.refresh(cart, [_product('dish-a')]);
expect(refreshed, isEmpty);
expect(identical(refreshed, cart), isFalse);
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
class _PricingGateTestApp extends AppControl {
@override
void onClose() {}
}
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
late Directory temporaryDirectory;
late SelectPayTypeDialogController controller;
setUpAll(() async {
temporaryDirectory =
await Directory.systemTemp.createTemp('pj-dialog-pricing-gate-');
for (final channel in [
'plugins.flutter.io/path_provider',
'external_path'
]) {
binding.defaultBinaryMessenger.setMockMethodCallHandler(
MethodChannel(channel),
(_) async => temporaryDirectory.path,
);
}
Get.put<AppControl>(_PricingGateTestApp());
await logger.init;
});
setUp(() {
// Construct directly: registration would start onInit and terminal settings/hardware work.
controller = SelectPayTypeDialogController()
..appSetting = SysSetting(sysType: SysType.CANTEEN);
controller.payUser.value = ServiceUser(
userId: '100',
username: 'test',
walletId: '200',
walletBalance: 1000,
);
controller.orderPayType.value = OrderPayType.FACE;
controller.orderPayDisplayType.value = OrderPayDisplayType.face;
});
tearDown(() {
// These tests never start recognition; avoid the production onClose hardware cleanup.
controller.payTypeRadioC.dispose();
});
tearDownAll(() async {
await logger.close();
Get.delete<AppControl>(force: true);
await temporaryDirectory.delete(recursive: true);
});
test('pending rule lookup blocks submission until a later explicit click',
() async {
final rulesReady = Completer<void>();
var submissions = 0;
controller.onDiscernUser = (_) => rulesReady.future;
controller.onSubmit = (_, __, ___, ____, _____, ______) async {
submissions++;
expect(controller.isSubmitting.value, isTrue);
return false;
};
final refresh = controller.refreshRecognizedUserPricing();
expect(controller.isPricing.value, isTrue);
await controller.doSubmit();
expect(submissions, 0);
expect(controller.isSubmitting.value, isFalse);
rulesReady.complete();
await refresh;
expect(controller.isPricing.value, isFalse);
expect(controller.pricingError.value, isEmpty);
expect(submissions, 0);
await controller.doSubmit();
expect(submissions, 1);
expect(controller.isSubmitting.value, isFalse);
});
test('failed rule lookup retries rules and requires another click to pay',
() async {
var ruleReads = 0;
var submissions = 0;
var rejectRules = true;
controller.onDiscernUser = (_) async {
ruleReads++;
if (rejectRules) throw StateError('injected rule failure');
};
controller.onSubmit = (_, __, ___, ____, _____, ______) async {
submissions++;
return false;
};
await controller.refreshRecognizedUserPricing();
expect(controller.pricingError.value, isNotEmpty);
expect(controller.isPricing.value, isFalse);
expect(submissions, 0);
rejectRules = false;
await controller.doSubmit();
expect(ruleReads, 2);
expect(controller.pricingError.value, isEmpty);
expect(submissions, 0);
await controller.doSubmit();
expect(ruleReads, 2);
expect(submissions, 1);
expect(controller.isSubmitting.value, isFalse);
});
test('synchronous recognized-user callback remains valid for supermarket',
() async {
controller.appSetting = SysSetting(sysType: SysType.SUPERMARKET);
var ruleReads = 0;
var submissions = 0;
controller.onDiscernUser = (user) {
expect(user?.userId, '100');
ruleReads++;
};
controller.onSubmit = (_, __, ___, ____, _____, ______) async {
submissions++;
return false;
};
await controller.refreshRecognizedUserPricing();
expect(ruleReads, 1);
expect(controller.isPricing.value, isFalse);
expect(controller.pricingError.value, isEmpty);
await controller.doSubmit();
expect(submissions, 1);
expect(controller.isSubmitting.value, isFalse);
});
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/checkout_discount_session.dart';
const _key = CheckoutDiscountKey(tenantId: '1', hardId: '10', userId: '100');
void main() {
test('同结算的并发优惠查询合并,成功规则供后续计算复用', () async {
final session = CheckoutDiscountSession<int>();
final source = Completer<List<int>>();
var calls = 0;
Future<List<int>> lookup() {
calls++;
return source.future;
}
final first = session.load(key: _key, lookup: lookup);
final second = session.load(key: _key, lookup: lookup);
expect(identical(first, second), isTrue);
source.complete([7]);
expect(await first, [7]);
expect(await second, [7]);
expect(await session.load(key: _key, lookup: lookup), [7]);
expect(calls, 1);
});
test('成功的空优惠同样缓存,不会重复请求或变成失败结果', () async {
final session = CheckoutDiscountSession<int>();
var calls = 0;
Future<List<int>> lookup() async {
calls++;
return [];
}
final empty = await session.load(key: _key, lookup: lookup);
expect(empty, isEmpty);
expect(() => empty.add(1), throwsUnsupportedError);
expect(await session.load(key: _key, lookup: lookup), isEmpty);
expect(calls, 1);
});
test('过期刷新失败向上传播,不返回旧优惠,下一次会重新查询', () async {
var elapsed = Duration.zero;
final session = CheckoutDiscountSession<int>(elapsed: () => elapsed);
var calls = 0;
final failure = StateError('lookup rejected');
Future<List<int>> lookup() async {
calls++;
if (calls == 2) throw failure;
return [calls];
}
expect(await session.load(key: _key, lookup: lookup), [1]);
elapsed = const Duration(seconds: 30);
await expectLater(
session.load(key: _key, lookup: lookup), throwsA(same(failure)));
expect(await session.load(key: _key, lookup: lookup), [3]);
expect(calls, 3);
});
test('租户、设备、人员和结算实例之间不共用规则', () async {
final session = CheckoutDiscountSession<int>();
var calls = 0;
Future<List<int>> lookup() async => [++calls];
for (final key in [
_key,
const CheckoutDiscountKey(tenantId: '2', hardId: '10', userId: '100'),
const CheckoutDiscountKey(tenantId: '1', hardId: '11', userId: '100'),
const CheckoutDiscountKey(tenantId: '1', hardId: '10', userId: '101'),
]) {
await session.load(key: key, lookup: lookup);
}
await CheckoutDiscountSession<int>().load(key: _key, lookup: lookup);
expect(calls, 5);
});
test('失效前的迟到结果不能覆盖新请求或清掉新查询', () async {
final session = CheckoutDiscountSession<int>();
final old = Completer<List<int>>();
final next = Completer<List<int>>();
var calls = 0;
Future<List<int>> lookup() => ++calls == 1 ? old.future : next.future;
final first = session.load(key: _key, lookup: lookup);
session.invalidate();
final second = session.load(key: _key, lookup: lookup);
old.complete([1]);
expect(await first, [1]);
expect(identical(session.load(key: _key, lookup: lookup), second), isTrue);
next.complete([2]);
expect(await second, [2]);
expect(await session.load(key: _key, lookup: lookup), [2]);
expect(calls, 2);
});
test('旧代次的迟到成功结果不能执行离线快照持久化', () async {
final session = CheckoutDiscountSession<int>();
final old = Completer<List<int>>();
final persisted = <List<int>>[];
Future<void> persist(List<int> rules) async => persisted.add(rules);
final oldRequest = session.load(
key: _key,
lookup: () => old.future,
onAccepted: persist,
);
session.invalidate();
expect(
await session.load(
key: _key, lookup: () async => [2], onAccepted: persist),
[2]);
old.complete([1]);
expect(await oldRequest, [1]);
expect(persisted, [
[2]
]);
});
test('同一人员切换设备或租户时,旧上下文查询自动失效且不能写离线规则', () async {
for (final nextKey in [
const CheckoutDiscountKey(tenantId: '1', hardId: '11', userId: '100'),
const CheckoutDiscountKey(tenantId: '2', hardId: '10', userId: '100'),
]) {
final session = CheckoutDiscountSession<int>();
final old = Completer<List<int>>();
final persisted = <List<int>>[];
Future<void> persist(List<int> rules) async => persisted.add(rules);
final oldRequest = session.load(
key: _key,
lookup: () => old.future,
onAccepted: persist,
);
expect(
await session.load(
key: nextKey,
lookup: () async => [2],
onAccepted: persist,
),
[2]);
old.complete([1]);
expect(await oldRequest, [1]);
expect(persisted, [
[2]
]);
// 切回原上下文也必须重新读取,不能复用其已经作废的迟到结果。
expect(
await session.load(
key: _key,
lookup: () async => [3],
onAccepted: persist,
),
[3]);
expect(persisted, [
[2],
[3]
]);
}
});
test('有效期从成功完成计算,单调时钟回退不会提前复用过期结果', () async {
var elapsed = Duration.zero;
final session = CheckoutDiscountSession<int>(elapsed: () => elapsed);
final source = Completer<List<int>>();
final first = session.load(key: _key, lookup: () => source.future);
elapsed = const Duration(seconds: 29);
source.complete([1]);
await first;
var calls = 0;
Future<List<int>> refresh() async => [++calls + 1];
elapsed = const Duration(seconds: 50);
expect(await session.load(key: _key, lookup: refresh), [1]);
elapsed = const Duration(seconds: 59);
expect(await session.load(key: _key, lookup: refresh), [2]);
elapsed = const Duration(seconds: 5);
expect(await session.load(key: _key, lookup: refresh), [2]);
expect(calls, 1);
});
}
+89
View File
@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/checkout_price_coordinator.dart';
void main() {
test('A 的慢响应不能覆盖 B 的金额', () async {
final coordinator = CheckoutPriceCoordinator();
final first = Completer<int>();
var amount = 0;
final old = coordinator.calculate(
load: () => first.future,
isContextCurrent: () => true,
apply: (value) => amount = value,
);
expect(
await coordinator.calculate(
load: () async => 200,
isContextCurrent: () => true,
apply: (value) => amount = value,
),
isTrue);
first.complete(100);
expect(await old, isFalse);
expect(amount, 200);
});
test('购物车变化或关闭结算后不写回在途金额', () async {
for (final invalidate in [false, true]) {
final coordinator = CheckoutPriceCoordinator();
final pending = Completer<int>();
var contextCurrent = true;
var applied = false;
final result = coordinator.calculate(
load: () => pending.future,
isContextCurrent: () => contextCurrent,
apply: (_) => applied = true,
);
if (invalidate) {
coordinator.invalidate();
} else {
contextCurrent = false;
}
pending.complete(150);
expect(await result, isFalse);
expect(applied, isFalse);
}
});
test('新查询失败不会让旧查询随后恢复为有效金额', () async {
final coordinator = CheckoutPriceCoordinator();
final oldResponse = Completer<int>();
var applied = false;
final old = coordinator.calculate(
load: () => oldResponse.future,
isContextCurrent: () => true,
apply: (_) => applied = true,
);
await expectLater(
coordinator.calculate(
load: () async => throw StateError('查询失败'),
isContextCurrent: () => true,
apply: (_) => applied = true,
),
throwsStateError);
oldResponse.complete(100);
expect(await old, isFalse);
expect(applied, isFalse);
});
test('已作废人员的失败不会破坏当前人员已确认的金额', () async {
final coordinator = CheckoutPriceCoordinator();
final pending = Completer<int>();
var amount = 0;
final old = coordinator.calculate(
load: () => pending.future,
isContextCurrent: () => true,
apply: (value) => amount = value,
);
await coordinator.calculate(
load: () async => 200,
isContextCurrent: () => true,
apply: (value) => amount = value,
);
pending.completeError(StateError('旧请求失败'));
expect(await old, isFalse);
expect(amount, 200);
});
}
+316
View File
@@ -0,0 +1,316 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' as dio;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:pj_supermarket_cloud/common/index.dart';
import 'package:sqflite/sqflite.dart' as sqlite;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
class _DiscountTestApp extends AppControl {
@override
void onClose() {}
}
/// 所有请求在这里结束,不访问真实优惠、钱包或支付接口。
class _DiscountAdapter implements dio.HttpClientAdapter {
_DiscountAdapter(this.respond);
final Future<dio.ResponseBody> Function(dio.RequestOptions) respond;
final requests = <dio.RequestOptions>[];
@override
Future<dio.ResponseBody> fetch(dio.RequestOptions options,
Stream<Uint8List>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return respond(options);
}
@override
void close({bool force = false}) {}
}
dio.ResponseBody _json(Object? data, {int code = 0}) =>
dio.ResponseBody.fromString(
jsonEncode({'code': code, 'msg': 'test response', 'data': data}),
200,
headers: {
'content-type': ['application/json']
},
);
DiscountsItem _rule({
bool allProducts = true,
int base = 0,
double rate = 1.5,
ProductHourType hour = ProductHourType.MORNING,
}) =>
DiscountsItem(
name: 'test rule',
hourType: hour,
type: null,
basePrice: base,
specialDishEnable: allProducts,
extraPer: rate,
);
ShopCardEntity _cart(int price, {bool increased = true}) => ShopCardEntity(
orderId: -1,
productServiceUniqueCode: '$price',
productName: 'test',
productPic: '',
productPrice: price,
nature: ProductNature.DEFAULT,
productNumber: 1,
productWidget: 0,
totalPrice: price,
isPriceIncreased: increased,
);
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
late Directory directory;
late AppDatabase database;
late _DiscountTestApp app;
late dio.HttpClientAdapter originalAdapter;
late String originalBaseUrl;
final muteToasts = dio.InterceptorsWrapper(onRequest: (options, handler) {
// 保持测试无界面;另行断言生产 API 自己设置了 rejectBusinessError 标记。
options.extra[HttpManage.silentRequestKey] = true;
handler.next(options);
});
setUpAll(() async {
directory = await Directory.systemTemp.createTemp('pj-discounts-test-');
for (final channel in [
'plugins.flutter.io/path_provider',
'external_path'
]) {
binding.defaultBinaryMessenger.setMockMethodCallHandler(
MethodChannel(channel),
(_) async => directory.path,
);
}
sqfliteFfiInit();
sqlite.databaseFactory = databaseFactoryFfiNoIsolate;
database = await $FloorAppDatabase.inMemoryDatabaseBuilder().build();
app = _DiscountTestApp()..dataBase = database;
Get.put<AppControl>(app);
await database.getSettingDao.save(SettingEntity(
sysJson: jsonEncode(
SysSetting(sysType: SysType.CANTEEN, serviceId: '10').toJson()),
userJson: jsonEncode(
(UserSetting(username: '', password: '')..tenantId = '1').toJson()),
));
await logger.init;
final client = HttpManage().dio;
originalAdapter = client.httpClientAdapter;
originalBaseUrl = client.options.baseUrl;
client.options.baseUrl = 'https://discount-test.invalid/api';
client.interceptors.add(muteToasts);
});
setUp(() async {
app.isNetwork.value = true;
await database.getUserDao.delAll();
await database.getUserDao.save(UserEntity(
userId: '100',
nickname: 'test',
walletBalance: 1000,
discountsList: [_rule()],
discountsUpdateTime: DateTime(2026, 1, 1),
));
});
tearDownAll(() async {
final client = HttpManage().dio;
client.interceptors.remove(muteToasts);
client.httpClientAdapter = originalAdapter;
client.options.baseUrl = originalBaseUrl;
await logger.close();
await AppSettingTool.removeSettingCache();
await database.close();
Get.delete<AppControl>(force: true);
await directory.delete(recursive: true);
});
test('空优惠成功后清除离线旧规则,后续同结算不再查网络', () async {
final adapter = _DiscountAdapter((_) async => _json([]));
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
expect(
await DiscountsTool.updateUserDiscountsList(
userId: '100', session: session),
isEmpty);
expect(
await DiscountsTool.updateUserDiscountsList(
userId: '100', session: session),
isEmpty);
expect(adapter.requests, hasLength(1));
final stored = await database.getUserDao.findByUserId('100');
expect(stored!.discountsList, isEmpty);
expect(stored.discountsUpdateTime, isNot(DateTime(2026, 1, 1)));
app.isNetwork.value = false;
expect(await DiscountsTool.updateUserDiscountsList(userId: '100'), isEmpty);
expect(adapter.requests, hasLength(1));
});
test('在线业务拒绝严格抛错、不按零优惠、不回退旧缓存,重试重新请求', () async {
final adapter = _DiscountAdapter((_) async => _json(null, code: 912));
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
for (var i = 0; i < 2; i++) {
await expectLater(
DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: [_cart(1000)],
hourType: ProductHourType.MORNING.code,
session: session),
throwsA(isA<dio.DioException>()),
);
}
expect(adapter.requests, hasLength(2));
expect(
adapter.requests.every((request) =>
request.extra[HttpManage.rejectBusinessErrorKey] == true),
isTrue);
expect((await database.getUserDao.findByUserId('100'))!.discountsList,
hasLength(1));
});
test('异常空返回不能当作成功的空优惠', () async {
final adapter = _DiscountAdapter((_) async => _json(null));
HttpManage().dio.httpClientAdapter = adapter;
await expectLater(DiscountsTool.updateUserDiscountsList(userId: '100'),
throwsFormatException);
expect((await database.getUserDao.findByUserId('100'))!.discountsList,
hasLength(1));
});
test('等待规则时冻结商品金额,同结算改变数量仅重算不再次查询', () async {
final source = Completer<dio.ResponseBody>();
final adapter = _DiscountAdapter((_) => source.future);
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
final cart = [_cart(1000)];
final first = DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: cart,
hourType: ProductHourType.MORNING.code,
session: session);
cart.single.totalPrice = 2000;
source.complete(_json([_rule().toJson()]));
expect(await first, 500);
expect(
await DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: cart,
hourType: ProductHourType.MORNING.code,
session: session),
1000);
expect(adapter.requests, hasLength(1));
});
test('明确离线继续使用原本缓存而不访问网络', () async {
final adapter =
_DiscountAdapter((_) async => throw StateError('must not request'));
HttpManage().dio.httpClientAdapter = adapter;
app.isNetwork.value = false;
expect(
await DiscountsTool.computedExtraPrice(
userId: '100',
shopCardList: [_cart(1000)],
hourType: ProductHourType.MORNING.code),
500);
expect(adapter.requests, isEmpty);
});
test('刷新人员余额时保留已有优惠规则和更新时间', () async {
await SyncUserTool.storageUser(
user: ServiceUser(userId: '100', username: 'test', walletBalance: 900));
final stored = await database.getUserDao.findByUserId('100');
expect(stored!.walletBalance, 900);
expect(stored.discountsList, hasLength(1));
expect(stored.discountsUpdateTime, DateTime(2026, 1, 1));
});
test('旧结算网络结果迟到时不能把新结算的空优惠覆盖回旧规则', () async {
final started = Completer<void>();
final oldResponse = Completer<dio.ResponseBody>();
var calls = 0;
final adapter = _DiscountAdapter((_) {
if (++calls == 1) {
started.complete();
return oldResponse.future;
}
return Future.value(_json([]));
});
HttpManage().dio.httpClientAdapter = adapter;
final session = DiscountsTool.createSession();
final old =
DiscountsTool.updateUserDiscountsList(userId: '100', session: session);
await started.future;
session.invalidate();
expect(
await DiscountsTool.updateUserDiscountsList(
userId: '100', session: session),
isEmpty);
oldResponse.complete(_json([_rule(rate: 2).toJson()]));
expect(await old, hasLength(1));
expect((await database.getUserDao.findByUserId('100'))!.discountsList,
isEmpty);
});
test('人员读取旧快照期间更新优惠,余额与新规则不会互相覆盖', () async {
final updatedAt = DateTime(2026, 10, 23, 16, 0);
// 人员刷新先发起读取;随后的规则 UPDATE 可以排在其读取和写入之间。
final profile = SyncUserTool.storageUser(
user: ServiceUser(
userId: '100',
username: 'test',
walletBalance: 700,
));
final discounts = database.getUserDao.updateDiscounts(
'100',
jsonEncode([_rule(rate: 2).toJson()]),
updatedAt,
);
await Future.wait([profile, discounts]);
final stored = (await database.getUserDao.findByUserId('100'))!;
expect(stored.walletBalance, 700);
expect(stored.discountsList!.single.extraPer, 2);
expect(stored.discountsUpdateTime, updatedAt);
// 反向更新规则同样不能把更新后的余额改回请求前的值。
await database.getUserDao.updateDiscounts('100', '[]', updatedAt);
final cleared = (await database.getUserDao.findByUserId('100'))!;
expect(cleared.walletBalance, 700);
expect(cleared.discountsList, isEmpty);
});
test('原收费算法保留餐段、特色菜范围、基数边界和逐规则四舍五入', () {
final rules = [
_rule(base: 1000, rate: 1.5),
_rule(allProducts: false, base: 0, rate: 1.1),
_rule(rate: 0.8),
_rule(hour: ProductHourType.NOON),
];
expect(
DiscountsTool.computeExtraPriceFromRules(
discountsItems: rules,
hourType: ProductHourType.MORNING.code,
totalPrice: 1000,
increasedPrice: 555),
56);
expect(
DiscountsTool.computeExtraPriceFromRules(
discountsItems: rules,
hourType: ProductHourType.MORNING.code,
totalPrice: 1001,
increasedPrice: 555),
557);
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pj_supermarket_cloud/common/utils/discounts/terminal_discount_protocol.dart';
import 'package:pj_supermarket_cloud/common/utils/order/terminal_order_recovery.dart';
void main() {
DioException response(int status, Object? body) {
final request = RequestOptions();
return DioException(
requestOptions: request,
type: DioExceptionType.badResponse,
response:
Response(requestOptions: request, statusCode: status, data: body),
);
}
test('定价拒绝立即返回并保留重新确认所需证据,不进入超时对账', () {
final error = response(409, {'code': 40901, 'data': null});
expect(TerminalDiscountProtocol.isPricingRejected(error), isTrue);
expect(TerminalOrderRecovery.shouldQuery(error), isFalse);
});
test('其他冲突和未知支付错误不误判为定价拒绝', () {
for (final error in [
response(409, {'code': 1}),
response(409, 'conflict'),
response(500, {'code': 40901}),
DioException(
requestOptions: RequestOptions(),
type: DioExceptionType.receiveTimeout),
StateError('unexpected'),
]) {
expect(TerminalDiscountProtocol.isPricingRejected(error), isFalse);
}
expect(TerminalOrderRecovery.shouldQuery(response(500, null)), isTrue);
});
}