Skip to content

用户退了一件,这件该退多少没数

三件商品一张 50 元券,50 除以 3 除不尽,明细加起来不是 50。

我跑了 20 万单:每单 2~5 件,49065 单分摊之和不等于优惠总额,占 24.53%。
一天一千单,每天两百多笔。不报错。退款那天一起爆。

用户在小程序上先看到一个数。申请退款页把整单实付除以件数,再乘要退的件数。
三件价格不一样,这个数一开始就不是「这一件该退多少」。

后端退的是明细上那一列实付。列对了,两边还可能对不上;
列不对,小程序展示、退款单、商户结算,三个数各说各的。

订单总价始终是对的。用户付的钱也是对的。错的是没人日常会看的那一列。

申请页除一下,测试再除一下

申请页那段可以写成这样:

js
let unit = pay / qty;
let show = (unit * n).toFixed(2);

pay 是整单实付,qty 是整单件数。
100 块和 50 块两件,用了 30 块券,实付 120。
退那件 100 的,页面显示 60。

后端如果按明细实付退,这一件不该是 60。
两边差的不是分,是「这一件到底摊了多少券」有没有存下来。

测试也爱用整除。两件各 100,券 50,每件摊 25,assert 通过。
20 万单里对不上的 24.53%,用的是 2~5 件随机金额。
整除的用例证明不了除不尽。

一张券为什么必须摊到行上

真实系统里一张券的旋钮不少:平台还是商家发、满减还是折扣、
固定有效期还是领取后 N 天、能不能跨店。

光发行方 × 六种类别 × 满减/折扣,就是 24 种
叠加要回答哪些能一起用、先算谁。

跨店券是那个逼你必须分摊的旋钮。
一张券摊到不同商户的商品上,结算是分开的。
订单上只记一个「优惠总额」,月底两家商户都说这 50 块是自己出的。

复杂度不在算术,在组合。算术错了,组合会把错放大到每一行。

定死三条,其余都好办

一、先算门槛,再算优惠

满 100 减 20,两张,商品 110:

  • 按原价:两张都能用,减 40
  • 按减完再判:第一张减完剩 90,第二张作废,只减 20

同样两张券,差 20 块。

我选按原价判断门槛,并写进规则。
用户预期通常是「这单 110,两张满 100 的都能用」。
更重要的不是选哪个,是选了就写下来,别变成隐式行为

二、折扣和满减同时在,先算折扣

100 的商品,8 折,再满 50 减 10:

  • 先折后减:80 再减 10 = 70
  • 先减后折:90 再 × 0.8 = 72

差 2 块。一天一万单就是两万。

先折扣后满减。 折扣改「这件值多少钱」,满减改「这单少付多少」。
不是同一个动作,顺序不能靠手感。

三、优惠摊到明细,除不尽给最后一件

订单表记一个「优惠总额」不够。每一笔优惠都要落到商品行。

按金额占比摊。前 n-1 行向下取整,最后一行吃掉差额:

java
import static java.math.BigDecimal.ZERO;
import static java.math.RoundingMode.DOWN;

static void split(
        BigDecimal coupon,
        BigDecimal[] amt) {
    BigDecimal tot = ZERO;
    for (BigDecimal a : amt) {
        tot = tot.add(a);
    }
    BigDecimal used = ZERO;
    int n = amt.length;
    for (int i = 0; i < n; i++) {
        BigDecimal share;
        if (i == n - 1) {
            share = coupon.subtract(used);
        } else {
            share = coupon
                .multiply(amt[i])
                .divide(tot, 2, DOWN);
            used = used.add(share);
        }
        amt[i] = share;
    }
}

别用四舍五入然后祈祷加起来正好。

不做兜底时,20 万单跑下来:

模拟订单:   200000 单
分摊对不上: 49065 单
占比:       24.53%
最大偏差:   2 分
最坏的一单: 券 269.76 分摊到 5 件,
            合计 269.78,差 2 分

接近四分之一的订单会对不上。 不是极端情况,是日常。

平时看不出来。出事在退款:按明细退,跟当初优惠对不上。
一天两百多笔,谁也查不动。

券、活动、会员折扣,每一种单独分摊、单独记一列。
不要合并成一个「优惠总额」——退款时要知道哪部分是平台的、哪部分是商户的。

申请页那个 pay / qty,在分摊列写上之后可以改成读这一列。
展示和结算用同一个数。 两套算法并存,客服会对着两个屏幕吵架。

退多少、券退不退,写代码之前定死

用户用 100 元券买了 200 的东西,实付 100。全额退,退多少?

退 100,券不退。 —— 用户会说券没了。

退 100,券退回账户。 —— 常见,但要处理券已过期。

退 200。 —— 你亏 100,别这么干。

没有标准答案,但必须在设计阶段定死,写进注释。
运行时再讨论,讨论的就不是「该退多少」,是「已经退错的这几百笔怎么办」。

常见问题对照表

现象真正的原因解法
小程序显示的退款额和实际到账不一致申请页用整单实付÷件数展示改读明细上的分摊列
部分退款金额算不出来优惠没摊到明细,只记了订单总优惠每笔优惠分摊到商品行,单独记列
分摊之和比优惠总额差一分每行四舍五入(实测 24.53% 会中)前 n-1 行向下取整,最后一行兜底
单测绿、线上退一件就错用例金额都能整除用 3 件 × 除不尽的券额做用例
同样两张券,不同订单减的钱不一样门槛按原价还是按剩余,实现不一致定死一种,写进文档和注释
折扣和满减一起用,金额有争议计算顺序没定义先折扣后满减,并明确写出来
跨店券结算时算不清谁出的钱没按发行方拆分优惠来源分摊时区分平台补贴和商户补贴
券在有效期内却提示不可用固定时间段和领取后 N 天漏判一种两条分支都覆盖,写测试
用券订单退款后,券的状态没人管退款只处理了钱券的回退纳入退款处理单元
限量券超发剩余数量的扣减没做并发保护用带条件的 UPDATE,不要先查后改

这条链路上的其他几篇

签名-A

大粽子