☰
HarmonyOS 7 ArkTS:组合优惠税额拆分与发票总额守恒
2026/10/8 7:15:15 网站建设 项目流程

这次不是讨论“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)。页面看起来没问题,可发票服务要求同时满足三条守恒式:行优惠之和等于订单优惠,行含税金额之和等于实付金额,行税额与不含税金额相加仍等于含税金额。日志里偶尔出现discountDiff=0.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 元,diffCent=0。代码里变量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): Promise<void> { 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 gross=42860 discount=3740 payable=39120、Allocation A=1744 B=1307 C=689 sum=3740、Tax base=34620 tax=4500 gross=39120、State VERIFYING -> RECONCILED diffCent=0。

截图中的模拟器固定显示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 元,diffCent=0。这一分不再靠某个组件临时补齐,也不会因为刷新、排序或重复点击而换位置。到这里,InvoiceBalance 才从一个金额展示页,变成了可以交给发票、退款和审计链路共同复用的计算边界。

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

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

立即咨询