From 6904eae179712a954155618727733ac1450abaf0 Mon Sep 17 00:00:00 2001 From: Guoqs <123456@jsowell.com> Date: Sat, 25 Jul 2026 20:51:42 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BF=9D=E9=99=A9=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=B5=84=E9=87=91=E5=8F=A3=E5=BE=84=EF=BC=9ApayAmount?= =?UTF-8?q?=3D=E5=85=85=E7=94=B5=E9=87=91=E9=A2=9D=EF=BC=8C=E6=98=8E?= =?UTF-8?q?=E7=BB=86=3D=E5=AE=8C=E6=95=B4=E5=AE=9E=E4=BB=98(=E5=85=85?= =?UTF-8?q?=E7=94=B5+=E4=BF=9D=E9=99=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则:order_basic_info.pay_amount 只记充电金额(不含保险);order_pay_record 汇总为用户完整实付(充电+保险);凭证校验以 payAmount+保险==明细 为准。 - 在线支付(OrderService.adapayCallback):payAmount 存 amount-保险(充电金额), 回退 f11b781c2 的"完整金额"写入;下发桩额度直接用 payAmount,避免结算多退保险。 - 余额支付(Not/DelayMerchantProgramLogic.balancePayOrderV2):与在线一致,从钱包 扣 充电+保险,order_pay_record 记完整实付,payAmount 仍只记充电金额;余额不足 (<充电+保险)直接拒绝。会员交易流水记完整实付。 - 凭证校验(OrderBasicInfoServiceImpl.inspectOrderPayment):余额/非余额支路统一 改为容错对比 payAmount==明细 或 payAmount+保险==明细;补偿回写只写充电金额, 修复余额支路仍按 payAmount==明细 会误判新余额保险单的问题。 - 新增 docs/sql/fix_online_insurance_pay_amount_20260725.sql:矫正 f11b781c2 期间 在线保险单被写成完整金额的 payAmount(dry-run/备份/回滚齐全)。 - 新增单测:calculateBalanceRefund 6 例 + inspectOrderPayment 5 例(计算层,不连库)。 --- ...x_online_insurance_pay_amount_20260725.sql | 131 +++++++++++++++ .../com/jsowell/service/OrderService.java | 13 +- .../OrderPaymentVoucherInsuranceTest.java | 157 ++++++++++++++++++ .../BalanceInsuranceRefundTest.java | 96 +++++++++++ .../impl/OrderBasicInfoServiceImpl.java | 53 ++++-- .../DelayMerchantProgramLogic.java | 23 ++- .../NotDelayMerchantProgramLogic.java | 23 ++- 7 files changed, 462 insertions(+), 34 deletions(-) create mode 100644 docs/sql/fix_online_insurance_pay_amount_20260725.sql create mode 100644 jsowell-admin/src/test/java/com/jsowell/pile/service/impl/OrderPaymentVoucherInsuranceTest.java create mode 100644 jsowell-admin/src/test/java/com/jsowell/pile/service/programlogic/BalanceInsuranceRefundTest.java diff --git a/docs/sql/fix_online_insurance_pay_amount_20260725.sql b/docs/sql/fix_online_insurance_pay_amount_20260725.sql new file mode 100644 index 000000000..ed455558c --- /dev/null +++ b/docs/sql/fix_online_insurance_pay_amount_20260725.sql @@ -0,0 +1,131 @@ +-- ============================================================================ +-- 矫正在线支付「保险订单」的 pay_amount +-- 背景:提交 f11b781c2 上线到本次回退期间,在线支付回调把 order_basic_info.pay_amount +-- 写成了「完整实付(充电+保险)」。规则应为: +-- order_basic_info.pay_amount = 充电金额(不含保险) +-- order_pay_record 汇总 = 完整实付(充电 + 保险) +-- 故需把这批订单的 pay_amount 从「完整金额」矫正为「pay_amount - insurance_amount」。 +-- 适用:MySQL 5.7+(未用 CTE,5.7 可直接跑) +-- 日期:2026-07-25 +-- +-- 识别口径:在线支付(微信'4'/支付宝'5') + 已支付 + insurance>0 + pay_amount≈明细汇总。 +-- · 正确订单:pay_amount = 明细 - 保险 < 明细 +-- · 错误订单:pay_amount = 明细(含保险) => 命中本脚本 +-- +-- ⚠️ 关于「已完成(order_status='6')」订单:结算时已用错误的 pay_amount 计算过 +-- 退款(residue = pay_amount - 消费),即保险已被多退,本脚本无法追回那笔钱; +-- 对已完成订单矫正 pay_amount 只是让「记录」变正确,且会与已落库的 refund_amount +-- 不再自洽。是否处理已完成订单请按业务决定(见第 3 步的两种 WHERE)。 +-- ============================================================================ + + +-- --------------------------------------------------------------------------- +-- 第 1 步:DRY-RUN 预览(务必先只跑这个,核对数量与金额) +-- --------------------------------------------------------------------------- +SELECT + o.order_code, + o.order_status, + CASE o.order_status WHEN '6' THEN '已完成' WHEN '4' THEN '异常' ELSE '未完成' END AS status_desc, + o.pay_mode, + o.pay_amount AS cur_pay_amount, -- 当前(错误,含保险) + o.insurance_amount AS insurance_amount, + pr.pay_record_sum AS pay_record_sum, -- 明细汇总(完整实付) + (o.pay_amount - o.insurance_amount) AS corrected_pay_amount -- 矫正后(充电金额) +FROM order_basic_info o +JOIN ( + SELECT order_code, SUM(pay_amount) AS pay_record_sum + FROM order_pay_record + WHERE del_flag = '0' + GROUP BY order_code +) pr ON pr.order_code = o.order_code +WHERE o.pay_mode IN ('4','5') -- 在线支付(微信/支付宝) + AND o.pay_status = '1' -- 已支付 + AND o.insurance_amount > 0 -- 有保险 + AND ABS(o.pay_amount - pr.pay_record_sum) < 0.005 -- pay_amount == 明细汇总 => 含了保险(错误) +ORDER BY o.order_status, o.order_code; + + +-- --------------------------------------------------------------------------- +-- 第 2 步:备份受影响行(建议执行,便于回滚) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS bak_order_pay_amount_fix_20260725 ( + order_code VARCHAR(64) PRIMARY KEY, + old_pay_amount DECIMAL(10,2), + insurance_amount DECIMAL(10,2), + new_pay_amount DECIMAL(10,2), + order_status VARCHAR(4), + backup_time DATETIME +); + +INSERT INTO bak_order_pay_amount_fix_20260725 + (order_code, old_pay_amount, insurance_amount, new_pay_amount, order_status, backup_time) +SELECT o.order_code, o.pay_amount, o.insurance_amount, + (o.pay_amount - o.insurance_amount), o.order_status, NOW() +FROM order_basic_info o +JOIN ( + SELECT order_code, SUM(pay_amount) AS pay_record_sum + FROM order_pay_record WHERE del_flag = '0' GROUP BY order_code +) pr ON pr.order_code = o.order_code +WHERE o.pay_mode IN ('4','5') + AND o.pay_status = '1' + AND o.insurance_amount > 0 + AND ABS(o.pay_amount - pr.pay_record_sum) < 0.005 +ON DUPLICATE KEY UPDATE backup_time = NOW(); + + +-- --------------------------------------------------------------------------- +-- 第 3 步:正式矫正(确认第 1 步无误后执行) +-- pay_amount = pay_amount - insurance_amount = 充电金额 +-- +-- ▸ 方案 A(推荐):只矫正「未完成」订单,避免动已结算/已退款的历史账 +-- —— 防止这些单将来结算时多退保险。把下面这条的注释去掉执行: +-- --------------------------------------------------------------------------- +-- UPDATE order_basic_info o +-- JOIN ( +-- SELECT order_code, SUM(pay_amount) AS pay_record_sum +-- FROM order_pay_record WHERE del_flag = '0' GROUP BY order_code +-- ) pr ON pr.order_code = o.order_code +-- SET o.pay_amount = o.pay_amount - o.insurance_amount +-- WHERE o.pay_mode IN ('4','5') +-- AND o.pay_status = '1' +-- AND o.insurance_amount > 0 +-- AND ABS(o.pay_amount - pr.pay_record_sum) < 0.005 +-- AND o.order_status NOT IN ('6','7'); -- 排除已完成/已关闭 + +-- ▸ 方案 B:矫正「全部」受影响订单(含已完成,仅让记录口径一致;已多退的保险无法追回) +-- 如需按此执行,去掉下面这条的注释: +-- UPDATE order_basic_info o +-- JOIN ( +-- SELECT order_code, SUM(pay_amount) AS pay_record_sum +-- FROM order_pay_record WHERE del_flag = '0' GROUP BY order_code +-- ) pr ON pr.order_code = o.order_code +-- SET o.pay_amount = o.pay_amount - o.insurance_amount +-- WHERE o.pay_mode IN ('4','5') +-- AND o.pay_status = '1' +-- AND o.insurance_amount > 0 +-- AND ABS(o.pay_amount - pr.pay_record_sum) < 0.005; + + +-- --------------------------------------------------------------------------- +-- 第 4 步:矫正后复核(应为 0 行) +-- --------------------------------------------------------------------------- +-- 复核逻辑:矫正后 正确订单应满足 pay_amount + insurance = 明细汇总; +-- 若仍有 pay_amount == 明细汇总 且 insurance>0,说明还有漏网的。 +SELECT COUNT(*) AS still_wrong +FROM order_basic_info o +JOIN ( + SELECT order_code, SUM(pay_amount) AS pay_record_sum + FROM order_pay_record WHERE del_flag = '0' GROUP BY order_code +) pr ON pr.order_code = o.order_code +WHERE o.pay_mode IN ('4','5') + AND o.pay_status = '1' + AND o.insurance_amount > 0 + AND ABS(o.pay_amount - pr.pay_record_sum) < 0.005; + + +-- --------------------------------------------------------------------------- +-- 回滚(如需):从备份表还原 +-- --------------------------------------------------------------------------- +-- UPDATE order_basic_info o +-- JOIN bak_order_pay_amount_fix_20260725 b ON b.order_code = o.order_code +-- SET o.pay_amount = b.old_pay_amount; diff --git a/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java b/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java index 43581f9da..f97a12988 100644 --- a/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java +++ b/jsowell-admin/src/main/java/com/jsowell/service/OrderService.java @@ -1302,12 +1302,17 @@ public class OrderService { // 支付订单成功 OrderBasicInfo orderBasicInfo = orderBasicInfoService.getOrderInfoByOrderCode(orderCode); BigDecimal insuranceAmount = orderBasicInfo.getInsuranceAmount(); - log.info("adapayCallback-订单:{}, 网关支付金额(amount):{}, 订单保险金额(insuranceAmount):{}, payRecord将记录:{}", - orderCode, amount, insuranceAmount, amount); + // 主表 payAmount 只存充电金额(不含保险):充电金额 = 网关实付(amount) - 保险金额; + // order_pay_record 仍记录完整实付(amount),故校验时 明细 == payAmount + 保险。 + BigDecimal chargeAmount = amount; + if (insuranceAmount != null && insuranceAmount.compareTo(BigDecimal.ZERO) > 0) { + chargeAmount = amount.subtract(insuranceAmount).setScale(2, RoundingMode.HALF_UP); + } + log.info("adapayCallback-订单:{}, 网关支付金额(amount):{}, 订单保险金额(insuranceAmount):{}, 充电金额(payAmount将记录):{}, order_pay_record记录:{}", + orderCode, amount, insuranceAmount, chargeAmount, amount); PayOrderSuccessCallbackDTO callbackDTO = PayOrderSuccessCallbackDTO.builder() .orderCode(orderCode) - .payAmount(amount) // 完整支付金额(含保险),与 order_pay_record 汇总一致 - .insuranceAmount(insuranceAmount) // 保险金额,回调中用于计算下发桩的充电金额 + .payAmount(chargeAmount) // 充电金额(不含保险) .payMode(payModel) .acquirer(AcquirerEnum.ADAPAY.getValue()) .build(); diff --git a/jsowell-admin/src/test/java/com/jsowell/pile/service/impl/OrderPaymentVoucherInsuranceTest.java b/jsowell-admin/src/test/java/com/jsowell/pile/service/impl/OrderPaymentVoucherInsuranceTest.java new file mode 100644 index 000000000..267bc7176 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/pile/service/impl/OrderPaymentVoucherInsuranceTest.java @@ -0,0 +1,157 @@ +package com.jsowell.pile.service.impl; + +import com.jsowell.common.enums.DelFlagEnum; +import com.jsowell.common.enums.MemberWalletEnum; +import com.jsowell.common.enums.ykc.OrderPayModeEnum; +import com.jsowell.common.enums.ykc.OrderPayRecordEnum; +import com.jsowell.common.enums.ykc.OrderPayStatusEnum; +import com.jsowell.pile.domain.MemberWalletLog; +import com.jsowell.pile.domain.OrderBasicInfo; +import com.jsowell.pile.domain.OrderPayRecord; +import com.jsowell.pile.service.MemberWalletLogService; +import com.jsowell.pile.service.OrderPayRecordService; +import com.jsowell.pile.vo.web.OrderPaymentCheckResult; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.objenesis.ObjenesisStd; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * 支付凭证校验({@code inspectOrderPayment})保险口径的单元测试(mock 依赖,不连库)。 + * + *

规则:主表 payAmount = 充电金额;order_pay_record 汇总 = 完整实付(充电 + 保险); + * {@code payAmount==明细 或 payAmount+保险==明细} 任一成立即视为一致。

+ */ +class OrderPaymentVoucherInsuranceTest { + + private static final String ORDER_CODE = "O-TEST"; + + private static void setField(Object target, String name, Object value) { + try { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static OrderPaymentCheckResult inspect(OrderBasicInfoServiceImpl service, OrderBasicInfo order) { + try { + Method m = OrderBasicInfoServiceImpl.class.getDeclaredMethod("inspectOrderPayment", OrderBasicInfo.class); + m.setAccessible(true); + return (OrderPaymentCheckResult) m.invoke(service, order); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static OrderPayRecord payRecord(String payMode, String amount) { + return OrderPayRecord.builder() + .payMode(payMode) + .payAmount(new BigDecimal(amount)) + .delFlag(DelFlagEnum.NORMAL.getValue()) + .createTime(new Date()) + .build(); + } + + private static MemberWalletLog walletOut(String amount) { + return MemberWalletLog.builder() + .type(MemberWalletEnum.TYPE_OUT.getValue()) + .subType(MemberWalletEnum.SUBTYPE_PAYMENT_FOR_ORDER.getValue()) + .amount(new BigDecimal(amount)) + .createTime(new Date()) + .build(); + } + + private static OrderBasicInfo order(String payMode, String payAmount, String insurance) { + OrderBasicInfo o = new OrderBasicInfo(); + o.setOrderCode(ORDER_CODE); + o.setPayMode(payMode); + o.setPayStatus(OrderPayStatusEnum.paid.getValue()); + o.setPayAmount(new BigDecimal(payAmount)); + o.setPayTime(new Date()); + o.setInsuranceAmount(new BigDecimal(insurance)); + return o; + } + + private OrderBasicInfoServiceImpl serviceWith(List records, List walletLogs) { + OrderPayRecordService payRecordService = Mockito.mock(OrderPayRecordService.class); + MemberWalletLogService walletLogService = Mockito.mock(MemberWalletLogService.class); + when(payRecordService.getOrderPayRecordList(ORDER_CODE)).thenReturn(records); + when(walletLogService.getOrderRecord(ORDER_CODE, MemberWalletEnum.TYPE_OUT.getValue())).thenReturn(walletLogs); + // OrderBasicInfoServiceImpl 构造器/字段初始化里有 SpringUtils.getBean,依赖 Spring 容器; + // 用 Objenesis 绕过构造器实例化,只注入本用例需要的依赖。 + OrderBasicInfoServiceImpl service = new ObjenesisStd().newInstance(OrderBasicInfoServiceImpl.class); + setField(service, "logger", LoggerFactory.getLogger(OrderPaymentVoucherInsuranceTest.class)); + setField(service, "orderPayRecordService", payRecordService); + setField(service, "memberWalletLogService", walletLogService); + return service; + } + + @Test + void balanceInsuranceOrder_isConsistent() { + // 余额:payAmount=50(充电),明细=50.5(充电+保险),保险0.5 → 50+0.5==50.5 ✓ + OrderBasicInfoServiceImpl service = serviceWith( + Collections.singletonList(payRecord(OrderPayRecordEnum.PRINCIPAL_BALANCE_PAYMENT.getValue(), "50.5")), + Collections.singletonList(walletOut("-50.5"))); + OrderPaymentCheckResult r = inspect(service, + order(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), "50.00", "0.5")); + assertTrue(r.isOrderPaymentReady(), r.getReason()); + } + + @Test + void balanceNoInsurance_isConsistent() { + // 余额无保险:payAmount=50,明细=50 → 50==50 ✓(回归) + OrderBasicInfoServiceImpl service = serviceWith( + Collections.singletonList(payRecord(OrderPayRecordEnum.PRINCIPAL_BALANCE_PAYMENT.getValue(), "50")), + Collections.singletonList(walletOut("-50"))); + OrderPaymentCheckResult r = inspect(service, + order(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), "50", "0")); + assertTrue(r.isOrderPaymentReady(), r.getReason()); + } + + @Test + void balanceGenuineMismatch_isFlagged() { + // 余额真异常:payAmount=40,明细=50.5,保险0.5 → 40≠50.5 且 40.5≠50.5 → 不通过 + OrderBasicInfoServiceImpl service = serviceWith( + Collections.singletonList(payRecord(OrderPayRecordEnum.PRINCIPAL_BALANCE_PAYMENT.getValue(), "50.5")), + Collections.singletonList(walletOut("-50.5"))); + OrderPaymentCheckResult r = inspect(service, + order(OrderPayModeEnum.PAYMENT_OF_PRINCIPAL_BALANCE.getValue(), "40", "0.5")); + assertFalse(r.isOrderPaymentReady()); + } + + @Test + void onlineInsuranceOrder_isConsistent() { + // 在线:payAmount=50(充电),明细=50.5,保险0.5 → 50+0.5==50.5 ✓ + OrderBasicInfoServiceImpl service = serviceWith( + Collections.singletonList(payRecord(OrderPayRecordEnum.WECHATPAY_PAYMENT.getValue(), "50.5")), + Collections.emptyList()); + OrderPaymentCheckResult r = inspect(service, + order(OrderPayModeEnum.PAYMENT_OF_WECHATPAY.getValue(), "50.00", "0.5")); + assertTrue(r.isOrderPaymentReady(), r.getReason()); + } + + @Test + void onlineLegacyFullPayAmount_isConsistent() { + // 兼容 f11b781c2 期间历史单:payAmount=50.5(含保险),明细=50.5 → 50.5==50.5 ✓ + OrderBasicInfoServiceImpl service = serviceWith( + Collections.singletonList(payRecord(OrderPayRecordEnum.WECHATPAY_PAYMENT.getValue(), "50.5")), + Collections.emptyList()); + OrderPaymentCheckResult r = inspect(service, + order(OrderPayModeEnum.PAYMENT_OF_WECHATPAY.getValue(), "50.5", "0.5")); + assertTrue(r.isOrderPaymentReady(), r.getReason()); + } +} diff --git a/jsowell-admin/src/test/java/com/jsowell/pile/service/programlogic/BalanceInsuranceRefundTest.java b/jsowell-admin/src/test/java/com/jsowell/pile/service/programlogic/BalanceInsuranceRefundTest.java new file mode 100644 index 000000000..43386580b --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/pile/service/programlogic/BalanceInsuranceRefundTest.java @@ -0,0 +1,96 @@ +package com.jsowell.pile.service.programlogic; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 余额支付「电费 + 保险费」退款计算的单元测试(纯计算,不连库)。 + * + *

前提:order_pay_record 汇总(principalPay + giftPay) = 完整实付(充电 + 保险), + * 故 {@code 电费预付款 = 总支付 - 保险}。这是「余额与在线一样从钱包扣保险」之后才成立的口径。

+ * + *

覆盖场景:

+ *
    + *
  1. 无保险 + 正常消费(回归,行为不变)
  2. + *
  3. 有保险 + 正常消费(消费≥1) → 保险不退
  4. + *
  5. 有保险 + 消费<1 → 保险退
  6. + *
  7. 保险跨本金/赠金拆分 + 消费<1
  8. + *
  9. 折扣把实际电费消费压到<1 → 保险退
  10. + *
  11. 消费恰好=1(边界)→ 保险不退
  12. + *
+ */ +class BalanceInsuranceRefundTest { + + private static Map refund(String principalPay, String giftPay, + String orderAmount, String discount, String insurance) { + try { + Method method = AbstractProgramLogic.class.getDeclaredMethod("calculateBalanceRefund", + BigDecimal.class, BigDecimal.class, BigDecimal.class, BigDecimal.class, BigDecimal.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map result = (Map) method.invoke(null, + new BigDecimal(principalPay), new BigDecimal(giftPay), new BigDecimal(orderAmount), + new BigDecimal(discount), new BigDecimal(insurance)); + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void assertAmount(String key, String expected, BigDecimal actual) { + assertTrue(actual != null && new BigDecimal(expected).compareTo(actual) == 0, + key + " 期望 " + expected + " 实际 " + actual); + } + + /** electricityPrincipal/Gift = 电费退款;insurancePrincipal/Gift = 保险退款。 */ + private static void assertRefund(Map m, + String electricityPrincipal, String electricityGift, + String insurancePrincipal, String insuranceGift) { + assertAmount("returnPrincipal", electricityPrincipal, m.get("returnPrincipal")); + assertAmount("returnGift", electricityGift, m.get("returnGift")); + assertAmount("returnPrincipalForInsurance", insurancePrincipal, m.get("returnPrincipalForInsurance")); + assertAmount("returnGiftForInsurance", insuranceGift, m.get("returnGiftForInsurance")); + } + + @Test + void noInsurance_normalConsumption_refundUnusedElectricityOnly() { + // 本金50全充电,消费30 → 退20;无保险 + assertRefund(refund("50", "0", "30", "0", "0"), "20", "0", "0", "0"); + } + + @Test + void withInsurance_normalConsumption_insuranceNotRefunded() { + // 实付50.5(充电50+保险0.5),消费30(≥1) → 电费退20,保险不退 + assertRefund(refund("50.5", "0", "30", "0", "0.5"), "20", "0", "0", "0"); + } + + @Test + void withInsurance_lowConsumption_insuranceRefunded() { + // 实付50.5,消费0.5(<1) → 电费退49.5,保险退0.5 + assertRefund(refund("50.5", "0", "0.5", "0", "0.5"), "49.5", "0", "0.5", "0"); + } + + @Test + void insuranceSplitAcrossPrincipalAndGift_lowConsumption() { + // 本金0.3+赠金50.2=50.5;保险0.5优先扣本金0.3、赠金补0.2;消费0.2(<1) + // 电费:本金退0,赠金退49.8;保险退:本金0.3、赠金0.2 + assertRefund(refund("0.3", "50.2", "0.2", "0", "0.5"), "0", "49.8", "0.3", "0.2"); + } + + @Test + void discountReducesConsumptionBelowOne_insuranceRefunded() { + // 消费10、折扣9.5 → 实际电费消费0.5(<1) → 电费退49.5,保险退0.5 + assertRefund(refund("50.5", "0", "10", "9.5", "0.5"), "49.5", "0", "0.5", "0"); + } + + @Test + void consumptionExactlyOne_insuranceNotRefunded() { + // 实际电费消费=1.0,非严格<1 → 保险不退;电费退49 + assertRefund(refund("50.5", "0", "1", "0", "0.5"), "49", "0", "0", "0"); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java index 4886cdd3e..2424f17e0 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java @@ -1137,29 +1137,36 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { .reason("订单支付明细与会员钱包出账金额不一致") .build(); } - // 余额支付:payAmount 已通过回调累加(可能含保险),payRecordAmount 仅含余额支付记录, - // 直接比较两者即可,不应再加保险金额(避免双重计算) + // 余额支付主表 payAmount 只记充电金额;order_pay_record/钱包出账为完整实付(充电+保险)。 + // 故 payAmount==明细 或 payAmount+保险==明细 任一成立即视为一致(无保险时两者等价,兼容历史单)。 + BigDecimal balanceInsuranceAmount = positiveOrZero(orderBasicInfo.getInsuranceAmount()); + BigDecimal balanceChargeOnly = payRecordAmount.subtract(balanceInsuranceAmount); + if (balanceChargeOnly.compareTo(BigDecimal.ZERO) < 0) { + balanceChargeOnly = BigDecimal.ZERO; + } if (!baseFieldsReady) { return OrderPaymentCheckResult.builder() .checkCompleted(true) .paymentEvidenceFound(true) .orderPaymentReady(false) - .paymentAmount(payRecordAmount) + .paymentAmount(balanceChargeOnly) // 修复回写只写充电金额(明细-保险) .paymentTime(paymentTime) .reason("支付流水存在,但订单主表支付字段不完整(payStatus=" + payStatus + ", payAmount=" + orderBasicInfo.getPayAmount() + ", payTime=" + orderBasicInfo.getPayTime() + ")") .build(); } - if (!samePaymentAmount(orderBasicInfo.getPayAmount(), payRecordAmount)) { + boolean balanceAmountConsistent = samePaymentAmount(orderBasicInfo.getPayAmount(), payRecordAmount) + || samePaymentAmount(orderBasicInfo.getPayAmount().add(balanceInsuranceAmount), payRecordAmount); + if (!balanceAmountConsistent) { return OrderPaymentCheckResult.builder() .checkCompleted(true) .paymentEvidenceFound(true) .orderPaymentReady(false) - .paymentAmount(payRecordAmount) + .paymentAmount(balanceChargeOnly) .paymentTime(paymentTime) .reason("支付流水存在,但订单主表支付金额(" + orderBasicInfo.getPayAmount() - + ")与支付明细汇总(" + payRecordAmount + ")不一致") + + ")+保险金额(" + balanceInsuranceAmount + ")与支付明细汇总(" + payRecordAmount + ")不一致") .build(); } return OrderPaymentCheckResult.builder() @@ -1174,26 +1181,44 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { // 非余额支付保持兼容:必须有完整主表支付字段;若存在支付明细,则再校验明细金额。 if (!baseFieldsReady) { + // 补偿修复回写主表 payAmount 时只写充电金额:payRecordAmount 是完整实付(充电+保险),需扣掉保险。 + BigDecimal chargeOnlyAmount = null; + if (payRecordEvidence) { + BigDecimal insuranceAmount = positiveOrZero(orderBasicInfo.getInsuranceAmount()); + chargeOnlyAmount = payRecordAmount.subtract(insuranceAmount); + if (chargeOnlyAmount.compareTo(BigDecimal.ZERO) < 0) { + chargeOnlyAmount = BigDecimal.ZERO; + } + } return OrderPaymentCheckResult.builder() .checkCompleted(true) .paymentEvidenceFound(payRecordEvidence) .orderPaymentReady(false) - .paymentAmount(payRecordEvidence ? payRecordAmount : null) + .paymentAmount(chargeOnlyAmount) .paymentTime(paymentTime) .reason("订单主表支付字段不完整(payStatus=" + payStatus + ", payAmount=" + orderBasicInfo.getPayAmount() + ", payTime=" + orderBasicInfo.getPayTime() + ")") .build(); } - // payAmount 已通过回调/支付记录设置(微信/支付宝/白名单等),不应再加保险金额(避免双重计算) - if (payRecordEvidence && !samePaymentAmount(orderBasicInfo.getPayAmount(), payRecordAmount)) { + // 兼容主表 payAmount 的两种历史口径,避免保险订单被误判为异常: + // - 旧口径:payAmount = 充电金额(历史/旧在线回调把保险减掉了),此时 payAmount + 保险 = 完整实付; + // - 新口径:payAmount = 完整金额(f11b781c2 起含保险),此时 payAmount 直接等于完整实付。 + // order_pay_record 汇总(payRecordAmount)始终是用户真实支付的完整金额(充电+保险), + // 故 payAmount==明细 或 payAmount+保险==明细 任一成立即视为一致;无保险时保险=0,两条等价。 + // (余额支付支路已在上方单独校验,此处仅覆盖非余额支付。) + BigDecimal insuranceAmount = positiveOrZero(orderBasicInfo.getInsuranceAmount()); + boolean amountConsistent = samePaymentAmount(orderBasicInfo.getPayAmount(), payRecordAmount) + || samePaymentAmount(orderBasicInfo.getPayAmount().add(insuranceAmount), payRecordAmount); + if (payRecordEvidence && !amountConsistent) { return OrderPaymentCheckResult.builder() .checkCompleted(true) .paymentEvidenceFound(true) .orderPaymentReady(false) .paymentTime(paymentTime) .reason("订单支付明细金额(" + payRecordAmount - + ")与订单主表支付金额(" + orderBasicInfo.getPayAmount() + ")不一致") + + ")与订单主表支付金额(" + orderBasicInfo.getPayAmount() + + ")+保险金额(" + insuranceAmount + ")均不一致") .build(); } return OrderPaymentCheckResult.builder() @@ -5406,12 +5431,8 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService { return; } - // 获取启动金额:保险金额不计入下发桩的充电额度 - BigDecimal payAmountForPile = dto.getPayAmount(); - if (dto.getInsuranceAmount() != null && dto.getInsuranceAmount().compareTo(BigDecimal.ZERO) > 0) { - payAmountForPile = dto.getPayAmount().subtract(dto.getInsuranceAmount()); - } - BigDecimal chargeAmount = computeChargeAmount(orderInfo.getMerchantId(), orderInfo.getStationId(), orderInfo.getMemberId(), payAmountForPile); + // 获取启动金额:dto.payAmount 已是充电金额(不含保险),直接用于下发桩 + BigDecimal chargeAmount = computeChargeAmount(orderInfo.getMerchantId(), orderInfo.getStationId(), orderInfo.getMemberId(), dto.getPayAmount()); // 发送启动指令 if (StringUtils.equals(pileConnectorDetailVO.getChargePortType(), Constants.THREE)) { diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java index 7f45776cc..e62c46a75 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/DelayMerchantProgramLogic.java @@ -336,12 +336,20 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR.getValue(), "启动金额必须大于0"); } dto.setPayAmount(chargeAmount); + // 保险费:和在线支付一致,余额支付也需从钱包扣保险;无保险则为 0 + BigDecimal insuranceAmount = orderBasicInfo.getInsuranceAmount() == null + ? BigDecimal.ZERO : orderBasicInfo.getInsuranceAmount(); + if (insuranceAmount.compareTo(BigDecimal.ZERO) < 0) { + insuranceAmount = BigDecimal.ZERO; + } + // 实际应扣金额 = 充电金额 + 保险费(完整实付,写入 order_pay_record) + BigDecimal totalPayAmount = chargeAmount.add(insuranceAmount); // 总余额 BigDecimal totalAccountAmount = memberVO.getTotalBalance() == null ? BigDecimal.ZERO : memberVO.getTotalBalance(); - if (totalAccountAmount.compareTo(chargeAmount) < 0) { - // 总余额小于充电金额 + if (totalAccountAmount.compareTo(totalPayAmount) < 0) { + // 总余额不足以覆盖 充电金额 + 保险费,直接拒绝 throw new BusinessException(ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT); } @@ -349,15 +357,16 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { BigDecimal principalBalancePay = BigDecimal.ZERO; // 赠送金支付金额 BigDecimal giftBalancePay = BigDecimal.ZERO; - // 计算下发金额 - Map stringBigDecimalMap = calculateTheAmount(memberVO, chargeAmount); + // 计算下发金额:按完整实付(充电+保险)拆分本金/赠金 + Map stringBigDecimalMap = calculateTheAmount(memberVO, totalPayAmount); if (stringBigDecimalMap != null) { principalBalancePay = stringBigDecimalMap.get("principalBalancePay"); giftBalancePay = stringBigDecimalMap.get("giftBalancePay"); } - // 更新支付金额 = 本金支付金额 + 赠送金支付金额,确保与 order_pay_record 汇总一致 - chargeAmount = principalBalancePay.add(giftBalancePay); + // 实际扣款合计(= 完整实付,含保险),用于扣款、order_pay_record、会员流水; + // 注意:主表 payAmount 仍只记充电金额(chargeAmount),不含保险。 + BigDecimal actualPaidAmount = principalBalancePay.add(giftBalancePay); // 更新会员钱包 全部金额都用于支付订单 UpdateMemberBalanceDTO updateMemberBalanceDTO = UpdateMemberBalanceDTO.builder() @@ -421,7 +430,7 @@ public class DelayMerchantProgramLogic extends AbstractProgramLogic { .actionType(ActionTypeEnum.FORWARD.getValue()) .payMode(PayModeEnum.PAYMENT_OF_BALANCE.getValue()) .paymentInstitutions(PaymentInstitutionsEnum.LOCAL_ACCOUNTS.getValue()) - .amount(dto.getPayAmount()) // 单位元 + .amount(actualPaidAmount) // 单位元,完整实付(充电+保险) .build(); memberTransactionRecordService.insertSelective(record); } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java index 5d6fd34e9..1c7d959bf 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/programlogic/NotDelayMerchantProgramLogic.java @@ -248,12 +248,20 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { throw new BusinessException(ReturnCodeEnum.CODE_PARAM_NOT_NULL_ERROR.getValue(), "启动金额必须大于0"); } dto.setPayAmount(chargeAmount); + // 保险费:和在线支付一致,余额支付也需从钱包扣保险;无保险则为 0 + BigDecimal insuranceAmount = orderBasicInfo.getInsuranceAmount() == null + ? BigDecimal.ZERO : orderBasicInfo.getInsuranceAmount(); + if (insuranceAmount.compareTo(BigDecimal.ZERO) < 0) { + insuranceAmount = BigDecimal.ZERO; + } + // 实际应扣金额 = 充电金额 + 保险费(完整实付,写入 order_pay_record) + BigDecimal totalPayAmount = chargeAmount.add(insuranceAmount); // 总余额 BigDecimal totalAccountAmount = memberVO.getTotalBalance() == null ? BigDecimal.ZERO : memberVO.getTotalBalance(); - if (totalAccountAmount.compareTo(chargeAmount) < 0) { - // 总余额小于充电金额 + if (totalAccountAmount.compareTo(totalPayAmount) < 0) { + // 总余额不足以覆盖 充电金额 + 保险费,直接拒绝 throw new BusinessException(ReturnCodeEnum.CODE_BALANCE_IS_INSUFFICIENT); } @@ -261,15 +269,16 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { BigDecimal principalBalancePay = BigDecimal.ZERO; // 赠送金支付金额 BigDecimal giftBalancePay = BigDecimal.ZERO; - // 计算下发金额 - Map stringBigDecimalMap = calculateTheAmount(memberVO, chargeAmount); + // 计算下发金额:按完整实付(充电+保险)拆分本金/赠金 + Map stringBigDecimalMap = calculateTheAmount(memberVO, totalPayAmount); if (stringBigDecimalMap != null) { principalBalancePay = stringBigDecimalMap.get("principalBalancePay"); giftBalancePay = stringBigDecimalMap.get("giftBalancePay"); } - // 更新支付金额 = 本金支付金额 + 赠送金支付金额 - chargeAmount = principalBalancePay.add(giftBalancePay); + // 实际扣款合计(= 完整实付,含保险),用于扣款、order_pay_record、会员流水; + // 注意:主表 payAmount 仍只记充电金额(chargeAmount),不含保险。 + BigDecimal actualPaidAmount = principalBalancePay.add(giftBalancePay); // 更新会员钱包 全部金额都用于支付订单 UpdateMemberBalanceDTO updateMemberBalanceDTO = UpdateMemberBalanceDTO.builder() @@ -333,7 +342,7 @@ public class NotDelayMerchantProgramLogic extends AbstractProgramLogic { .actionType(ActionTypeEnum.FORWARD.getValue()) .payMode(PayModeEnum.PAYMENT_OF_BALANCE.getValue()) .paymentInstitutions(PaymentInstitutionsEnum.LOCAL_ACCOUNTS.getValue()) - .amount(dto.getPayAmount()) // 单位元 + .amount(actualPaidAmount) // 单位元,完整实付(充电+保险) .build(); memberTransactionRecordService.insertSelective(record); }