W2仓的秋季盘点临近交接时,最难处理的往往不是扫描失败,而是一次操作停在两种状态之间:盘点行已经写入,页面还没有收到保存回执;离线修改已经进入出站队列,网络切换使服务端确认晚了一步;批次刚准备封存,应用重新打开后发现仍有两条待确认操作。如果恢复逻辑粗暴地“再保存一次”,账面数量就可能增加两遍。反过来,若为了避免重复而清空队列,真正尚未送达的修正便会丢失。

前七篇把W2仓A区、A-03-07货位、BRG-001轴承套件和LOT-09批号串成了完整的业务链:盘点行有稳定的复合身份,同一扫码回执按scanId去重;批次快照用固定的revision读取;离线变更形成opId出站日志;远端版本冲突交给人工裁决;归档必须等待必要的确认。本篇不再增加一个写入入口,而是给这些已有结构建立一套恢复次序与一致性核查规则。关键目标是让中断之后的每一条记录都有明确去向,而不是靠页面上的成功提示推测数据状态。

异常恢复与一致性回归流程

先识别中断究竟发生在哪一层

盘点端至少有四个互相独立的状态:盘点行代表当前可编辑数量,扫码回执说明某个输入事件是否被消费,出站日志记录本地修改是否得到远端确认,批次快照则保留已经冻结的浏览视图。它们之间有关联,但不能相互替代。读取到盘点行数量为19,并不意味着编号为scan-108的扫描回执也已落库;看到出站日志标成sent,也不意味着服务器已经接受。网络请求在发送成功后、确认返回前中断,是最典型的“结果未知”情形。

因此,恢复入口先读取持久化记录,而不是直接启动全部同步任务。可以先按批次加载盘点行、扫码回执、出站日志、冲突裁决和归档状态,再把异常分成缺失关联、未确认、版本不一致、非法数量以及快照被错误改写几类。这里特别要注意:同一opId可能在日志中出现多次传输尝试,但业务操作只能有一个逻辑身份;一次重复传输的时间戳不是第二次库存变更的依据。

把一致性条件写成可以复核的规则

回归检查的起点不是复杂框架,而是一组稳定的不变量。扫码回执必须指向现存的盘点行;每条盘点行的数量应是非负整数;确认消息必须匹配原操作身份;归档批次不能带着未解决的冲突或未确认的出站操作;冻结快照的版本号不应被后续可编辑列表的变化覆盖。若只检查界面上显示的数量,很容易漏掉“数量恰好一致但回执缺失”的情况。

下面把核查逻辑保留在应用业务层。它接收已经从数据层读取的普通对象,不依赖特定数据库方法;实际接入时可在查询结果集关闭后,把行数据交给这段逻辑。返回值只包含待处理事项,不在核查阶段修改任何数量。

const assert = require('node:assert/strict');
function inspectCountState(state) {
  const problems = [];
  const lineMap = new Map(state.lines.map(row => [row.lineId, row]));
  const seenScans = new Set();
  for (const row of state.lines) {
    if (!Number.isInteger(row.quantity) || row.quantity < 0) {
      problems.push({ kind: 'INVALID_QTY', id: row.lineId });
    }
  }
  for (const receipt of state.receipts) {
    if (seenScans.has(receipt.scanId)) {
      problems.push({ kind: 'DUP_RECEIPT', id: receipt.scanId });
    }
    seenScans.add(receipt.scanId);
    if (!lineMap.has(receipt.lineId)) {
      problems.push({ kind: 'ORPHAN_RECEIPT', id: receipt.scanId });
    }
  }
  for (const op of state.outbox) {
    if (op.status !== 'acked') {
      problems.push({ kind: 'UNCERTAIN_OP', id: op.opId });
    }
    if (op.status === 'acked' && op.ackOpId !== op.opId) {
      problems.push({ kind: 'ACK_MISMATCH', id: op.opId });
    }
  }
  return problems;
}
const findings = inspectCountState({
  lines: [{ lineId: 'A-03-07/BRG-001/LOT-09', quantity: 19 }],
  receipts: [{ scanId: 'scan-108', lineId: 'A-03-07/BRG-001/LOT-09' }],
  outbox: [{ opId: 'OP-102', status: 'sent' }]
});
assert.deepEqual(findings, [{ kind: 'UNCERTAIN_OP', id: 'OP-102' }]);

这里的sent故意被判为待确认,而不是失败。它表示请求已经尝试发出,但业务确认仍需核查。不同问题需要不同的处理方式:缺失引用要先恢复关联,数量异常需要保留原值并进入人工检查,回执身份不匹配不能直接采用远端数据。把所有异常压成一个布尔值,会让后续按钮只能给出“重试”这种笼统动作。

开发环境中的恢复检查与业务记录

恢复重放必须从持久化检查点开始

应用重新进入前台时,恢复线程先读取最后一个完整检查点,再顺序处理检查点之后的日志。检查点保存的是已确认的业务状态及日志位置,而不是当前页面的临时索引。对扫码记录,应使用scanId判断是否曾经应用过;对远端修改,应以opId检查服务器确认。即使两条事件使用同一物料与货位,也不能因为业务主键相同就丢掉其中一条合法修改。

重放过程中还要区分“重新计算页面状态”和“真正修改持久化业务数据”。前者允许按已有记录重新生成列表、筛选条件和待处理数量;后者必须被事务和幂等键约束。建议将恢复器设计为先计算候选状态,再集中提交可确定的本地变更。遇到同一事件的重复片段,恢复器返回原状态;遇到校验不过的记录则保留问题条目,不继续自动写入。

const assert = require('node:assert/strict');
function replayReceipts(checkpoint, events) {
  const lines = new Map(checkpoint.lines.map(r => [r.lineId, { ...r }]));
  const applied = new Set(checkpoint.appliedScanIds);
  const rejected = [];
  for (const event of events.slice().sort((a, b) => a.seq - b.seq)) {
    if (applied.has(event.scanId)) continue;
    const line = lines.get(event.lineId);
    if (!line || !Number.isInteger(event.delta)) {
      rejected.push(event.scanId);
      continue;
    }
    const quantity = line.quantity + event.delta;
    if (!Number.isInteger(quantity) || quantity < 0) {
      rejected.push(event.scanId);
      continue;
    }
    line.quantity = quantity;
    line.revision += 1;
    applied.add(event.scanId);
  }
  return { lines: [...lines.values()], appliedScanIds: [...applied], rejected };
}
const checkpoint = {
  lines: [{ lineId: 'bearing', quantity: 18, revision: 2 }],
  appliedScanIds: []
};
const scan = { seq: 5, scanId: 'scan-108', lineId: 'bearing', delta: 1 };
const once = replayReceipts(checkpoint, [scan, { ...scan, seq: 6 }]);
assert.equal(once.lines[0].quantity, 19);
assert.equal(once.appliedScanIds.length, 1);

这段代码强调重放时的身份判断。实际项目中,写盘点行与写扫码回执应放在同一数据库事务里,不能先把数组计算出来就当作持久化成功。若日志本身不完整,例如事件序列中间缺了一段,不能凭序列号连续这一假设自动补数量,而应把缺口记录到恢复清单。业务版本冲突同样不属于扫码回执重放能解决的范围,仍须沿用第五篇的人工裁决入口。

列表恢复与快照恢复分开做

第三篇建立的只读批次快照是排查时的重要参照,但它不是恢复写入的目标。恢复扫描后,当前工作明细可以更新到更高版本,用户重新打开历史批次时看到的仍应是冻结时那份revision 6。如果恢复器直接用最新列表覆盖快照,查账人员会失去“盘点当时看到什么”的证据。因此,快照校验重点检查原始行数、复合主键集合和摘要,而当前明细校验重点检查数量、事件引用和操作版本。

在较大的仓库中,不能一次把所有历史记录读入页面内存。利用第六篇的复合索引按批次与货位分段读取,逐段计算摘要和异常数;读取完成后释放结果集。用户可以看到已检查的货位范围、仍待确认的操作和异常类别,不需要看到内部数据库连接细节。恢复过程被打断时,保存的是“已核查到哪个稳定业务键”,而不是已经滚动到第几屏。

手机端的一致性核查与待处理操作

同步确认与恢复动作不能抢同一条记录

恢复期间最危险的竞态是两个入口同时处理同一opId:自动同步收到确认回执,用户又点击“检查待处理项”,两个分支都尝试更新操作状态。如果使用各自读到的旧状态直接覆盖,后写入的一方可能把acked改回pending。数据层更新应带预期状态与版本条件,成功后再统一刷新视图;不满足条件则重新读取最新状态,不把结果判为新一次提交。

回归门禁可以用一个纯函数明确表达:存在待确认操作就不能封存;有未解决冲突就不能进入清理阶段;快照摘要异常必须先定位原因。对可重复计算的工作状态,恢复器给出下一步建议;对不可逆的删除与归档,交由明确的人机确认。恢复入口不应该因为网络恢复就触发一连串不可撤销动作。

const assert = require('node:assert/strict');
function planRecovery(state) {
  const pending = state.outbox.filter(op => op.status !== 'acked');
  const openConflicts = state.conflicts.filter(c => c.status !== 'resolved');
  const actions = [];
  if (!state.snapshotDigestMatch) actions.push('CHECK_SNAPSHOT');
  if (pending.length > 0) actions.push('RECONCILE_ACK');
  if (openConflicts.length > 0) actions.push('MANUAL_REVIEW');
  const mayArchive = state.snapshotDigestMatch &&
    pending.length === 0 && openConflicts.length === 0 &&
    state.batchStatus === 'sealed';
  return { actions, mayArchive, pending: pending.map(op => op.opId) };
}
const state = {
  batchStatus: 'sealed', snapshotDigestMatch: true,
  outbox: [{ opId: 'OP-102', status: 'sent' }],
  conflicts: [{ id: 'conflict-1', status: 'resolved' }]
};
assert.deepEqual(planRecovery(state), {
  actions: ['RECONCILE_ACK'], mayArchive: false, pending: ['OP-102']
});

回归核对清单要对准可恢复的故障

恢复链路至少要覆盖这些输入组合:扫码事务写入前被中断、提交后回执延迟显示、网络请求发出后确认丢失、相同opId多次重送、远端版本前移、批次已经封存却仍有悬而未决的出站记录。每个组合都有不同的预期:已经消费的扫描不能再次加数;结果未知的远端操作先查确认状态;版本冲突保留本地与远端两份证据;封存条件不满足时不清理日志。

还需要覆盖更细的破坏性边界。例如读取日志时发现损坏记录,应隔离该条并继续提供可查看的历史数据,而不是把整张盘点表清空;检测到存储空间不足,应停止新增归档文件,保留尚可读取的出站操作;重试次数达到上限时,把操作留在队列并标记具体原因。错误信息要带稳定业务身份与发生阶段,敏感的人员联系方式和原始扫描内容不应进入诊断记录。

华为开发者文档对数据可靠性、关系数据存储、备份恢复和访问控制分别有说明,相关入口可参考Data Reliability and Security Overview及HarmonyOS开发文档中心。其中数据库备份与恢复解决的是持久化文件可靠性;业务scanId、opId的去重、快照一致性和人工冲突裁决仍由应用协议负责,不能混为一套机制。

回头看这套盘点工程,恢复并不是把“失败的动作重新跑一遍”,而是把各层已有证据重新对齐,再确认哪些操作可以安全续做。稳定主键给了记录身份,事务限制了局部写入,回执帮助判定是否重复,版本保证覆盖有依据,归档门禁保留了最终结果。最后这道一致性核查把前七篇的设计真正连接起来,也让异常后的处理路径有了可复核的出口。

Logo

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

更多推荐