药箱管理:库存事务与临期筛选的鸿蒙数据层设计


实例:药箱管理(Medicine Cabinet)|技术:数量增减事务、有效期字段、临期筛选(日期比较)、数量日志
一、业务需求分析
1.1 家庭药箱的数字化痛点
每个家庭都有一个药箱,装着感冒药、创可贴、肠胃药、保健品……但很少有人知道:
- 药箱里到底有什么、各有多少?——常常"以为还有,打开没有";
- 哪些药快过期了?——临期药没人提醒,过期药还在吃;
- 每次拿药用了多少?——数量变动的记录是追溯的凭据。
药箱管理应用的核心是库存思维:药品是"有保质期的库存品",数量会增减(购入 +1、服用 -1、过期处理归零),有效期是"库存的保质期截止日"。这与 08 商品分页、06 库存进销存有亲缘关系,但引入了一个新维度——有效期。
1.2 功能清单
| 编号 | 功能 | 技术要点 |
|---|---|---|
| 1 | 新增药品(名称/规格/分类/数量/有效期) | INSERT,初始数量记日志 |
| 2 | 数量变更(入库/取出) | 事务:更新数量 + 写日志,负库存校验 |
| 3 | 临期筛选(未来 N 天内过期) | 日期范围比较 expire_date BETWEEN |
| 4 | 已过期筛选 | expire_date < now |
| 5 | 数量变动日志(追溯每次增减) | 从表 + 时间倒序 |
| 6 | 分类统计 | GROUP BY |
| 7 | 删除药品 | 先删日志再删药品 |
1.3 与已有实例的关系
| 实例 | 相似点 | 本实例新增 |
|---|---|---|
| 06 库存进销存 | 数量增减、出入库 | 有效期维度 |
| 08 商品分页 | 商品列表 | 无分页(药品种类少) |
| 27 图书借阅 | 事务双写、日志 | 有效期 + 临期筛选 |
药箱管理的独特价值:"数量 + 有效期"双维度状态。数量是"有多少",有效期是"到何时"。临期筛选把"时间"变成查询条件——expire_date <= now + 90天——这是日期函数在业务查询中的典型应用。
二、字段设计表
2.1 主表 medicine(药品)
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| name | TEXT | NOT NULL | 药品名称 |
| spec | TEXT | DEFAULT ‘’ | 规格(0.5g*24粒/盒) |
| category | TEXT | DEFAULT ‘’ | 分类(感冒/肠胃/外伤/慢性病…) |
| batch | TEXT | DEFAULT ‘’ | 批次号(备用) |
| count | INTEGER | NOT NULL DEFAULT 0 | 当前数量(盒/瓶/袋) |
| unit | TEXT | DEFAULT ‘盒’ | 单位 |
| expire_date | INTEGER | NOT NULL DEFAULT 0 | 有效期至(毫秒时间戳,0=不限) |
| storage | TEXT | DEFAULT ‘’ | 存放位置 |
| note | TEXT | DEFAULT ‘’ | 备注 |
| created_at | INTEGER | NOT NULL | 录入时间戳 |
2.2 从表 dose_log(数量变动日志)
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| medicine_id | INTEGER | NOT NULL | 逻辑外键 → medicine.id |
| change | INTEGER | NOT NULL | 数量变化(正=入库,负=取出) |
| reason | TEXT | DEFAULT ‘’ | 原因(新购入/服用/过期处理…) |
| created_at | INTEGER | NOT NULL | 发生时间戳 |
2.3 设计要点详解
要点一:expire_date 用 0 表示"不限有效期"。
不是所有药都有有效期(温度计、纱布卷),所以 expire_date = 0 表示"不参与有效期检查"。这样临期查询只需 expire_date > 0 AND expire_date BETWEEN now AND now+90d,过滤掉无有效期项。0 值语义与 27/28 的约定完全一致,三个实例共用同一套"0 = 无"的编码规范。
要点二:数量为什么是 INTEGER 而非 REAL?
药品按"盒/瓶/袋"计件,是离散量,用 INTEGER 并配合单位字段(unit)。这与 28 的里程(连续量 REAL)形成对照——量纲决定类型:计件用整数,计量用浮点。
要点三:change 正负编码。
dose_log.change 用正负号表达方向:+1 入库、-1 取出。好处:
- 求和即净变化:
SUM(change)= 该药累计净变动; - 展示直观:
change > 0 ? '+' : '-'; - 不需要额外 type 字段(入/出),一个数字表达两个维度。
这是有符号变动量的简洁设计,比"type + amount"两个字段更紧凑。代价是语义靠约定(注释说明),页面统一处理。
要点四:为什么每次数量变更都写日志?
库存系统的黄金法则是可追溯:每次增减都记录"何时、变了多少、为什么"。这是审计需求——哪天吃过几粒、何时买的都查得到。日志表只 INSERT(追加),不修改不删除(历史不可变,同 26 事件表约定)。
三、建表 SQL
CREATE TABLE IF NOT EXISTS medicine (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
spec TEXT DEFAULT '',
category TEXT DEFAULT '',
batch TEXT DEFAULT '',
count INTEGER NOT NULL DEFAULT 0,
unit TEXT DEFAULT '盒',
expire_date INTEGER NOT NULL DEFAULT 0,
storage TEXT DEFAULT '',
note TEXT DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dose_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medicine_id INTEGER NOT NULL,
change INTEGER NOT NULL,
reason TEXT DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_medicine_name ON medicine (name);
CREATE INDEX IF NOT EXISTS idx_medicine_expire ON medicine (expire_date);
CREATE INDEX IF NOT EXISTS idx_log_medicine ON dose_log (medicine_id);
索引分析:
idx_medicine_expire:临期/过期筛选的高频查询入口(WHERE expire_date BETWEEN/< now),是本实例最有价值的索引;idx_medicine_name:按名搜索;idx_log_medicine:按药查日志的关联入口。
四、MedicineDao 数据访问层
4.1 数量变更:事务 + 库存校验 ★核心方法
static async changeCount(context: common.Context, medicineId: number, delta: number, reason: string): Promise<boolean> {
const store = await MedicineDao.getStore(context);
// 1. 读当前数量(事务外,只读判断)
const predicates = new relationalStore.RdbPredicates(MedicineDao.TABLE);
predicates.equalTo('id', medicineId);
const result = await store.query(predicates);
let current = 0;
if (result.goToNextRow()) {
current = MedicineDao.rowToMedicine(result).count;
}
result.close();
if (current + delta < 0) {
return false; // 库存不足,拒绝操作
}
// 2. 事务:更新数量 + 写日志
await store.beginTransaction();
try {
const values: relationalStore.ValuesBucket = { count: current + delta };
const upPredicates = new relationalStore.RdbPredicates(MedicineDao.TABLE);
upPredicates.equalTo('id', medicineId);
await store.update(values, upPredicates);
await store.insert(MedicineDao.LOG_TABLE, {
medicine_id: medicineId, change: delta, reason: reason, created_at: Date.now(),
});
await store.commit();
return true;
} catch (e) {
await store.rollBack();
throw new Error(`数量变更失败: ${JSON.stringify(e)}`);
}
}
拆解三个关键决策:
① 库存不足校验在事务外。先查当前数量,current + delta < 0 则直接 return false,不进事务。校验放事务外的好处:事务只做"确定会成功"的写操作,逻辑更清晰;事务内再校验会复杂化回滚路径。单机单用户下"读后写"无并发问题;多端并发场景应把校验放事务内(乐观锁/条件更新)。
② 返回 boolean 而非 throw。库存不足是业务预期结果(用户点了 -1 但库存 0),不是系统异常。用返回值表达业务结果,页面 if (!ok) toast('库存不足')——与 27 deleteBook 返回 false 的约定一致。
③ 事务保证"数量与日志同生共死"。若只更新数量、日志插入失败,会丢失"这次变动"的记录,追溯断裂。事务是库存系统一致性的底线。
4.2 临期筛选:日期范围比较 ★核心查询
static async queryExpiring(context: common.Context, days: number): Promise<Medicine[]> {
const store = await MedicineDao.getStore(context);
const now = Date.now();
const end = now + days * 86400000;
const result = await store.querySql(
`SELECT * FROM ${MedicineDao.TABLE}
WHERE expire_date > 0 AND expire_date >= ${now} AND expire_date <= ${end}
ORDER BY expire_date ASC`
);
return MedicineDao.collect(result);
}
static async queryExpired(context: common.Context): Promise<Medicine[]> {
const store = await MedicineDao.getStore(context);
const now = Date.now();
const result = await store.querySql(
`SELECT * FROM ${MedicineDao.TABLE}
WHERE expire_date > 0 AND expire_date < ${now}
ORDER BY expire_date ASC`
);
return MedicineDao.collect(result);
}
临期 vs 过期的边界:
| 筛选 | 条件 | 语义 |
|---|---|---|
| 临期 | expire_date BETWEEN now AND now+90d |
未来 90 天内到期,还能用但要注意 |
| 过期 | expire_date < now |
已到期,不能使用 |
边界不重叠:临期包含 >= now(今天到期的算临期),过期是 < now(昨天及以前到期的算过期)。expire_date > 0 排除"不限有效期"项。三个条件组合严谨,无遗漏无重叠——查询边界的严谨性是数据查询的基本功。
为什么用 querySql 而非 predicates:BETWEEN 用 predicates 也有 between('expire_date', now, end) API,但本实例的 > 0 过滤需要多条件组合,且日期范围用原生 SQL 更直观。两种写法等价,选 SQL 是为了教学展示原生日期比较。
4.3 新增药品:初始数量记日志
static async insertMedicine(context: common.Context, m: Medicine): Promise<number> {
const store = await MedicineDao.getStore(context);
const values: relationalStore.ValuesBucket = {
name: m.name, spec: m.spec, category: m.category,
batch: m.batch, count: m.count, unit: m.unit,
expire_date: m.expireDate, storage: m.storage,
note: m.note, created_at: m.createdAt,
};
const id = await store.insert(MedicineDao.TABLE, values);
if (m.count > 0) {
await store.insert(MedicineDao.LOG_TABLE, {
medicine_id: id, change: m.count, reason: '初始入库', created_at: m.createdAt,
});
}
return id;
}
初始数量也记日志:新药入库 2 盒,日志记"初始入库 +2",让"总量从哪来"可追溯。if (m.count > 0) 处理 0 数量入库(先建档后补货)。
4.4 统计:临期/过期动态计算
static async summary(context: common.Context): Promise<{ kinds: number; total: number; expiring: number; expired: number }> {
// 种类 + 总数:COUNT + COALESCE(SUM)
const result = await store.querySql(
`SELECT COUNT(*) AS kinds, COALESCE(SUM(count), 0) AS total
FROM ${MedicineDao.TABLE}`
);
// 临期/过期:复用筛选方法(动态时间)
const expiring = (await MedicineDao.queryExpiring(context, EXPIRE_WARN_DAYS)).length;
const expired = (await MedicineDao.queryExpired(context)).length;
return { kinds, total, expiring, expired };
}
临期/过期是"即时快照":随当前时间变化,每次 summary 重新计算。expired 的判定依赖 Date.now() 的当前值,无法存表,只能查询时算——与 28 的逾期判定同理。
4.5 分类统计
static async categoryStats(context: common.Context): Promise<Array<{ category: string; count: number }>> {
const result = await store.querySql(
`SELECT category, COALESCE(SUM(count), 0) AS cnt
FROM ${MedicineDao.TABLE}
GROUP BY category
ORDER BY cnt DESC`
);
// category || '未分类' 兜底空分类
}
GROUP BY category 按分类聚合数量,ORDER BY cnt DESC 数量多的在前。|| '未分类' 处理空分类(新增时没填分类)。页面顶部一行显示"感冒 9 · 肠胃 7 · 外伤 7 …"。
4.6 种子数据
static async initSeedData(context: common.Context): Promise<void> {
// 表非空则跳过
const seeds = [
{ name: '布洛芬缓释胶囊', category: '止痛退烧', count: 2, expireInDays: 400, ... },
{ name: '感冒灵颗粒', category: '感冒', count: 6, expireInDays: 60, note: '临期,优先使用' },
{ name: '健胃消食片', category: '肠胃', count: 1, expireInDays: -20, note: '已过期,待处理' },
{ name: '温度计', category: '器材', count: 1, expireInDays: 0, note: '不限有效期' },
// ...共 10 条
];
}
种子数据刻意覆盖四种有效期形态:正常效期(400 天)、临期(60 天)、已过期(-20 天)、不限(0)——临期/过期筛选、徽标四态都有演示数据。
五、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 库存事务 | 更新数量 + 写日志原子 | 数量与日志强一致 |
| 负库存校验 | 事务外读 + 布尔返回 | 业务结果驱动 UI |
| 有效期编码 | 0 = 不限,毫秒时间戳 | 统一"无"语义 |
| 临期筛选 | BETWEEN now~now+90d | 日期范围查询 |
| 过期筛选 | < now | 时间边界严谨 |
| 有符号变动 | change 正负编码 | 一字段双维度 |
| 数量日志 | 只追加不修改 | 审计追溯 |
| 分类聚合 | GROUP BY + COALESCE | 一屏分布 |
六、文章小结
本篇完成了药箱管理的数据层:medicine 存药品档案(含数量与有效期),dose_log 存数量变动日志,数量变更用事务保证"数量 + 日志"原子且校验负库存,临期/过期用日期范围比较动态筛选,有符号 change 一字段表达增减方向。这套模型的精髓是:库存品的两个生命维度——“还有多少”(数量)与"到何时"(有效期)——用事务管数量、用日期函数管有效期。
下一篇《页面 UI 与操作实现》将搭建:临期/过期筛选 Tab、药品卡片(效期四态徽标)、数量加减按钮、数量日志抽屉、新增药品表单。
七、数据层深度扩展
1. 有效期查询的时区与边界
expire_date 存毫秒时间戳,比较时用 Date.now()(本地时区)。若用户跨时区(旅行),时间戳是绝对时刻,边界判定依然正确。毫秒时间戳无时区歧义——这是全系列坚持时间戳存储的原因。
若按"自然日"判定(如"2026-01-01 到期"应理解为 1 月 1 日整天),需要把日期对齐到当日零点:
const expireDay = new Date(expireDate);
expireDay.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const remainDays = Math.round((expireDay.getTime() - today.getTime()) / 86400000);
页面 statusInfo 的 Math.ceil((expireDate - now) / 86400000) 是粗略口径,生产可按自然日对齐。口径在需求阶段定死。
2. 批次管理(batch 字段的用途)
本实例 batch 字段留空备用。真实药房按批次管理:同一种药不同批次有效期不同。完整建模应为"药品主表 + 批次子表(每批数量/效期)“,本实例简化为"单批一条记录”(同药多批 = 多行)。何时拆表:当"同一药品不同批次需要独立库存"时。家庭药箱场景单批足够。
3. 数量变更的并发安全
单机单用户无并发问题。多端同步场景,changeCount 的"读-判-写"三步可能产生竞态(两设备同时取出最后 1 盒)。解决方案:
- 条件更新:
UPDATE medicine SET count = count - 1 WHERE id = ? AND count >= 1,让数据库判断,受影响行数 0 即失败; - 乐观锁:加 version 字段,CAS 更新。
// 条件更新示例
const predicates = new relationalStore.RdbPredicates(MedicineDao.TABLE);
predicates.equalTo('id', medicineId).greaterThanOrEqualTo('count', Math.abs(delta));
const rows = await store.update({ count: current + delta }, predicates);
if (rows === 0) return false;
教学实例用"读-判-写"保持可读性,注释说明了并发演进路径。
4. 过期药品的处理流程
真实场景:过期药 → 标记"待处理" → 定期清理 → 记录处理(change = -count, reason = ‘过期处理’)。本实例种子含"已过期,待处理"的健胃消食片,演示徽标红色;清理操作就是 changeCount(id, -count, '过期处理'),数量归零。业务闭环已具备,页面可自行加"一键处理过期"按钮。
5. FAQ
Q1:为什么把"临期阈值 90 天"放 DAO 常量而非写死?
A:EXPIRE_WARN_DAYS 导出给页面复用(筛选 Tab 与徽标口径一致)。90 天是家庭用药的合理阈值(药品效期短),真实药房可能 30 天。阈值参数化 + 集中定义,一处修改全局生效。
Q2:日志表会无限增长吗?
A:家庭药箱每次增减一条,一年几百条,微不足道。若长期累积,可按月归档或定期清理旧日志(保留最近 N 条)。先接受增长,再按需清理。
Q3:为什么 change 不用两列(in/out)?
A:一列有符号数 SUM(change) 直接得净变动;两列需要 SUM(in) - SUM(out)。且展示/统计都更简洁。代价是"方向"是隐式的——用注释固化约定。
Q4:同一种药两盒不同效期怎么办?
A:本模型下存两行(不同 expire_date)。列表按效期升序,临期那行排前、徽标橙色,先消耗临期批次——多行模型天然支持"先进先出"的视觉排序。
Q5:药品能编辑吗?
A:本实例只增删改数量。编辑档案(改名/改规格)可加 updateMedicine,与 27 的 updateBook 同款,trivial 扩展。
八、下篇预告
下一篇《页面 UI 与操作实现》将完成:临期/过期筛选 Tab、药品卡片(效期四态徽标:绿/橙/红/灰)、数量加减按钮(含库存不足提示)、数量日志抽屉(+-时间线)、新增药品表单(含日期输入)。敬请期待。
更多推荐




所有评论(0)