同一张库存台账,平时领用一盒笔是一笔操作;月底盘点却可能一次调整几十种物料。把盘点表格逐行循环调用上一节的 submit(),看似复用了功能,实际上把“同一张盘点单”拆成许多可分别成功、分别失败的提交。前十行保存成功,第十一行写盘失败,管理员究竟应该重做整张盘点,还是只补最后一行?另一个风险来自“撤销”:当其他人已经在盘点后领用了转接头,界面再允许撤销盘点,就可能把那次合法领用一起覆盖。

SupplyLedger 已有 SupplyItem 的观察链、按业务 ID 筛选的视图、supply_snapshot 持久化以及 StockCommitQueue 串行提交。第四篇又给快照加上 version: 2、movements 与 nextEventNumber。本篇不另建数据库,也不把仓库管理写成云端同步平台,而是围绕一个明确的批次:一次盘点、一次校验、一次持久化提交;撤销是带条件的补偿操作,不是把屏幕倒回上一帧。

SupplyLedger 批量盘点技术封面

一、把批次定义成“目标数量”,而不是一连串按钮

日常出入库动作的输入是增减数量,盘点动作的输入则是现场确认的绝对结存。沿用示例数据,A4 打印纸原有 24 包,实点 22 包;中性笔原有 8 盒,实点仍为 8 盒;USB-C 转接头原有 3 个,实点 5 个。本批只有两项差异,分别是 -2 与 +2。提交后应显示 22、8、5,并新增两条流水;“中性笔没有差异”不需要制造一条数量为零的流水。新页面标题叫“盘点核对”,主按钮叫“确认盘点(2 项)”,提交成功后提供“撤销上次盘点”。

盘点核对页面:提交成功及撤销入口

数据边界需要先固定。界面输入只拥有 counted 草稿,不能直接改动 @Observed SupplyItem.stock。库存事实始终以提交队列中的 SupplyLedgerV2 为准;页面拿到写入成功的快照后再 rehydrate() 并 refreshVisible()。所谓批次一致性,是指在本应用的单个串行写入入口中,用一个候选 JSON 覆盖一个键,并在 flush() 返回后确认内存;这不是数据库事务,也不保证突然断电的硬件级耐久性。业务规模扩大或需要跨进程并发时,应考虑 RDB 事务和正式的服务端冲突控制。

批次模型只包含稳定 ID 和目标数量,不带“第几行”这样的视图索引。排序或筛选后,输入框的位置可能变化,但 id 不会被重新解释。数量必须是非负安全整数;重复的 id、不存在的物料和不合法数值都应让整个批次在写盘之前失败。这里允许目标数量为 0,因为盘点确实可能确认物品已用尽;它与上一节出入库动作要求 quantity > 0 并不矛盾。

// entry/src/main/ets/model/CountDraft.ets
export interface CountDraft {
  itemId: string;
  counted: number;
}

export interface CountReceipt {
  beforeEventNumber: number;
  afterEventNumber: number;
  before: CountDraft[];
  after: CountDraft[];
}

盘点草稿与正式库存的隔离还牵涉一个容易被忽视的交互细节:用户正在编辑“打印纸 22 包”,此时又点击分类筛选。组件如果使用数组下标保存草稿,原位置可能变成“中性笔”,最终提交就会把 22 写到错误物料。实现时应将草稿存放在以物料 ID 为键的映射中,切换筛选只影响哪些行可见,不影响已录入的目标数量。即使把筛选清空后再返回,输入仍属于原来的物料。提交按钮要依据整张草稿映射组装数据,而不是只收集当时屏幕上可见的几行。否则“只改了两条,却覆盖了隐藏的第三条”将成为另一种数据丢失。

对仓储人员来说,批次还需要呈现可复核的差异摘要。这里不引入货币估值,只在确认页明确显示“原结存 → 实点结存 → 差异”,并把负差异与正差异用两种颜色区分。颜色不能成为唯一识别方式,文字里仍显示“-2 包”“+2 个”。点击“确认盘点”前弹出的二次确认可以列出两条非零差异,未变化的中性笔作为已核对项目保留在列表中。操作人员核对的是一组业务事实,而不只是一个泛泛的“确定吗”。

二、先用纯函数计算整个差异,再生成一份候选快照

校验必须先于写入。下面的 reduceCountBatch 不触碰 Preferences,也不修改旧 rows 和旧 movements。对每一项输入,它根据当前快照计算 delta;只有非零差异会写一条 StockMovement。流水仍沿用上一节的 IN、OUT、stockAfter、createdAt,因此查账视图无需理解一种凭空出现的新流水协议。nextEventNumber 只在新增流水时递增,旧事件 ID 不复用。

// entry/src/main/ets/store/CountReducer.ets
import { CountDraft, CountReceipt } from '../model/CountDraft';
import { SupplyRow } from '../model/SupplyItem';
import { StockMovement, SupplyLedgerV2 } from '../model/StockMovement';

export interface BatchResult {
  snapshot: SupplyLedgerV2;
  receipt: CountReceipt;
  changed: number;
}

export function reduceCountBatch(source: SupplyLedgerV2,
  drafts: CountDraft[], nowMs: number, note: string): BatchResult {
  if (drafts.length === 0) throw new Error('盘点项不能为空');
  const seen: Set<string> = new Set<string>();
  const nextRows: SupplyRow[] = source.rows.map((r: SupplyRow) => ({ ...r }));
  const before: CountDraft[] = [];
  const after: CountDraft[] = [];
  const newEvents: StockMovement[] = [];
  let seq: number = source.nextEventNumber;
  for (const draft of drafts) {
    if (seen.has(draft.itemId)) throw new Error('盘点项重复');
    seen.add(draft.itemId);
    if (!Number.isSafeInteger(draft.counted) || draft.counted < 0) {
      throw new Error('盘点数必须是非负安全整数');
    }
    const row: SupplyRow | undefined = nextRows.find(
      (r: SupplyRow) => r.id === draft.itemId);
    if (!row) throw new Error('盘点物料不存在');
    const oldStock: number = row.stock;
    const delta: number = draft.counted - oldStock;
    before.push({ itemId: row.id, counted: oldStock });
    after.push({ itemId: row.id, counted: draft.counted });
    if (delta === 0) continue;
    newEvents.push({
      id: `move-${seq++}`, itemId: row.id,
      kind: delta > 0 ? 'IN' : 'OUT', quantity: Math.abs(delta),
      stockAfter: draft.counted, note: note, createdAt: nowMs
    });
    row.stock = draft.counted;
  }
  const snapshot: SupplyLedgerV2 = {
    version: 2, rows: nextRows,
    movements: source.movements.concat(newEvents).slice(-100),
    nextEventNumber: seq
  };
  return { snapshot: snapshot, changed: newEvents.length,
    receipt: { beforeEventNumber: source.nextEventNumber,
      afterEventNumber: seq, before: before, after: after } };
}

这段实现刻意不根据 drafts 顺序去覆盖原列表的位置,而是使用 ID 找物料。若一个项目无差异,row.stock 不变;若整个批次无差异,可在提交层直接提示“无需保存”,不必制造一次磁盘写入。实际应用还应设置合理的单批条目上限并检查事件序号是否接近安全整数边界;示例以办公室的小规模库存为范围。

整批校验、候选快照与一次落盘的流程

三、串行入口必须决定撤销是否仍然有效

第四篇的 StockCommitQueue 用 last 串接每次 Promise,并且只在 writeText() 完成后更新 current。这里延续同一个队列,不另外开辟一个“盘点保存快捷通道”。最小增量是在类内部增加盘点提交和撤销,下面代码省略前篇已有的 submit(change)、构造函数和成员声明,只展示新增方法与新增私有成员。

// StockCommitQueue.ets 类内部,沿用既有 last、current、store
private lastReceipt?: CountReceipt;

submitCounts(drafts: CountDraft[]): Promise<BatchResult> {
  const work = async (): Promise<BatchResult> => {
    const result: BatchResult = reduceCountBatch(
      this.current, drafts, Date.now(), '盘点差异');
    if (result.changed === 0) return result;
    await this.store.writeText(JSON.stringify(result.snapshot));
    this.current = result.snapshot;
    this.lastReceipt = result.receipt;
    return result;
  };
  const job: Promise<BatchResult> = this.last.then(work);
  this.last = job.then(() => {}, () => {});
  return job;
}

undoLastCount(): Promise<BatchResult> {
  const work = async (): Promise<BatchResult> => {
    const receipt: CountReceipt | undefined = this.lastReceipt;
    if (!receipt || this.current.nextEventNumber !== receipt.afterEventNumber) {
      throw new Error('盘点之后已有变更,不能直接撤销');
    }
    const result: BatchResult = reduceCountBatch(
      this.current, receipt.before, Date.now(), '盘点撤销');
    await this.store.writeText(JSON.stringify(result.snapshot));
    this.current = result.snapshot;
    this.lastReceipt = undefined;
    return result;
  };
  const job: Promise<BatchResult> = this.last.then(work);
  this.last = job.then(() => {}, () => {});
  return job;
}

这里有一个容易忽略的细节:只检查 afterEventNumber 仍是简化的本地互斥方案。如果后续允许没有流水的库存元数据编辑、并行多实例写盘或跨设备同步,仅靠流水序号不足以做全局并发控制,应该引入每次成功写入都递增的 revision,提交时进行版本匹配。当前项目的所有库存变动都从同一队列经过,并且盘点后的出入库会增加流水号,所以可以用这条约束解释“本次示例的撤销”边界。

另一个边界是内存中 lastReceipt 不跨冷启动保存:应用重启后不提供一键撤销。需要正式的跨会话撤销时,必须把批次 ID、前后值、作者和状态作为独立审核记录持久保存,防止用户把历史盘点误当成可随意覆盖的临时草稿。对于已发生新变更的物料,更稳妥的是发起新的差异调整单,而不是修改旧流水或删除历史事件。

对于撤销,还要解释两个经常被混淆的概念。UI 上的“取消编辑”只是丢掉尚未提交的草稿,不产生任何库存流水;“撤销已提交盘点”却是一次真正的新操作,需要重新经过校验、写入与确认。撤销后的流水会显示“盘点撤销”,与原先的“盘点差异”同时存在。管理员按时间回看,可以知道真实发生过两次操作,而不只是看到一个最终数量。若采用直接删除旧流水的做法,后续根本无法判断那次盘点曾经出错。

此外,批次提交期间应锁定盘点页面的再次确认入口,但不必假设锁住按钮就消除了所有并发。页面可能被路由切走,应用也可能重新创建;因此互斥最终必须由串行提交器承担。countSaving 只是避免用户在同一页面连续点按造成重复意图,不能取代队列。若后台任务可能同时修改库存,还要在业务入口统一走同一个 StockCommitQueue 实例,不允许另一个模块直接写 supply_snapshot。

四、页面交互和错误场景应围绕“是否真正提交”设计

InventoryPage.ets 可以在“盘点模式”中显示三个输入行,列表继续按照业务 ID 生成 Key。点击确认时先把输入字符串转换为 CountDraft[],再调用 queue.submitCounts()。不要在输入框的 onChange 中提交,也不要在遍历过程中逐条 await queue.submit()。盘点模式的“差异条目数”由当前快照与输入草稿比较得到,是派生值,不应作为另一个持久化字段。

// InventoryPage.ets:盘点确认核心逻辑
@State private countMessage: string = '';
@State private countSaving: boolean = false;

private async confirmCounts(drafts: CountDraft[]): Promise<void> {
  if (!this.queue || this.countSaving) return;
  this.countSaving = true;
  try {
    const result: BatchResult = await this.queue.submitCounts(drafts);
    this.items = rehydrate({ version: 1, rows: result.snapshot.rows });
    this.refreshVisible();
    this.countMessage = result.changed === 0 ?
      '盘点一致,无需写入' : `盘点已提交:${result.changed} 项差异`;
  } catch (_e) {
    this.countMessage = '盘点未确认,请核对数据后重试';
  } finally {
    this.countSaving = false;
  }
}

失败场景需要分清阶段。如果发现重复 ID、负数或超出安全整数范围,尚未调用写盘,页面可以直接标出不合法输入;如果 put 或 flush 抛出异常,既不能把候选快照展示成“已提交”,也不能盲目再点十次造成未知结果。可以先读取当前持久化快照,确认真实状态,再决定是重试还是人工核对。由于底层文件系统失败可能发生在不同阶段,“Promise 拒绝”等价于“绝对没有任何字节写入”这种结论不能从 API 直接推出。

建议手工验证四组动作。其一,按 22、8、5 提交,检查差异数为 2,流水连续,冷启动仍保留新库存。其二,批次中插入重复物料 ID,检查所有物料数量保持原值。其三,成功盘点后不做其他操作,立即撤销,检查库存回到 24、8、3,并保留补偿流水而不是删去原流水。其四,先盘点再领用一件转接头,检查撤销入口拒绝覆盖后来提交的变更。

撤销边界:合法补偿与后续变更冲突

这篇的真正结论不是“批量保存更快”,而是把一次业务意图固定成一个不可拆分的候选快照,并给撤销限定了可解释的条件。这样的实现适合单实例、少量物料的本地工具;它不替代库存审批、权限审计和跨设备冲突检测。下一节将以现有 SupplyLedgerV2 为基础做异常数据修复与发布前工程收尾,保留每一步业务操作的来源。

参考华为开发者文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-observed-and-objectlink/@Observed 与 @ObjectLink、ForEach 渲染控制、状态管理 V1 概览。

Logo

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

更多推荐