feat:消费机支付卡顿的问题
This commit is contained in:
1 parent
531815517a
commit
6ec171d262
21 files changed
+1470
-98
No files matched your search
@@ -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为版本号(内部比较)
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user