上一节已经把LedgerDock的金额统一成整数分:TX001为12500分,TX002为8600分,TX004为4300分,有效合计25400分;TX003的12.5x金额仍放在错误列表。目标对账单是27000分,正向差额1600分。但进入账单导入操作后,一个新问题很容易让这些数字失去意义:用户连续选择两次费用导入.csv,如果每次都把三条有效流水插入本地账本,就会出现六笔支出,有效合计也被重复放大。

只在导入按钮上加防抖,解决不了这个问题。用户可能隔一天再次选择同一个文件,可能在写入返回较慢时重新打开页面,也可能把CSV重新排序后又导入一次。文件名相同不一定内容相同;内容相同也不一定字节完全一样,比如CSV记录顺序改变、换行方式调整、说明字段的引号写法不同。这里需要把“解析产物是否相同”和“正式写入是否重复”拆开,前者生成稳定的业务指纹,后者用持久的唯一约束决定一次提交能否重复生效。

本篇约定账期为2026-10。首次导入四条业务记录,接受TX001、TX002、TX004三条并保留TX003错误;第二次选中同一份内容时显示“已登记批次1、本次新增0、重复跳过3、格式错误1”。账本总额仍是25400分,目标27000分、差额1600分保持不变。下一次CSV若把TX002金额改成9000分,即使文件名没变,也不能静默覆盖已记录的8600分,而应进入冲突复核流程。

导入指纹和批次幂等提交封面

先识别业务记录,再谈文件指纹

如果直接对原始文件字节计算指纹,Windows的CRLF和普通LF换行会得到两个结果;哪怕四笔交易在业务上完全一致,只是记录顺序不同,也会被当成不同批次。另一方面,只用交易ID拼接作为指纹,金额被修改后又会误判为同一个内容。LedgerDock选择以“账期 + 已通过校验的记录集合”为规范化输入,字段包括交易ID、分类和整数分金额;对有效记录按ID排序后进行确定性序列化,保证重排CSV不会改变内容身份。

本轮先用完整的规范化字符串作为可比较的业务指纹,而不使用截短的非加密哈希作为唯一性结论。数据量增加以后可以为字符串另外建立摘要索引来缩小候选集,但判定是否相同仍比对规范化原文,避免哈希碰撞导致跳过一份不同账单。此处还要检查同一批次内的重复ID;否则相同编号的两行会以不同排序顺序掩盖上游数据问题。

第一段代码处理规范化输入,不读取文件、不处理页面、也不依赖数据库接口。JSON.stringify把字段边界编码到字符串里,避免简单使用分隔符时出现字段内也包含分隔符的歧义;排序使用固定的ID比较规则,不受当前界面列表展示顺序影响。

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

export function canonicalBatch(scope: string, rows: AcceptedExpense[]): string {
  if (scope.trim().length === 0) { throw new Error('账期不能为空'); }
  const seen: Set<string> = new Set<string>();
  for (const row of rows) {
    if (row.id.length === 0 || seen.has(row.id) ||
        !Number.isSafeInteger(row.cents) || row.cents < 0) {
      throw new Error('存在重复流水或非法金额');
    }
    seen.add(row.id);
  }
  const ordered = [...rows].sort((a: AcceptedExpense, b: AcceptedExpense) =>
    a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
  const tuples: string[][] = ordered.map((row: AcceptedExpense) =>
    [row.id, row.category, row.cents.toString()]);
  return JSON.stringify([scope, JSON.stringify(tuples)]);
}

这段实现刻意使用整数分字符串,而不是显示给用户的125.00元。如果展示格式随语言地区变化,业务指纹也不应变化。TX002的备注曾经跨两个物理行,但当前指纹协议不包含备注,因此说明字段排版变化不会造成重复支出;若项目把备注作为审计关键字段,就需要在后续指纹协议版本中明确加入它。协议变更应记录版本,而不是在已有数据上悄悄改变等价规则。

批次重复与流水冲突属于两种结果

一份内容完全相同的已导入批次,可以直接返回“重复跳过”,此时既不生成新交易,也不改变合计。若内容指纹不同,却含有同一个账期下已经登记的交易ID,需要再比较分类和金额:字段一致时可以视为已存在;金额或分类不同时必须给出冲突,而不是覆盖旧值或插入两条记录。这样用户在逐笔核对时才能发现TX002从8600分改成9000分这类业务变化。

第二段代码提供内存中的规则执行器,便于隔离测试“同一批次二次提交”和“不同批次同ID异值”两条路径。生产数据存储必须以本地数据库事务和唯一索引实现相同语义,不能把此处的Map误当成跨应用重启的账本。这里的原子性来自先完整扫描冲突、再写入新记录:任何一项冲突都会使整批次保持原状态。

export interface BatchCommitView {
  status: string;
  added: number;
  skipped: number;
  issueId: string;
}

export class BatchRuleBook {
  private batches: Set<string> = new Set<string>();
  private expenses: Map<string, AcceptedExpense> = new Map<string, AcceptedExpense>();

  commit(scope: string, rows: AcceptedExpense[]): BatchCommitView {
    const key = canonicalBatch(scope, rows);
    if (this.batches.has(key)) {
      return { status: '重复批次', added: 0, skipped: rows.length, issueId: '' };
    }
    const pending: AcceptedExpense[] = [];
    let skipped = 0;
    for (const row of rows) {
      const id = JSON.stringify([scope, row.id]);
      const old = this.expenses.get(id);
      if (old === undefined) {
        pending.push(row);
      } else if (old.cents !== row.cents || old.category !== row.category) {
        return { status: '同号内容冲突', added: 0, skipped: 0, issueId: row.id };
      } else {
        skipped++;
      }
    }
    for (const row of pending) {
      this.expenses.set(JSON.stringify([scope, row.id]), row);
    }
    this.batches.add(key);
    return { status: '已登记', added: pending.length, skipped, issueId: '' };
  }
}

把样例有效三条按TX001、TX002、TX004传给规则类,第一次提交的added为3;完全相同的第二次提交变成added=0,skipped=3。TX003仍然只是错误列表里的原始行,不进入已接受记录集,因此它不会因为重复导入而被写成一笔金额为零的账。对账目标27000分属于比较口径,不参与导入指纹;重新选择目标对账单不该让同一批费用自动增加一遍。

这里还需要警惕一个顺序问题:先写入TX001,遇到TX002冲突再返回失败,就会形成半批次。上面的扫描阶段没有对expenses进行写操作,直到全部冲突检查完成才统一登记;持久化实现也应把“批次登记 + 新增流水”放进一个关系型事务。批次表可用(scope, canonical_fingerprint)建立唯一约束,流水表用(scope, transaction_id)建立唯一约束,两者任何一次插入触发冲突都需要取消本次提交,不能把一半结果留给下一次重试。

批次指纹校验与导入结果开发环境

异步写入结果不能覆盖新的页面选择

本地数据库读写虽然通常很快,页面仍不能假设每个异步回调都按发起顺序返回。用户导入A文件后立即切到B文件,若A的提交结果晚到,页面的“当前导入文件”和结果统计可能被倒写。给导入操作维护递增代次,界面只消费当前代次的回执;持久化层则仍应完成它已经开始的事务,是否提交由唯一约束决定,与用户当前是否停留在页面无关。

第三段示例把存储能力约束为一个可替换接口。该接口的实现负责在同一事务内检查指纹及流水唯一键,并返回上面的结果结构;调用者负责匹配当前代次、区分业务冲突与存储异常。拆成接口也符合前两篇把CSV解析器放进本地HAR、让UI与实现解耦的做法。

export interface BatchStorePort {
  commitAtomic(scope: string, rows: AcceptedExpense[]): Promise<BatchCommitView>;
}

export class ImportCoordinator {
  private activeGeneration: number = 0;
  constructor(private readonly storage: BatchStorePort) {}

  cancelViewUpdates(): void {
    this.activeGeneration++;
  }

  async submit(scope: string, rows: AcceptedExpense[],
    onCurrent: (result: BatchCommitView) => void,
    onError: (message: string) => void): Promise<void> {
    const generation = ++this.activeGeneration;
    try {
      const result = await this.storage.commitAtomic(scope, rows);
      if (generation === this.activeGeneration) { onCurrent(result); }
    } catch (err) {
      if (generation === this.activeGeneration) {
        onError(`账单入库失败:${String(err)}`);
      }
    }
  }
}

接口commitAtomic不是系统现成方法,而是LedgerDock自己定义的存储契约。落地时可结合华为开发者的ArkTS语言说明、静态共享包HAR与添加依赖项组织源码。关系型存储的事务与唯一键需要由相应数据库适配器承担,不能因为页面已经给出绿色状态就省略数据库层的一致性检查。

重复导入跳过与对账金额保持界面

保留可复核的批次处理痕迹

重复导入时,页面应说明“本次新增0、重复跳过3、格式错误1”,并保持原来25400分的有效合计和1600分差额,不应该提示成“本次导入成功三条”。首次导入与重复导入在视觉上可使用不同的状态词,且允许查看原批次日期与原始记录来源。文件名只作为帮助用户识别的展示字段,不能独自成为唯一性条件。对差额复核来说,用户更需要知道“哪些交易已在账本中”,而不只是看见一个数字指纹。

错误记录还应该有独立处理路径。TX003金额格式错误不会进入财务汇总;如果之后源文件被修正,新的内容指纹将不同,业务层再根据已存在的TX001、TX002、TX004逐笔比较,已有一致记录可跳过,新修正的TX003符合金额协议才作为新交易进入候选集。对于同号异值的旧流水,仍应明确提示复核,而不是借由“修正文件”覆盖原账。这样既能处理正常补录,也保留每次内容变化的因果关系。

性能上,三条有效记录用线性比较已经足够。规模扩大到数万笔时,可为账期和交易号建立复合唯一索引,并对规范化指纹另建辅助索引;但完整内容比较仍不可省。批量写入完成后再一次性刷新汇总,避免每插入一行就重新计算页面总额。页面退出时递增代次阻止旧回执覆盖当前显示,数据库事务则根据自身提交状态完成收尾。导入重复与写入幂等不是同一个UI开关,而是一条贯穿业务身份、持久化约束和异步回执的完整规则。下一节再处理大文件的分块读取,才能确保批次识别在数据量变大以后仍然可靠。

Logo

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

更多推荐