ArkTS + HarmonyOS 库存台账:领用入库的串行提交与可追溯记录 · 04【共创稿事节】
库存台账最容易被忽略的故障,不是页面少显示了一行,而是两次操作都提示成功,最终只剩下一次的结果。假设中性笔原有 8 盒,仓库管理员领走 3 盒,同时另一位操作人员在同一页面快速登记入库 2 盒。若两个异步处理函数都基于最初的 8 盒构造快照,落盘顺序又不受控制,最后可能出现 10 盒或 5 盒,而正确结果应该是 7 盒。这个错误既可能发生在领用与入库之间,也可能发生在用户重复点按保存时。
前面三节的 SupplyLedger 已经建立 SupplyItem、StockCard、InventoryPage 的状态观察体系,筛选视图只引用主数据;随后又用 ArkData Preferences 保存 supply_snapshot,坚持 flush() 成功后才把候选库存交给 UI。本节不换项目,也不改动 A4 打印纸 24 包、中性笔 8 盒、USB-C 转接头 3 个这组基础样本,而是让“入库、领用、数量校验、写入顺序、变更记录”成为同一条可推理的提交链。

一、先让每次变动都有明确的业务含义
上一节的编辑保存直接覆盖 stock 字段,对于修正录入错误很方便,但对日常仓储业务还不够。一个数量从 8 变成 5,可能是正常领用、盘点损耗,也可能是工作人员把 8 错改成 5。只存最终值,后来无法区分原因。因此这里将库存动作限定为 IN 和 OUT,每次必须携带物品 id、正整数数量和备注。数量变更通过计算得到,不允许调用者直接传入所谓“新的库存值”。
数据协议从 version: 1 升级为 version: 2。原有 rows 六个业务字段完全保留,新增 movements 保存少量近期流水,以及 nextEventNumber 作为快照内递增编号。事件包含 itemId、方向、数量、变动后的结存、备注、发生时间。这里的时间只用于展示先后和审计辅助,并不承担唯一性;即使设备时钟回拨,也不应该因为相同毫秒而生成重复事件 id。稳定 id 改由持久化的递增序号决定。
// entry/src/main/ets/model/StockMovement.ets
import { SupplyRow } from './SupplyItem';
export type MovementKind = 'IN' | 'OUT';
export interface StockMovement {
id: string;
itemId: string;
kind: MovementKind;
quantity: number;
stockAfter: number;
note: string;
createdAt: number;
}
export interface SupplyLedgerV2 {
version: number;
rows: SupplyRow[];
movements: StockMovement[];
nextEventNumber: number;
}
export interface StockChange {
itemId: string;
kind: MovementKind;
quantity: number;
note: string;
}
这里把版本值声明为 number,不是为了接受任意版本,而是让解析器可以显式拒绝未知结构。示例的流水只保留最近 100 条,这属于教学项目的产品容量决定,不是 Preferences 官方保证的最大条数。若后续要求查找全年领用记录、按用户审计或支持并发远端提交,应该迁移到 RDB 或服务器事务,而不是把同一个 JSON 字符串无限扩张。
从第一页到现在的示例数据还有一个不变规则:库存与阈值都必须是非负安全整数,业务 id 不能重复。库存领用时必须另外验证 quantity <= stock,否则只做 Math.max(0, stock - quantity) 会把“领用超量”的错误悄悄抹掉。例如转接头只有 3 个,领用 5 个应提示失败,而不是生成一个“领用 5、结存 0”的自相矛盾记录。
二、纯函数先算候选值,不直接改响应式对象
把核心计算与界面分开有两个好处。第一,错误数据可以在触发 Preferences 之前被拒绝;第二,所有点击进入队列后,都从上一次已成功提交的快照构建下一次候选,而不是从事件发生时捕获的旧 @State 数组读取。下面是本节最重要的状态转换函数,除了生成新数据,不接触页面、不执行 IO。
// entry/src/main/ets/store/StockReducer.ets
import { StockChange, StockMovement, SupplyLedgerV2 } from '../model/StockMovement';
import { SupplyRow } from '../model/SupplyItem';
export function applyMovement(current: SupplyLedgerV2,
change: StockChange, nowMs: number): SupplyLedgerV2 {
const quantity: number = change.quantity;
if (!Number.isSafeInteger(quantity) || quantity <= 0) {
throw new Error('数量必须是正整数');
}
if (change.kind !== 'IN' && change.kind !== 'OUT') {
throw new Error('不支持的库存动作');
}
const index: number = current.rows.findIndex((r: SupplyRow) => r.id === change.itemId);
if (index < 0) throw new Error('物品不存在或已删除');
const row: SupplyRow = current.rows[index];
const delta: number = change.kind === 'IN' ? quantity : -quantity;
const nextStock: number = row.stock + delta;
if (!Number.isSafeInteger(nextStock) || nextStock < 0) {
throw new Error('领用超出库存或数值溢出');
}
const rows: SupplyRow[] = current.rows.map((r: SupplyRow) => ({
id: r.id, name: r.name, unit: r.unit, category: r.category,
minimum: r.minimum,
stock: r.id === row.id ? nextStock : r.stock
}));
const event: StockMovement = {
id: `move-${current.nextEventNumber}`, itemId: row.id,
kind: change.kind, quantity: quantity, stockAfter: nextStock,
note: change.note.trim(), createdAt: nowMs
};
return { version: 2, rows: rows,
movements: current.movements.concat([event]).slice(-100),
nextEventNumber: current.nextEventNumber + 1 };
}
applyMovement 没有修改 current.rows[index].stock,而是返回新数组;这样页面可以选择在持久化确认后一次性替换对象集合,避免出现数量已经变更、写盘却失败的短暂假成功。它也避免了筛选视图的陷阱:用户当前只看“文具”,代码仍通过 itemId 在主快照里找到中性笔,而不是依据筛选后列表的下标定位。排序、分类和关键词都不能影响实际修改目标。
为了防止异常输入产生意外流水,真正的读取入口还需要校验 nextEventNumber 为正安全整数、事件 id 不重复、rows 中物品 id 唯一,以及 movements 中的数字与方向格式。这里仅展示写入侧转换;验证器延续上一节的 isValidSnapshot,在版本 2 分支增加流水字段检查,不能把一段 TypeScript 类型断言当作实际校验。

三、串行队列只允许成功的候选成为下一次基线
A-03 已有 InventoryStore.open()、readText()、write(snapshot)。本节只需要在同一仓储对象里补一个 writeText(raw: string),实现仍是 db.put('supply_snapshot', raw) 然后 db.flush();推荐在存储层内部复用已有键名,不能新建一个 supply_snapshot_v2 却忘记迁移旧数据。因为 Preferences 是带缓存的键值存储,put 完成并非磁盘确认,必须等 flush() Promise 正常返回。官方也明确指出 Preferences 不适合多进程共享写入,本节队列只覆盖单应用进程内的操作顺序。
// InventoryStore.ets:接在 A-03 的 InventoryStore 类内部
async writeText(raw: string): Promise<void> {
if (!this.db) throw new Error('存储未初始化');
await this.db.put('supply_snapshot', raw);
await this.db.flush();
}
// entry/src/main/ets/store/StockCommitQueue.ets
import { InventoryStore } from './InventoryStore';
import { StockChange, SupplyLedgerV2 } from '../model/StockMovement';
import { applyMovement } from './StockReducer';
export class StockCommitQueue {
private last: Promise<void> = Promise.resolve();
private current: SupplyLedgerV2;
constructor(store: InventoryStore, snapshot: SupplyLedgerV2) {
this.store = store; this.current = snapshot;
}
private store: InventoryStore;
submit(change: StockChange): Promise<SupplyLedgerV2> {
const run = async (): Promise<SupplyLedgerV2> => {
const candidate: SupplyLedgerV2 = applyMovement(this.current, change, Date.now());
await this.store.writeText(JSON.stringify(candidate));
this.current = candidate;
return candidate;
};
const job: Promise<SupplyLedgerV2> = this.last.then(run);
this.last = job.then(() => {}, () => {});
return job;
}
}
代码的关键不是 Promise 写法,而是 run() **真正执行时才读取 this.current**。先点击领用 3 盒,再点击入库 2 盒,第二个任务必须等待第一个成功后才使用结存 5 盒进行计算,因此得到 7 盒。若第一笔写盘失败,this.current 不会前进;第二笔进入队列后会根据 8 盒进行计算。两笔操作的错误与成功回执需要分别显示,绝不能把“最后一次成功”混成“全部成功”。
但这里仍有一条必须说清的技术边界:当 put() 已经改了缓存而 flush() 抛错时,仓储中的 current 保持旧值,Preferences 缓存却可能持有未落盘的新字符串。因此上述队列严格保证的是“UI 认可的提交顺序”,不等于数据库的原子提交。生产设计应在失败时尝试恢复旧缓存、标记存储不确定状态,必要时冻结写入并要求重新装载;如果连回退也失败,继续写队列并声称数据绝对一致并不可靠。这个例子不覆盖跨进程竞争、多终端合并或断电时的绝对持久性保证。

四、把业务回执放到 UI 提交边界后
页面继续维持 @State items: SupplyItem[],每次队列返回成功快照,重新构建 @Observed SupplyItem 实例并刷新 visibleItems。因为上节已证明直接替换原始业务对象比任意修改筛选结果可靠,这里只增加两个输入:操作数量和业务备注。默认选中“领用”,也允许切换“入库”;领用数量不允许是 0、负数或小数。
// InventoryPage.ets 中的操作入口,其他查询逻辑沿用 A-02
@State private quantityDraft: string = '3';
// saving、notice 沿用 A-03 的现有字段,不再重复声明
private queue?: StockCommitQueue;
private async commitStock(itemId: string, kind: MovementKind): Promise<void> {
if (this.saving || !this.queue) return;
if (!/^[1-9]\d*$/.test(this.quantityDraft)) {
this.notice = '请输入正整数数量'; return;
}
const quantity: number = Number(this.quantityDraft);
if (!Number.isSafeInteger(quantity)) {
this.notice = '数量超出安全整数范围'; return;
}
this.saving = true;
try {
const next: SupplyLedgerV2 = await this.queue.submit({
itemId: itemId, kind: kind, quantity: quantity, note: '日常领用'
});
this.items = rehydrate({ version: 1, rows: next.rows });
this.lowStockCount = this.items.filter((i: SupplyItem) => i.stock < i.minimum).length;
this.refreshVisible();
this.notice = kind === 'OUT' ? '领用已入账' : '入库已入账';
} catch (e) {
this.notice = '本次操作未确认,请检查存储状态后重试';
} finally { this.saving = false; }
}
这段是页面原组件中的增量方法,MovementKind、StockCommitQueue 等按文件路径导入;rehydrate() 沿用 A-03 函数。按钮通过 .enabled(!this.saving) 阻止同一页面的快速重复触发,而队列仍负责多个操作入口共享仓储时的顺序。这里的按钮禁止点击是交互保护,不能代替队列,因为其他页面、异步回调或者未来批量操作都可能绕开按钮状态。
我们用一组固定示例检查页面:中性笔 8 盒,领用 3 盒成功后变 5 盒,流水产生 move-1、方向 OUT、数量 3、结存 5;预警阈值设为 6 时,该项进入低库存提醒。A4 打印纸仍是 24 包,USB-C 转接头仍是 3 个。屏幕底部显示“领用已入账”,不是在 put 返回时就提前展示“成功”。

五、回归测试不仅看数量,还要看流水与恢复
第一组测试检查先后顺序:连续发出“领用 3、入库 2”,预期同一物品由 8 变 7,流水 id 按递增顺序记录两条;把测试交换为“入库 2、领用 3”,最终数量也应是 7,但流水次序不同。第二组测试检查拦截:试图从 3 个转接头领用 5 个,状态应保持 3,不能追加“失败的领用”事件,更不能出现负库存。第三组验证业务 id:先筛选“文具”、再清空关键词,操作中性笔的结果应始终指向相同 id。
第四组故意让写入方法抛出异常:页面应保留旧库存和明确错误提示;如果 put 已发生而 flush 失败,还要通过重新初始化或故障注入观察缓存与磁盘可能不一致的路径,不能拿单一的 catch 当作保证。第五组关闭应用后重启,读版本 2 的快照验证 5 盒和 move-1 可恢复。对老的 version: 1 数据执行单向迁移时,仅填空流水和 nextEventNumber=1,保持原有 rows 值,首次读取绝不自动覆盖用户的旧存储。
此外,若多处需要复用库存队列,必须避免各自建立一个 StockCommitQueue 实例。两个页面用不同队列对象同时写同一个 Preferences 键,就又回到“最后写入者覆盖前者”的问题。队列实例应在 UIAbility 或共享仓储层统一持有;退出该进程后,再次启动从已校验快照恢复,不能依赖还活着的 Promise 保证提交。对于已经送达服务器的业务,重复请求防护还应在服务端实现,单机事件编号不承担远端全局去重职责。
至此库存台账从“编辑某条记录”升级成了“解释一笔库存为什么改变”:稳定业务 id 定位对象,纯函数先计算变更,写入队列保证串行,磁盘确认后才提交页面,流水保留最近的操作轨迹。它适合小规模、单进程离线台账;更复杂的权限审计、撤销凭证和多端并发,应引入关系型事务或具备冲突解决机制的后端。下一节将在这套提交链上讨论批量盘点和撤销的边界,而不是重复实现一个普通的加减按钮。
参考:华为开发者 · Preferences API、ArkUI @Observed 与 @ObjectLink、Stage UIAbility 生命周期。
更多推荐



所有评论(0)