From 6ec171d262ddb64637cb5fc86a23132eed4ec5c6 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 23 Oct 2026 17:13:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B6=88=E8=B4=B9=E6=9C=BA=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E5=8D=A1=E9=A1=BF=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- lib/common/api/wallet_api.dart | 10 +- .../services/database/dao/user_dao.dart | 12 + lib/common/services/database/database.g.dart | 11 + .../discounts/checkout_cart_repricing.dart | 36 ++ .../discounts/checkout_discount_session.dart | 105 ++++++ .../discounts/checkout_price_coordinator.dart | 24 ++ .../utils/discounts/discounts_tool.dart | 101 ++++-- .../discounts/terminal_discount_protocol.dart | 12 + .../utils/order/payment_timing_trace.dart | 1 + lib/common/utils/sync/sync_user_tool.dart | 18 +- .../widgets/order/mixin/order_mixin.dart | 73 +++- .../widgets/order/select_pay_type_dialog.dart | 74 +++- lib/pages/canteen_product/controller.dart | 214 ++++++++++-- pubspec.yaml | 2 +- test/checkout_cart_repricing_test.dart | 113 +++++++ test/checkout_dialog_pricing_gate_test.dart | 141 ++++++++ test/checkout_discount_session_test.dart | 177 ++++++++++ test/checkout_price_coordinator_test.dart | 89 +++++ test/discounts_tool_test.dart | 316 ++++++++++++++++++ test/terminal_discount_protocol_test.dart | 37 ++ 21 files changed, 1470 insertions(+), 98 deletions(-) create mode 100644 lib/common/utils/discounts/checkout_cart_repricing.dart create mode 100644 lib/common/utils/discounts/checkout_discount_session.dart create mode 100644 lib/common/utils/discounts/checkout_price_coordinator.dart create mode 100644 lib/common/utils/discounts/terminal_discount_protocol.dart create mode 100644 test/checkout_cart_repricing_test.dart create mode 100644 test/checkout_dialog_pricing_gate_test.dart create mode 100644 test/checkout_discount_session_test.dart create mode 100644 test/checkout_price_coordinator_test.dart create mode 100644 test/discounts_tool_test.dart create mode 100644 test/terminal_discount_protocol_test.dart diff --git a/README.md b/README.md index 1543ad6..96e06ec 100644 --- a/README.md +++ b/README.md @@ -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为版本号(内部比较) ``` diff --git a/lib/common/api/wallet_api.dart b/lib/common/api/wallet_api.dart index b15cc8a..d9c4f91 100644 --- a/lib/common/api/wallet_api.dart +++ b/lib/common/api/wallet_api.dart @@ -1,15 +1,21 @@ import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:dio/dio.dart' as dio; abstract class WalletApi { /// 优惠列表 - static Future?> getDiscountsListByUserId( + static Future> 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(); } diff --git a/lib/common/services/database/dao/user_dao.dart b/lib/common/services/database/dao/user_dao.dart index 5bf556c..b77fd79 100644 --- a/lib/common/services/database/dao/user_dao.dart +++ b/lib/common/services/database/dao/user_dao.dart @@ -23,6 +23,18 @@ abstract class UserDao { @update Future batchEdit(List entitys); + /// 优惠成功结果只更新规则快照,不能覆盖同时刷新的钱包余额。 + @Query(''' + UPDATE user_record + SET discountsList = :rulesJson, discountsUpdateTime = :updatedAt + WHERE userId = :userId + ''') + Future updateDiscounts( + String userId, + String rulesJson, + DateTime updatedAt, + ); + @Query(''' SELECT * FROM user_record WHERE diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 1502bd0..eb4c81a 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -1820,6 +1820,17 @@ class _$UserDao extends UserDao { await _queryAdapter.queryNoReturn('DELETE FROM user_record'); } + @override + Future 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 findCount(String keyword) async { return _queryAdapter.query( diff --git a/lib/common/utils/discounts/checkout_cart_repricing.dart b/lib/common/utils/discounts/checkout_cart_repricing.dart new file mode 100644 index 0000000..26c2a6c --- /dev/null +++ b/lib/common/utils/discounts/checkout_cart_repricing.dart @@ -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 refresh( + List cartItems, + List 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); + } +} diff --git a/lib/common/utils/discounts/checkout_discount_session.dart b/lib/common/utils/discounts/checkout_discount_session.dart new file mode 100644 index 0000000..a3b252c --- /dev/null +++ b/lib/common/utils/discounts/checkout_discount_session.dart @@ -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 { + 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> _successful = {}; + final Map>> _running = {}; + Duration _lastElapsed = Duration.zero; + CheckoutDiscountKey? _context; + int _generation = 0; + + Future> load({ + required CheckoutDiscountKey key, + required Future> Function() lookup, + Future Function(List 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> request; + request = Future>.sync(lookup).then((rules) async { + final result = List.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 { + const _DiscountRulesEntry(this.rules, this.completedAt); + + final List rules; + final Duration completedAt; +} diff --git a/lib/common/utils/discounts/checkout_price_coordinator.dart b/lib/common/utils/discounts/checkout_price_coordinator.dart new file mode 100644 index 0000000..12ed4b1 --- /dev/null +++ b/lib/common/utils/discounts/checkout_price_coordinator.dart @@ -0,0 +1,24 @@ +/// 只允许最新一次结算计算写回金额。关闭、换人或清空购物车时作废在途结果。 +class CheckoutPriceCoordinator { + int _revision = 0; + + void invalidate() => _revision++; + + Future calculate({ + required Future 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; + } +} diff --git a/lib/common/utils/discounts/discounts_tool.dart b/lib/common/utils/discounts/discounts_tool.dart index b1bc2b8..30a1a48 100644 --- a/lib/common/utils/discounts/discounts_tool.dart +++ b/lib/common/utils/discounts/discounts_tool.dart @@ -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 createSession({ + Duration maxAge = const Duration(seconds: 30), + Duration Function()? elapsed, + }) => + CheckoutDiscountSession(maxAge: maxAge, elapsed: elapsed); /// 更新用户优惠列表 - static Future?> updateUserDiscountsList({ + static Future> updateUserDiscountsList({ required String userId, + CheckoutDiscountSession? session, }) async { final app = Get.find(); 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> 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 shopCardList, required String hourType, + CheckoutDiscountSession? 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(0, (sum, e) => sum + e.totalPrice); + final increasedPrice = shopCardList + .where((e) => e.isPriceIncreased) + .fold(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 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(0, (sum, e) => sum + e.totalPrice); - extraPrice += item.computedDiscountsExtraPrice(totalPrice); + extraPrice += item.computedDiscountsExtraPrice( + item.specialDishEnable ? totalPrice : increasedPrice, + ); } return extraPrice; } diff --git a/lib/common/utils/discounts/terminal_discount_protocol.dart b/lib/common/utils/discounts/terminal_discount_protocol.dart new file mode 100644 index 0000000..655777d --- /dev/null +++ b/lib/common/utils/discounts/terminal_discount_protocol.dart @@ -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'; + } +} diff --git a/lib/common/utils/order/payment_timing_trace.dart b/lib/common/utils/order/payment_timing_trace.dart index 2827d39..5cb74b3 100644 --- a/lib/common/utils/order/payment_timing_trace.dart +++ b/lib/common/utils/order/payment_timing_trace.dart @@ -124,6 +124,7 @@ class PaymentTimingTrace { } final safeOutcome = const { 'success', + 'amount_changed', 'failed', 'exception', 'offline_accepted', diff --git a/lib/common/utils/sync/sync_user_tool.dart b/lib/common/utils/sync/sync_user_tool.dart index 6623a1f..e58bbdb 100644 --- a/lib/common/utils/sync/sync_user_tool.dart +++ b/lib/common/utils/sync/sync_user_tool.dart @@ -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) { diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index b1e2cb8..7757c4c 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -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 pendingFaceRecognition = Rxn(); + /// 明确的收费拒绝没有发生扣款,由具体收银页面刷新规则和商品快照后等待再次确认。 + Future onTerminalPricingRejected() async {} + final appC = Get.find(); /// **************** start 需要重制 start **************** @@ -311,6 +315,23 @@ mixin OrderMixin { /// 准备并提交原协议订单;网络响应丢失时先只读对账,确认后统一完成本地状态。 Future _createApiOrder() 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> 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 >[]; 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; diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 59b6a13..5c641bc 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -78,7 +78,7 @@ class SelectPayTypeDialogController extends GetxController { VoidCallback? onCancel; // 识别出用户的回调 - Function(ServiceUser?)? onDiscernUser; + FutureOr 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 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(); @@ -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 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 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: [ @@ -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 diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index 29b2d8b..08395c0 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -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 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 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 _computedAmountMealPrice() async { - var extraPrice = 0; + Future _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 _refreshDisplayedAmount() async { + try { + await _computedAmountMealPrice(); + } catch (error, stack) { + logger.e('|$_tag|更新收费金额失败|error:$error|stack:$stack|'); + if (_checkoutOpen && Get.isRegistered()) { + Get.find().markPricingFailed(); + } + Toast.error(msg: '读取收费规则失败,支付前将重新校验'); } - // 金额没变化就不重发副屏;变化时必须整体覆盖,包括重算后归零的情况。 - if (amountMealPrice.value == extraPrice) return; - amountMealPrice.value = extraPrice; - sendSubScreen(); + } + + /// 服务端已明确拒绝旧报价,此时尚未扣款。刷新本单菜价及规则后只更新展示。 + @override + Future onTerminalPricingRejected() async { + _catalogRefreshRequired = true; + _invalidateDiscounts(); + try { + await _refreshCheckoutCatalog(); + if (!await _computedAmountMealPrice()) { + throw StateError('结算信息已变化,请重新确认'); + } + Get.find() + .updateCheckoutSnapshot(_createCheckoutSnapshot()); + } catch (_) { + if (!_pageClosed && Get.isRegistered()) { + Get.find().markPricingFailed(); + } + rethrow; + } + } + + Future _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 _clearShopCard() async { + _invalidateDiscounts(); + _catalogRefreshRequired = false; shopCardList.clear(); payUser.value = null; totalPrice.value = 0; diff --git a/pubspec.yaml b/pubspec.yaml index 190c3a8..59a20c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/checkout_cart_repricing_test.dart b/test/checkout_cart_repricing_test.dart new file mode 100644 index 0000000..4d98b62 --- /dev/null +++ b/test/checkout_cart_repricing_test.dart @@ -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() + .having((error) => error.message, 'message', contains('重新选择'))), + ); + expect(cart.map((item) => item.toJson()).toList(), before); + expect(cart, hasLength(2)); + }); + + test('空购物车产生空新列表', () { + final cart = []; + final refreshed = CheckoutCartRepricing.refresh(cart, [_product('dish-a')]); + expect(refreshed, isEmpty); + expect(identical(refreshed, cart), isFalse); + }); +} diff --git a/test/checkout_dialog_pricing_gate_test.dart b/test/checkout_dialog_pricing_gate_test.dart new file mode 100644 index 0000000..41eeb00 --- /dev/null +++ b/test/checkout_dialog_pricing_gate_test.dart @@ -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(_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(force: true); + await temporaryDirectory.delete(recursive: true); + }); + + test('pending rule lookup blocks submission until a later explicit click', + () async { + final rulesReady = Completer(); + 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); + }); +} diff --git a/test/checkout_discount_session_test.dart b/test/checkout_discount_session_test.dart new file mode 100644 index 0000000..d05b279 --- /dev/null +++ b/test/checkout_discount_session_test.dart @@ -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(); + final source = Completer>(); + var calls = 0; + Future> 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(); + var calls = 0; + Future> 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(elapsed: () => elapsed); + var calls = 0; + final failure = StateError('lookup rejected'); + Future> 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(); + var calls = 0; + Future> 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().load(key: _key, lookup: lookup); + expect(calls, 5); + }); + + test('失效前的迟到结果不能覆盖新请求或清掉新查询', () async { + final session = CheckoutDiscountSession(); + final old = Completer>(); + final next = Completer>(); + var calls = 0; + Future> 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(); + final old = Completer>(); + final persisted = >[]; + Future persist(List 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(); + final old = Completer>(); + final persisted = >[]; + Future persist(List 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(elapsed: () => elapsed); + final source = Completer>(); + final first = session.load(key: _key, lookup: () => source.future); + elapsed = const Duration(seconds: 29); + source.complete([1]); + await first; + var calls = 0; + Future> 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); + }); +} diff --git a/test/checkout_price_coordinator_test.dart b/test/checkout_price_coordinator_test.dart new file mode 100644 index 0000000..9e5f971 --- /dev/null +++ b/test/checkout_price_coordinator_test.dart @@ -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(); + 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(); + 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(); + 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(); + 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); + }); +} diff --git a/test/discounts_tool_test.dart b/test/discounts_tool_test.dart new file mode 100644 index 0000000..27f6f31 --- /dev/null +++ b/test/discounts_tool_test.dart @@ -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 Function(dio.RequestOptions) respond; + final requests = []; + + @override + Future fetch(dio.RequestOptions options, + Stream? requestStream, Future? 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(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(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()), + ); + } + 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(); + 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(); + final oldResponse = Completer(); + 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); + }); +} diff --git a/test/terminal_discount_protocol_test.dart b/test/terminal_discount_protocol_test.dart new file mode 100644 index 0000000..86c9439 --- /dev/null +++ b/test/terminal_discount_protocol_test.dart @@ -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); + }); +}