合同核验工作最容易出错的时间点,往往不是发现字段差异时,而是收到新版合同后继续使用旧版核验结论时。前六篇已经围绕 service-agreement 建立稳定条款编号、逐段差异、金额日期规则、宽屏三栏定位、双人修订冲突和离线记录包。现在合同从 r2 进入 r3,部分段落顺序发生变化,个别条款改写,还新增了资料保管条款。旧记录仍然需要保存,但每条记录能否引用新版文本,必须重新判断。

例如,CL-03 付款约定在 r2 中对应金额120000.00元调整为125000.00元,截止日期由2026-11-15调整为2026-11-20。这些差异记录曾被标为待复核。进入 r3 后,即使付款条款仍保留 CL-03 稳定编号,也不能因为编号一致就自动认可旧结论;同一字段在新版本中的正文可能变化,旧记录的判断依据必须跟着原修订版本保存。CL-02 验收标准存在双人修订分歧,更应继续保留冲突来源。

合同文档版本迁移与批量核验封面

迁移的是条款引用与核验关系,不是整个页面状态

对照界面在 r2 下可以准确定位 service-agreement:CL-03,宽屏时以三栏呈现旧文、字段差异和新文。到了新修订版,条款可能移动位置,页面滚动偏移和列表行号自然也会变。迁移任务不应保存“第三行”“滚动到248像素”这种版式细节,而要围绕文档稳定ID、条款ID、内容摘要和字段路径重新建立关系。页面恢复时再根据目标版本找到当前行,并判断是否仍可展开对应差异。

可以把一次文档迁移理解为源修订集合到目标修订集合的映射。优先匹配明确的稳定条款ID;如果ID消失,则将旧记录放入孤立引用队列,而不是按照标题相近就直接认定为同一条款。标题相同也不一定内容相同,所以稳定ID匹配后还要比较文本指纹。指纹相同意味着可以沿用定位关系,指纹不同则表示引用存在但依据有变,需要重新复核相关字段。

在当前业务中,r2 包含 CL-01 到 CL-04 四个条款。r3 保留这四个稳定ID,调整 CL-03 的显示顺序,并增加 CL-05 资料保管。CL-02 正文改写,CL-03 的核验状态仍待确认。界面可以显示“映射4项、新增1项、待复核2项”,这些数值来自应用内的集合操作,而不是对合同法律效力的判断。下面的Node.js代码首先处理稳定ID映射,结果保留来源与目标修订信息。

const assert = require('node:assert/strict');
const oldClauses = [
  { id: 'CL-01', digest: 'scope-a' },
  { id: 'CL-02', digest: 'acceptance-a' },
  { id: 'CL-03', digest: 'payment-b' },
  { id: 'CL-04', digest: 'dispute-a' }
];
const newClauses = [
  { id: 'CL-01', digest: 'scope-a' },
  { id: 'CL-02', digest: 'acceptance-c' },
  { id: 'CL-04', digest: 'dispute-a' },
  { id: 'CL-03', digest: 'payment-b' },
  { id: 'CL-05', digest: 'retention-a' }
];
function buildClauseMigration(from, to) {
  const current = new Map(to.map((c, index) => [c.id, { ...c, index }]));
  const mapped = from.map(c => {
    const target = current.get(c.id);
    if (!target) return { id: c.id, status: 'orphan' };
    return { id: c.id, targetIndex: target.index,
      status: c.digest === target.digest ? 'mapped' : 'changed' };
  });
  const known = new Set(from.map(c => c.id));
  const added = to.filter(c => !known.has(c.id)).map(c => c.id);
  return { fromRevision: 'r2', toRevision: 'r3', mapped, added };
}
const migration = buildClauseMigration(oldClauses, newClauses);
assert.equal(migration.mapped.length, 4);
assert.equal(migration.mapped.find(x => x.id === 'CL-03').targetIndex, 3);
assert.equal(migration.mapped.find(x => x.id === 'CL-02').status, 'changed');
assert.deepEqual(migration.added, ['CL-05']);
console.log(migration);

digest 在这里是短的字段占位值,用来强调迁移算法对内容摘要的依赖。投入文档处理管线时应使用规范化正文计算真正的SHA-256:统一换行与明确允许的空白规则,保留数字、小数位、日期和标点等有合同意义的字符。尤其不能通过删除全部空格或标点来追求摘要一致,否则两个付款条件不同的句子可能被误判为相同。正文指纹与稳定ID互相补充,一个用于定位身份,一个用于发现内容变化。

批量检查需要保留原记录的修订归属

第06篇的离线核验包包含文档ID、修订号、规则版本和记录摘要。迁移到 r3 时,需要把这些字段作为来源证据留在记录中,并生成新的迁移关系,而不是修改旧包里的 revision 字段。旧版的 CL-02 双人分歧可能在新版中仍未解决;旧版的 CL-03.amount 与 CL-03.dueDate 虽然找到了同名字段,也需要核对新版正文是否沿用原值。

批量核验可以使用有限的状态集,例如 mapped、needs_review、orphan 和 new。其中 mapped 仅表示条款定位关系建立成功,不自动升级为“业务结论已批准”。条款内容改写、原记录有冲突、规则版本改变或来源摘要不一致,都应进入待复核集合。对于ID消失的旧条款,可以展示原文快照及可能的候选条款,但不自动转移人工确认记录。对于新增条款则要创建新的核验入口,不能因为旧版中不存在就默认该条已经检查完毕。

下面的批处理函数将迁移映射与原核验记录合并,遵守两个条件:原记录必须保留 sourceRevision,并且只有内容关系没有变化、原状态明确为已批准时,才允许输出 mapped。同一条款的多个字段可以分别输出状态,不会因为其中一项通过就把另一项也标为通过。

const assert = require('node:assert/strict');
const relation = [
  { id: 'CL-01', status: 'mapped' },
  { id: 'CL-02', status: 'changed' },
  { id: 'CL-03', status: 'mapped' },
  { id: 'CL-04', status: 'mapped' }
];
const records = [
  { id: 'check-scope', clauseId: 'CL-01', field: 'scope', sourceRevision: 'r2', state: 'approved' },
  { id: 'check-accept', clauseId: 'CL-02', field: 'deadline', sourceRevision: 'r2', state: 'conflict' },
  { id: 'check-amount', clauseId: 'CL-03', field: 'amount', sourceRevision: 'r2', state: 'review' },
  { id: 'check-date', clauseId: 'CL-03', field: 'dueDate', sourceRevision: 'r2', state: 'review' }
];
function migrateReviews(rows, relations, targetRevision) {
  const index = new Map(relations.map(x => [x.id, x.status]));
  return rows.map(row => {
    const link = index.get(row.clauseId) || 'orphan';
    return { ...row, targetRevision,
      status: link === 'orphan' ? 'orphan' :
        link === 'mapped' && row.state === 'approved' ? 'mapped' : 'needs_review' };
  });
}
const moved = migrateReviews(records, relation, 'r3');
assert.equal(moved.filter(x => x.status === 'mapped').length, 1);
assert.equal(moved.filter(x => x.status === 'needs_review').length, 3);
assert.equal(moved[2].sourceRevision, 'r2');
console.log(moved);

这种策略会把原来“已批准”的结论保持在更严格的条件之下。即使 CL-01 内容没有变化,也应注意规则版本是否仍适用;本例只检查条款关系与记录状态,完整核验流程还要检查规则版本、金额格式与日期有效性。金额可以继续使用以分为单位的整数运算,避免把125000.00元转换为二进制浮点运算结果;日期要按明确的业务格式验证有效日,不应把用户所在设备的当前时区当作合同条款的默认时区。

白色开发工作区中的条款迁移与批量检查

窄屏与宽屏共用一份迁移结果

r2 与 r3 并排查看时,宽屏可以分为来源条款、差异核查和目标条款三栏。手机上则采用条款列表进入单条详情的方式,但两种布局都应使用 docId:clauseId 识别条款。迁移完成后,用户此前选中的 service-agreement:CL-03 应重新定位到目标版本的付款条款,而不是继续依赖旧版列表中的第三行。若新版本将该条移至第四行,界面应随目标索引更新滚动位置,保持选中业务身份不变。

批量操作要避免长时间阻塞界面。可以把待迁移记录按固定条数分组,保留处理游标和来源修订号,并在用户切换文档时丢弃旧请求的结果。进度展示必须区分“映射已完成”和“人工复核已完成”,因为前者只是程序建立条款关系,后者仍需要合适的业务人员判断。对于需要再次打开核验包的记录,应该展示摘要、来源版本及冲突原因,避免用户只看到一个红色状态,却不知道要查看哪个旧字段。

最终输出不应只是一组计数。记录中至少要有文档稳定ID、源与目标修订、迁移时间、条款对应关系、规则版本、待复核列表及结果摘要。把这些字段组织成确定顺序,可以供后续报告生成使用。下方代码生成一份带SHA-256的批量汇总,所有统计均从明细集合派生;原始核验记录继续保留在独立存储区。

const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
function buildBatchReport(input) {
  const items = [...input.items].sort((a, b) =>
    a.clauseId.localeCompare(b.clauseId) || a.field.localeCompare(b.field));
  const summary = {
    mapped: items.filter(x => x.status === 'mapped').length,
    pending: items.filter(x => x.status === 'needs_review').length,
    orphan: items.filter(x => x.status === 'orphan').length
  };
  const payload = { docId: input.docId, from: input.from, to: input.to,
    rulesVersion: input.rulesVersion, summary, items };
  const sha256 = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
  return { payload, sha256 };
}
const report = buildBatchReport({ docId: 'service-agreement',
  from: 'r2', to: 'r3', rulesVersion: '2.1', items: [
    { clauseId: 'CL-03', field: 'amount', status: 'needs_review' },
    { clauseId: 'CL-02', field: 'deadline', status: 'needs_review' },
    { clauseId: 'CL-01', field: 'scope', status: 'mapped' }
  ] });
assert.deepEqual(report.payload.summary, { mapped: 1, pending: 2, orphan: 0 });
assert.match(report.sha256, /^[a-f0-9]{64}$/);
console.log(JSON.stringify(report, null, 2));

对较大的文档集合,摘要计算可以分块,但必须清楚规定摘要覆盖的字节内容,不能将同一份报告在不同设备上序列化成不同顺序。导入外部报告时,先校验尺寸和结构,再核对SHA-256、文档ID与目标修订,之后展示候选迁移结果。SHA-256提供内容完整性检查,业务人员身份和签署权限仍需通过独立的应用权限流程管理。处理失败时,旧版引用与旧核验记录不应被覆盖。

合同版本迁移与待复核条款界面

资料保存与可维护性

HarmonyOS端的列表可以使用ArkUI List按条款生成独立条目,大量记录采用按需加载,条目键使用稳定条款ID而不是数组下标。华为开发者的焦点控制接口支持组件获焦与主动定位;当应用切换布局或显示新的修订版,应该在目标列表建立后再恢复焦点。@Watch可以观察受管理的状态字段变化,但不宜在回调里递归修改同一个状态,使迁移触发重复处理。页面应保留一个清晰的数据加载入口,避免布局切换时重新执行整批核验。

Preferences适合保存当前打开的文档ID、目标修订号和最近选中的条款键;完整的合同正文及大量核验记录需要另行设计可靠存储。对用户选取的外部文件,应用应遵循相应的文件URI访问与授权流程,不能把任意文件路径写死到页面。历史记录也需要与可导出的新报告分开:打开 r3 的页面不应改变 r2 中已经完成的原始核验事实,只有后续复核确认才能产生新的结论记录。

相关技术资料:华为开发者创建列表(List)、焦点控制、状态监听(@Watch)、Preferences键值持久化、文件共享接口。

版本迁移的价值在于让旧记录继续有可解释的来源,同时为新版条款建立新的检查起点。稳定ID解决“在哪里”,正文指纹揭示“有没有变化”,批量核验决定“哪些需要重新看”。将三件事分开处理,合同即便多次改版,也能保持条款定位、差异记录与审核责任之间的连续关系。

Logo

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

更多推荐