CSV解析器到了第四篇,已经能把跨行说明字段还原成一条业务记录,并指出错误所在的物理位置。接下来要处理的是最容易被忽略的财务细节:金额。界面展示的是“125.00元”,文件给到的却可能是“12500分”;如果直接用小数求和,金额位数和类型转换的差异会传到对账页。更糟的是,TX003的金额原文为12.5x,如果把它交给宽松的数值解析函数,有机会误读成一个合法前缀。

这份费用导入.csv继续沿用前四篇的数据:TX001物料12500分,TX002配件8600分且说明字段跨物理第3—4行,TX003差旅的金额原文为12.5x,TX004办公4300分。逻辑业务记录四条,有效三条,错误一条。有效合计是25400分。本篇增加一笔来自对账单的期望总额27000分,约定差额 = 目标金额 - 有效合计,所以差额为正1600分。这个数字不是可自动补录的账款,只说明两组数据的总额尚未对齐。

整数金额对账和差额定位封面

先决定金额的唯一内部单位

如果金额字段使用“元”的浮点数,常见写法会把125.00转成125、86.00转成86,再乘以100进入数据库。这种转换看起来直接,但遇到0.1、0.2一类数值时,中间计算可能出现二进制浮点误差。LedgerDock采用一个更窄的协议:解析HAR输出的金额字段就是非负整数“分”,不会在解析阶段出现小数元。显示时才把整数拆成整数部分与两位小数。

金额原文必须匹配十进制整数的完整格式。12.5x、1,200、带空格的拼接内容都不能靠parseInt截取前半段。源文件的前后空格是否允许,需要协议层作明确选择;当前要求去掉字段前后的空白,但字符串内部的分隔符仍属于非法字符。完成匹配后还要检查Number.isSafeInteger,否则一串非常长的数字即使满足正则,也可能超过JavaScript安全整数范围。

export interface AmountResult {
  ok: boolean;
  cents: number;
  issue: string;
}

export function parseCents(raw: string): AmountResult {
  const value = raw.trim();
  if (!/^(0|[1-9][0-9]*)$/.test(value)) {
    return { ok: false, cents: 0, issue: '金额分字段格式错误' };
  }
  const amount = Number(value);
  if (!Number.isSafeInteger(amount) || amount < 0) {
    return { ok: false, cents: 0, issue: '金额超出安全整数范围' };
  }
  return { ok: true, cents: amount, issue: '' };
}

代码故意不接受负数,原因是这一版导入处理的是费用支出明细而不是冲红流水。退货、冲账若需要负数,应扩展业务类型并为负数定义明确规则,不能把校验随意改成“任何包含数字的字符串都可以”。针对输入中超过安全整数上限的情况,界面要保留原文本和所在行列,并要求更正原始数据,而不是在溢出后继续求和。

错误记录在汇总之前就被隔离

有些对账页面为了让汇总看起来完整,会在金额解析失败时填0。这样做比直接报错更危险:一条未知金额变成了确定的零元支出,用户看到的差额就失去了含义。LedgerDock仍让TX003保留在错误列表,展示原值12.5x与具体定位,但有效求和过程接收的集合里不包含这条记录。

另外一个经常被忽略的问题是重复流水号。CSV来源不同,确实可能出现相同交易号;在当前应用的账单范围内,我们要求每个id唯一。如果TX002出现两次,不允许把两次金额当作两笔有效费用。检查顺序应是:字段校验、身份校验、金额合法性校验,最后才做汇总。检查失败不会自动覆盖已有记录,错误行必须保留可追溯的信息。

export interface CsvExpense {
  id: string;
  category: string;
  amountText: string;
}

export interface AcceptedExpense {
  id: string;
  category: string;
  cents: number;
}

export interface ReconcileInput {
  accepted: AcceptedExpense[];
  rejectedIds: string[];
}

export function acceptExpenses(rows: CsvExpense[]): ReconcileInput {
  const seen: Set<string> = new Set<string>();
  const accepted: AcceptedExpense[] = [];
  const rejectedIds: string[] = [];
  for (const row of rows) {
    const amount = parseCents(row.amountText);
    if (row.id.length === 0 || seen.has(row.id) || !amount.ok) {
      rejectedIds.push(row.id);
      continue;
    }
    seen.add(row.id);
    accepted.push({ id: row.id, category: row.category,
      cents: amount.cents });
  }
  return { accepted: accepted, rejectedIds: rejectedIds };
}

这里选择只把合格记录的ID加入seen,适合“同一个编号出现错误行后允许一条修正行进入待处理区”的当前导入交互。如果业务规则要求“文件中重复出现的所有同号行都报错”,则应先分组统计,再统一剔除对应ID。两种策略不能混用,否则同一份文件的最终合计会随着行顺序发生变化。生产场景应该把去重策略写进导入协议,并通过固定的数据样本覆盖两种输入顺序。

开发环境中的金额校验、汇总逻辑与对账预览

汇总不能越过安全整数上限

三条金额相加是12500 + 8600 + 4300 = 25400。业务上没有小数,仍然不能认为整数求和天然安全。JavaScript的Number只有一定范围内的整数能保持精确表示。如果数据集来自多个周期、多种币种或超长文件,必须在每次加法前判断是否会越界。币种也必须有独立维度;不能把人民币分和其他货币的最小单位直接混在同一个合计中。

对账结果要携带“有效合计”“目标金额”“差额”“错误记录数”四组信息。差额采用有符号整数:正数代表目标大于已接收金额,负数代表已接收金额超过目标,零代表总额相同。总额相同也不能据此认定每条流水都匹配;逐笔编号、时间和收款方匹配属于下一层对账问题,本篇先把金额汇总协议做扎实。

export interface MoneyReport {
  acceptedCount: number;
  acceptedCents: number;
  targetCents: number;
  differenceCents: number;
}

export function reconcile(rows: AcceptedExpense[], target: number): MoneyReport {
  if (!Number.isSafeInteger(target) || target < 0) {
    throw new Error('目标金额格式错误');
  }
  let sum = 0;
  for (const row of rows) {
    if (!Number.isSafeInteger(row.cents) || row.cents < 0 ||
      sum > Number.MAX_SAFE_INTEGER - row.cents) {
      throw new Error('金额合计超出安全整数范围');
    }
    sum += row.cents;
  }
  return {
    acceptedCount: rows.length,
    acceptedCents: sum,
    targetCents: target,
    differenceCents: target - sum
  };
}

target - sum可能得到负数,但两个操作数都已通过安全整数与非负检查,差的绝对值不会超过两者较大值,因此结果仍处于安全范围。代码没有在业务逻辑内提前格式化“254.00元”,输出仍是分单位的整数;UI根据币种和地区做展示时再负责分隔符、符号位置与两位小数。这样可以避免“显示样式”和“数学结果”之间互相污染。

账单对账页面中的有效合计与正差额

差额提示应该保留计算口径

页面从四条业务记录里筛选出TX001、TX002、TX004三条合法金额,分别是12500、8600、4300分,得到25400分;对账目标27000分,差额+1600分。TX003金额格式错误,不参与合计。结果页应该明确写出“目标减有效合计”的方向,并提供错误行入口。否则相同一个1600,用户可能不知道是缺支出记录,还是账单来源汇总了其他项目。

出现差额时不应直接把1600分补到任何交易号。正确路径是查看错误记录与账单范围,确认日期、币种、分类、去重策略及是否存在遗漏行。特别是TX002含跨行说明,不能因为物理行数多一行就重复计算它的金额。上篇定义的逻辑记录边界与本篇整数金额协议必须串起来:解析器只输出完整的一条记录,金额校验接收单条记录的原始字段,汇总器接收校验后的整数列表。

异常处理需要明确区分三种层次。字段错误属于数据问题,应积累到错误列表并继续分析其他行;超出安全整数上限属于合计不可继续的问题,应停止形成结果;对账目标为空或非法属于参数问题,应让页面提示用户重新选择对账口径。页面退出时清理临时选择状态,若采用异步文件导入还需防止旧文件解析完成后覆盖新文件结果。可以为每次导入维护一次递增序号,仅接收当前序号对应的结果,而不是依赖回调恰好按顺序返回。

性能方面,完整合计只需要对有效记录遍历一次,时间开销随记录数线性增长。小账本无需复杂并发;大量记录可按块解析并累计,但仍需保留每条错误的原始定位,避免为了减少内存而抹掉审计依据。无论是否采用后台任务,都不能在每一行读入时反复刷新整个明细页面。解析、校验、汇总结束后集中提交不可变的结果对象,才能让页面状态与账单来源保持一致。

华为开发者的ArkTS语言说明介绍了语言的类型检查和运行机制;开发静态共享包HAR与添加依赖项可用于核对解析模块的工程组织。本篇的整数金额、错误隔离与差额计算属于业务协议,重点是让输入和输出都有明确的含义。

到这里,LedgerDock不再停留在“能够打开一份CSV”。从跨行记录恢复、原始错误定位、HAR接口封装,到整数求和与差额呈现,这条链路已经能让每个数字追溯到对应的业务记录。下一篇进一步讨论逐笔匹配时,仍可保留这套金额口径,避免把新的匹配规则写回底层解析器。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐