
这次不是讨论“0.1 0.2”的老问题而是复盘一张真实会卡住结算的含税订单三件商品共享 37.40 元优惠逐行税额都正确汇总时却差了 1 分。Demo 名为 InvoiceBalance核对单号固定为INV-1002-0310。一、一分钱没有丢只是被三次舍入藏起来了问题出在一次发票预览改版。订单原价 428.60 元组合优惠 37.40 元实付 391.20 元。三条商品行的原价分别是 199.90、149.80 和 78.90 元优惠需要按金额占比分摊然后再按 13% 含税价反算税额。第一版代码用number算比例每一行都调用toFixed(2)。页面看起来没问题可发票服务要求同时满足三条守恒式行优惠之和等于订单优惠行含税金额之和等于实付金额行税额与不含税金额相加仍等于含税金额。日志里偶尔出现discountDiff0.01重新进入页面又可能消失。这不是 big.js 没用对而是舍入边界放错了。比例是高精度中间量分币才是业务提交量如果每行各自四舍五入三个局部正确值并不保证全局仍然守恒。于是这次我把页面从“展示计算结果”改成“展示一份可验收账本”状态也从简单的READY/DONE换成DRAFT → ALLOCATING → VERIFYING → RECONCILED。二、金额对象只接受字符串分值才进入分配器InvoiceBalance 的目录没有复用以前订单 Demo 的结构。MoneyCodec负责边界转换DiscountAllocator只做分币TaxLedger生成税额账本InvoiceCoordinator管理页面代次页面本身不再参与金额运算。工程入口是entry/src/main/ets其下分别放置pages/InvoiceAuditPage.ets、money/MoneyCodec.ets、ledger/DiscountAllocator.ets、ledger/TaxLedger.ets、model/InvoiceSnapshot.ets与coordinator/InvoiceCoordinator.ets。当前代码首先要解决的问题是禁止浮点数在网络响应、页面状态和 big.js 之间来回穿透。只要金额先变成过number后面再包一层 Big 也无法恢复已经损失的十进制语义。因此金额 DTO 使用字符串最终提交则统一变成整数分。import Big from big.js Big.DP 24 Big.RM Big.roundHalfUp export class MoneyCodec { static toCent(text: string): number { const normalized text.trim() if (!/^-?\d(\.\d{1,2})?$/.test(normalized)) { throw new Error(INVALID_MONEY:${text}) } return Number(new Big(normalized).times(100).toFixed(0)) } static fromCent(cent: number): string { if (!Number.isSafeInteger(cent)) { throw new Error(UNSAFE_CENT:${cent}) } return new Big(cent).div(100).toFixed(2) } }这里刻意没有接受number重载。428.60、37.40在进入toCent()前仍是接口原文转换后得到 42860 和 3740。Big.DP只影响除法等中间计算不能把它理解成页面最终保留位数。方法在订单快照创建时执行一次失败就停留在DRAFT不会生成半份账本。正式项目还要给金额设置业务上限避免一个超大字符串转为number分值后越过安全整数。若业务可能达到该范围整数分也应继续保留为 Big 或字符串。当前 Demo 面向普通零售发票安全整数检查已经足够但这个边界不能从组件层偷偷放宽。三、最大余数法把 1 分交给证据最充分的商品真正需要解决的第二个问题是三条行优惠的向下取整结果只有 3739 分剩余 1 分应该交给谁。不能固定补到第一行也不能每次随机补否则商品排序变化会改变发票明细重放同一订单还可能得到不同结果。import Big from big.js export interface DiscountPart { sku: string discountCent: number remainder: string } export function allocateDiscount( items: Array{ sku: string; grossCent: number }, discountCent: number ): DiscountPart[] { const grossTotal items.reduce((sum, item) sum item.grossCent, 0) const parts items.map((item, index) { const raw new Big(item.grossCent).times(discountCent).div(grossTotal) const floorCent Number(raw.round(0, Big.roundDown).toFixed(0)) return { sku: item.sku, discountCent: floorCent, remainder: raw.minus(floorCent).toFixed(18), index } }) let left discountCent - parts.reduce((sum, part) sum part.discountCent, 0) parts.sort((a, b) new Big(b.remainder).cmp(a.remainder) || a.index - b.index) for (let i 0; i left; i) parts[i].discountCent 1 return parts.sort((a, b) a.index - b.index) }本单的原始向下取整是 17.44、13.07、6.88 元第三行余数最大因此拿到最后 1 分最终变成 17.44、13.07、6.89 元。三行实付随之固定为 182.46、136.73、72.01 元合计正好 391.20 元。排序时保留原始index是为了处理余数完全相同的情况。这个次级规则看似不起眼却决定了同一输入能否得到稳定输出。正式项目更适合使用不会随页面拖拽变化的 SKU 序号或发票行号如果拿当前 UI 顺序做兜底用户换一次排序就会生成另一份合法但不同的明细。分配方法没有修改原始商品数组也不缓存上一次结果。它是纯函数订单重算、单元测试和服务端对账都能使用同一组向量。重复调用不会多补 1 分但调用方仍要避免把“已分配后的金额”再次当原价输入否则会形成二次优惠。四、含税反算之后再做一次账本级守恒优惠守恒后税额仍然不能逐行算完就直接展示。这里要解决的是税额、未税金额和含税金额的三方核对并且把任何差异转换成可定位的错误而不是在 UI 上悄悄修正。import Big from big.js export function buildTaxLedger(lines: Array{ sku: string; netCent: number }) { const entries lines.map((line) { const gross new Big(line.netCent) const taxCent Number(gross.minus(gross.div(1.13)).round(0, Big.roundHalfUp).toFixed(0)) return { sku: line.sku, grossCent: line.netCent, taxCent, baseCent: line.netCent - taxCent } }) const grossCent entries.reduce((s, x) s x.grossCent, 0) const taxCent entries.reduce((s, x) s x.taxCent, 0) const baseCent entries.reduce((s, x) s x.baseCent, 0) if (baseCent taxCent ! grossCent) throw new Error(LEDGER_NOT_BALANCED) return { entries, grossCent, taxCent, baseCent, diffCent: 0 } }当前数据得到税额 20.99、15.73、8.28 元税额合计 45.00 元不含税金额 346.20 元。两者相加仍是 391.20 元diffCent0。代码里变量netCent表示优惠后的含税行金额命名容易误解正式工程我会改为discountedGrossCent本文保留它是为了对应调试截图里的既有日志。税率不能写死在通用层。Demo 只有 13% 一档所以直接使用1.13便于看清反算公式真实发票存在不同税率、免税行和折扣税务口径应该先按税率分组再在组内执行分币与税额回补。千万不要把整个订单先算一个总税额再平均摊回商品那会让单行税率证据失真。五、页面代次挡住重复确认而不是挡住重算第三个工程问题来自交互用户快速修改优惠再点两次“生成账本”旧计算晚到时会覆盖新结果。金额纯函数可以重复执行但提交动作必须受页面代次约束。ObservedV2 export class InvoiceCoordinator { Trace state: string DRAFT Trace snapshot?: InvoiceSnapshot private revision: number 0 private committedRevision: number -1 async reconcile(input: InvoiceInput): Promisevoid { const current this.revision this.state ALLOCATING const snapshot await TaskPoolService.calculate(input, current) if (current ! this.revision) return this.state VERIFYING if (snapshot.diffCent ! 0) throw new Error(INVARIANT_FAILED) if (this.committedRevision current) return this.snapshot snapshot this.committedRevision current this.state RECONCILED } cancel(): void { this.revision; this.state DRAFT } }revision解决的是迟到结果committedRevision解决的是同一结果重复提交两者不是一回事。页面输入改变、离开页面或点击取消时都会推进 revision后台计算即使无法物理中断回来后也失去写入资格。RECONCILED只表示内存账本通过守恒验证不等于发票已经上传正式项目还需要独立的SUBMITTING/SUBMITTED状态和服务端幂等键。页面销毁时不必释放 big.js它没有原生句柄需要收口的是 TaskPool 回调和页面观察对象。如果把 coordinator 放在应用级容器中页面退出后仍能继续计算但必须把 UI 提交点换成事件仓库不能继续持有页面引用。六、DevEco 里我只盯四条日志调试时没有继续打印每一次 Big 运算而是保留能证明守恒的四条日志原价与优惠、三行分配、税额汇总、最终差额。这样既能定位也不会把日志变成另一张账单。四条验收日志依次为INV-1002-0310 gross42860 discount3740 payable39120、Allocation A1744 B1307 C689 sum3740、Tax base34620 tax4500 gross39120、State VERIFYING - RECONCILED diffCent0。截图中的模拟器固定显示INV-1002-0310按钮叫“重新核对”状态为RECONCILED。如果代码区是allocateDiscount()而右侧却出现另一套四舍五入数据图片就失去了调试证据的意义。这里特意圈出第三行的 6.89 元它就是那一分最终落下的位置。七、运行结果之外还要验三类反例真机结果页显示原价 428.60 元、优惠 37.40 元、实付 391.20 元、不含税 346.20 元、税额 45.00 元和差额 0.00 元。页面不是为了好看而是把计算合同一次展示完整。验收时我又补了三类反例。第一类是零金额行必须参与展示但不能在比例分母为零时继续分配第二类是负数退款退款单要使用独立方向不能把负优惠混进正向订单第三类是优惠大于原价应该在输入门禁直接拒绝而不是让某行金额变成负数。还有一个容易被忽略的点金额格式化只负责显示不能再参与计算。Intl.NumberFormat可以把 39120 分展示为¥391.20但不能从格式化字符串反解析业务金额。千位分隔符、币种符号和本地小数点都属于界面语义账本里仍应保存币种、整数分和十进制字符串。八、这次留下的是规则不是一段公式最后稳定下来的并不是“全部改用 big.js”这一句而是四个工程边界接口金额以字符串进入业务提交量以整数分表达比例计算保留精度分币集中在唯一提交点尾差按最大余数和稳定次级键回补页面只接收通过守恒验证且代次仍有效的不可变快照。对账结果最终是RECONCILED优惠分配 17.44、13.07、6.89 元税额合计 45.00 元diffCent0。这一分不再靠某个组件临时补齐也不会因为刷新、排序或重复点击而换位置。到这里InvoiceBalance 才从一个金额展示页变成了可以交给发票、退款和审计链路共同复用的计算边界。