☰
HarmonyOS 7 + decimal.js:多币种十进制定点计算与余差归集
2026/10/7 20:01:04 网站建设 项目流程

账单页没有报错,三个人的金额看起来也都像那么回事,可底部“待收合计”总比应收少两分钱。更难受的是,改一下汇率再撤销,第二个人和第三个人有时会互换一分钱;同一张账单反复打开,快照哈希也会变化。这个问题不是把toFixed(2)多调用几次就能解决。Demo 叫SplitMint,页面是RoundingAuditPage,本轮任务号MONEY-1842,我用一张美元账单把整个计算链重做了一遍。

一、两分钱从哪里丢的,先把账算明白

原始账单是USD 183.47,结算汇率7.0934,服务费6.00%。精确乘积为CNY 1379.51166388,结算总额按半入规则保留两位得到1379.51。三位参与者权重为3:2:2。如果每份先向下截断到分,初稿是591.21 / 394.14 / 394.14,合计1379.49,余差正好 2 分。

最终分配不能把 2 分随手加给第一个人。SplitMint 使用最大余数法:比较每个人被截掉的小数残差,优先把一分钱补给残差大的份额;残差相同则按参与者稳定 ID 排序。最终金额固定为591.22 / 394.15 / 394.14,合计1379.51。无论列表显示顺序如何变化,只要输入快照不变,结果和快照哈希就必须不变。

这次选用decimal.js 10.6.0。npm 当前页面标注最新版为 10.6.0;项目 README 说明它支持任意精度十进制、实例方法不会修改原值,并建议高位数值用字符串传入,避免 JavaScript Number 在进入库之前就损失精度。decimal.js npm;decimal.js 官方仓库。这些边界比“库能算小数”更重要:如果先执行0.7 + 0.1再交给 Decimal,误差已经发生,后面再高精度也救不回来。

二、工程里只允许一种金额入口:字符串

SplitMint把pages/RoundingAuditPage.ets、money/SettlementEngine.ets、money/RemainderAllocator.ets、snapshot/CalculationSnapshot.ets和model/Participant.ets分开。页面不参与数学运算,只接收显示字符串;引擎不关心 ArkUI 生命周期,只接受不可变输入;快照层负责代次和哈希,保证重复点击、页面重建或异步汇率返回不会提交不同版本。

第一段代码解决输入精度和舍入规则。配置使用 28 位有效数字与ROUND_HALF_UP,所有外部值先通过正则校验,再以字符串创建 Decimal。服务费以比例字符串表示,不能把 UI 中的6.00当作已经除以 100 的值。中间计算不截断,只有结算币种边界才保留两位。

importDecimalfrom'decimal.js';Decimal.set({precision:28,rounding:Decimal.ROUND_HALF_UP});exporttypeSettlementInput={sourceAmount:string;fxRate:string;serviceRate:string;};exportclassSettlementEngine{staticparse(input:SettlementInput):Decimal{[input.sourceAmount,input.fxRate,input.serviceRate].forEach((value)=>{if(!/^\d+(\.\d+)?$/.test(value)){thrownewError(`INVALID_DECIMAL:${value}`);}});constsource=newDecimal(input.sourceAmount);constfx=newDecimal(input.fxRate);constfeeRatio=newDecimal(input.serviceRate).div('100');if(source.isNegative()||fx.lte(0)||feeRatio.isNegative()){thrownewError('OUT_OF_RANGE');}returnsource.times(fx).times(feeRatio.plus(1)).toDecimalPlaces(2,Decimal.ROUND_HALF_UP);}}

这里返回的是 Decimal,不是 Number。页面最终显示时调用toFixed(2),数据库保存时写入分单位整数或定点字符串,不能中途toNumber()再写回。资源释放方面,Decimal 实例是普通不可变对象,不持有原生句柄;真正要控制的是重复创建规模。列表每次重绘不应重新计算全账单,计算只发生在输入快照变化时。

三、余差归集要排序“残差”,不能排序“金额”

很多实现会把剩余分配给金额最大的人。权重简单时看不出问题,但最大金额不一定拥有最大被截断残差。正确做法是先把总金额转换为整数分,再计算每份的精确分值、向下取整值和残差;剩余几分,就按残差从大到小补几次。稳定 ID 是第二排序键,避免同残差时受当前列表顺序影响。

第二段代码解决 2 分的确定性归集。输入权重仍是字符串,最终输出也是两位小数字符串。分配器先验证权重总和大于 0,再以ROUND_FLOOR得到基础分;remaining的上界小于参与人数,因此最后循环很短。

typeWeightedMember={id:string;name:string;weight:string};typeShare={id:string;name:string;amount:string;cents:number};exportfunctionallocate(total:Decimal,members:WeightedMember[]):Share[]{consttotalCents=total.times(100).toDecimalPlaces(0,Decimal.ROUND_HALF_UP);constweightSum=members.reduce((sum,member)=>sum.plus(newDecimal(member.weight)),newDecimal(0));if(weightSum.lte(0))thrownewError('ZERO_WEIGHT');constrows=members.map((member)=>{constexact=totalCents.times(member.weight).div(weightSum);constfloor=exact.toDecimalPlaces(0,Decimal.ROUND_FLOOR);return{member,floor,residue:exact.minus(floor)};});letremaining=totalCents.minus(rows.reduce((sum,row)=>sum.plus(row.floor),newDecimal(0))).toNumber();constorder=[...rows].sort((a,b)=>{constresidueOrder=b.residue.comparedTo(a.residue);returnresidueOrder!==0?residueOrder:a.member.id.localeCompare(b.member.id);});for(leti=0;i<remaining;i+=1)order[i].floor=order[i].floor.plus(1);returnrows.map((row)=>({id:row.member.id,name:row.member.name,cents:row.floor.toNumber(),amount:row.floor.div(100).toFixed(2)}));}

这里只把最终整数分转换为 Number,因为金额上限在产品规则内且整数分不会超过安全整数;如果是企业级大额结算,cents也应保留字符串或 Decimal。localeCompare的参与者 ID 必须是稳定 ASCII 业务键,不应使用会随语言变化的姓名。成员增删时整个分配重新计算是预期行为,不能试图在旧结果上修补,否则余差排序会被上一次状态污染。

四、快照幂等不是缓存命中,而是提交资格

页面快速改汇率时会启动多次计算。旧计算虽然耗时不长,也可能在动画和数据保存之间晚返回。SplitMint 为每次输入变更增加calcEpoch,输出携带标准化快照:金额、汇率、服务费、按 ID 排序的权重和算法版本remainder-v2。同一快照重复计算 20 次,结果哈希应始终为calc_v4_9f2c,差异数为 0。

第三段代码解决重复点击与页面重建。先标准化输入,再生成签名;只有代次仍是当前值,且各份整数分之和等于结算总分,才把结果提交给 ArkUI。离开页面时dispose()提升代次,旧 Promise 即使返回也只能记录STALE_CALC_DROPPED。

typeCalculationResult={taskId:string;hash:string;total:string;shares:Share[];epoch:number;};exportclassCalculationSnapshot{privatecalcEpoch=0;asynccalculate(input:SettlementInput,members:WeightedMember[]):Promise<CalculationResult>{constmine=++this.calcEpoch;consttotal=SettlementEngine.parse(input);constordered=[...members].sort((a,b)=>a.id.localeCompare(b.id));constsignature=JSON.stringify({...input,members:ordered,algo:'remainder-v2'});constshares=allocate(total,ordered);constshareCents=shares.reduce((sum,item)=>sum+item.cents,0);if(mine!==this.calcEpoch)thrownewError(`STALE_CALC:${mine}`);if(shareCents!==total.times(100).toNumber())thrownewError('SUM_MISMATCH');return{taskId:'MONEY-1842',hash:Hash.short(signature),total:total.toFixed(2),shares,epoch:mine};}dispose():void{this.calcEpoch+=1;}}

Hash.short在 Demo 里只是稳定内容哈希的封装,哈希不承担安全签名。要落库时还需保存原始输入、算法版本和每份整数分,不能只保存结果哈希。另一个易错点是 JSON 字段顺序:成员必须先按稳定 ID 排序,输入字段也要固定结构,否则数学结果相同,序列化文本却不同。

五、调试日志要同时证明金额守恒和结果稳定

本轮 HiLog 固定五行:task=MONEY-1842 source=USD183.47 fx=7.0934 fee=6.00%、rawCNY=1379.51166388 settled=1379.51、floor=591.21,394.14,394.14 remainder=2c、final=591.22,394.15,394.14 sum=1379.51、replays=20 mismatches=0 hash=calc_v4_9f2c state=SNAPSHOT_LOCKED。第一组证明换汇边界,第二组证明余差来源,第三组证明归集结果,最后一组才证明重复计算没有漂移。

IDE 图左侧是SplitMint的计算、归集与快照目录,中间展示RemainderAllocator.ets的残差排序,右侧模拟器显示同一美元金额、汇率、服务费和三人结果,底部逐行给出上述日志。少量红色标注只指出字符串输入、2 分余差和 20 次零差异,不把页面做成会计报表拼图。

六、运行结果:一分钱也必须有确定归属

18:42 的RoundingAuditPage状态为SNAPSHOT_LOCKED。输入USD 183.47、汇率7.0934、服务费6.00%,结算CNY 1379.51;权重3:2:2,林舟591.22、周宁394.15、陈屿394.14,合计与结算金额完全一致。余差 2 分已经归集,重复复算 20 次、差异 0,快照哈希calc_v4_9f2c。

手机页保留“重新计算”和“锁定快照”两个按钮,顶部完整显示时间、5G、Wi‑Fi、信号、电量图标和 76% 数字。最重要的不是大号总额,而是原始金额、汇率、服务费、权重、每人金额、余差与哈希能在一屏互相核对。两处红色批注分别指向 2 分归集和mismatches=0,没有遮挡结算内容。

七、这套算法不能越过的产品边界

第一,舍入规则必须由业务确认。财务产品可能要求银行家舍入、向下取整或按币种最小单位处理,不能因为ROUND_HALF_UP常见就写死所有场景。第二,不同币种的小数位不同,日元通常不按两位处理;Demo 固定结算币种 CNY,生产代码应从币种元数据读取精度。第三,退款和负数分摊需要单独定义余差方向,不能直接复用正数循环。

第四,汇率必须携带来源与生效时间,本地快照不能只留一个7.0934。第五,成员权重变化会生成新快照,旧快照应只读保留,不能覆盖历史结算。第六,Decimal 的全局配置会影响同一运行环境内其他调用;大型项目更适合使用Decimal.clone()创建业务专用构造器,避免某个模块修改 precision 后悄悄改变另一个模块的结果。

八、真正要消灭的不是浮点数,而是不确定性

这次两分钱问题最终拆成三层:Decimal 保证输入和中间运算不提前丢精度,最大余数法保证总额守恒且分配可解释,快照代次与稳定排序保证同一输入永远得到同一结果。SNAPSHOT_LOCKED不是“按钮不能再点”,而是金额、算法版本、参与者顺序、余差归属和生命周期都已经具备可重放证据。对账单产品来说,一分钱很小;对工程一致性来说,它恰好足够暴露整条链路是否可信。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询