仓库的数据库心脏:ArkTS 为鸿蒙进销存设计商品表与流水表

实例:库存进销存(Inventory)|技术:商品表 + 出入库流水表、事务设计
一、业务需求分析:进销存的数据形态
进销存(进货-销售-库存)是零售行业最经典的信息系统场景。它的数据形态与前面五个实例有本质不同:数据之间存在强一致性约束——每笔出入库都必须同时更新「流水记录」和「商品库存」,两个动作要么都成功、要么都失败,绝不能出现「流水记了但库存没变」的中间状态。
核心业务需求拆解:
- 商品管理:商品种类、单价、单位、当前库存、预警线(库存低于预警线提示补货);
- 入库操作:进货时增加库存,记录入库流水(数量、单价、总额、时间);
- 出库操作:销售/领用时减少库存,记录出库流水——必须先校验库存充足,不足则拒绝;
- 库存预警:
库存 <= 预警线的商品列表,醒目标红; - 流水追溯:最近出入库记录,带商品名、方向、数量、金额。
这五个需求的核心矛盾是一致性:出入库必须原子化。这决定了本实例必须引入 TRANSACTION 事务——这是本书 20 个实例中第一次正式使用事务,也是本实例的技术核心。
二、双表字段设计:商品表与流水表
商品表 product
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| name | TEXT | NOT NULL | 商品名称 |
| category | TEXT | NOT NULL | 分类(饮品/食品/日用…) |
| stock | INTEGER | NOT NULL DEFAULT 0 | 当前库存件数 |
| unit | TEXT | DEFAULT ‘件’ | 单位(箱/瓶/袋…) |
| price | REAL | NOT NULL DEFAULT 0 | 进货单价(元) |
| warn_line | INTEGER | NOT NULL DEFAULT 5 | 预警线 |
| updated_time | INTEGER | NOT NULL | 最近变动时间 |
出入库流水表 stock_flow
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| product_id | INTEGER | NOT NULL | 关联商品(逻辑外键) |
| type | INTEGER | NOT NULL | 0 入库 / 1 出库 |
| quantity | INTEGER | NOT NULL | 数量 |
| unit_price | REAL | NOT NULL DEFAULT 0 | 单价 |
| total_price | REAL | NOT NULL DEFAULT 0 | 总额(数量 × 单价) |
| flow_time | INTEGER | NOT NULL | 流水时间戳 |
| remark | TEXT | DEFAULT ‘’ | 备注(门店配送/促销出库…) |
设计要点拆解:
1. stock 冗余在商品表里。这是进销存系统的核心设计决策:库存是「当前状态」,流水是「历史记录」。库存不通过 SUM(流水) 实时计算,而是在商品表里冗余一个字段,每笔流水即时增减。为什么?因为实时 SUM 在数据量大时性能差,而且「当前库存」是高频读取(列表、预警、详情都要用),冗余字段让读取 O(1)。代价是写入时必须保证「流水 + 库存」原子更新——这正是事务存在的意义。
2. type 用 0/1 数字。入库/出库是枚举,数字编码可参与 CASE WHEN 统计,与记账本 type 同理。
3. total_price 冗余存储。数量 × 单价 本可实时计算,但存下来有两个好处:单价可能随批次变化(历史流水保留当时的单价),报表查询直接 SUM(total_price) 无需乘法。
4. warn_line 预警线。每个商品独立的预警阈值,支持「可口可乐 10 箱预警、大米 10 袋预警」这种差异化配置。
三、建表 SQL:双表 + 双索引
CREATE TABLE IF NOT EXISTS product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
unit TEXT DEFAULT '件',
price REAL NOT NULL DEFAULT 0,
warn_line INTEGER NOT NULL DEFAULT 5,
updated_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS stock_flow (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
type INTEGER NOT NULL,
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL DEFAULT 0,
total_price REAL NOT NULL DEFAULT 0,
flow_time INTEGER NOT NULL,
remark TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_flow_product ON stock_flow (product_id);
CREATE INDEX IF NOT EXISTS idx_flow_time ON stock_flow (flow_time);
两个索引的用途:idx_flow_product 服务「某商品的流水追溯」,idx_flow_time 服务「最近流水列表」的倒序查询。流水表是只追加(append-only)的,索引不会因更新而失效。
四、InventoryDao 封装:ResultSet 双实体映射
数据层核心 InventoryDao,双表对应两个实体接口和两个行映射方法:
export interface Product {
id: number;
name: string;
category: string;
stock: number;
unit: string;
price: number;
warnLine: number;
updatedTime: number;
}
export interface StockFlow {
id: number;
productId: number;
productName: string; // 联表查询时填充
type: number;
quantity: number;
unitPrice: number;
totalPrice: number;
flowTime: number;
remark: string;
}
StockFlow 里的 productName 字段值得注意——它不属于 stock_flow 表,而是联表查询(JOIN product)时填充的商品名。实体接口可以包含「表外字段」,只要行映射时正确填充即可。这体现了 DAO 层「以查询结果为导向设计实体」的思路。
getStore 单例 + 双表初始化与前面实例同构,不再赘述。ArkTS 静态引用必须用 InventoryDao.store(不能 this.store),这是全书统一的红线。
五、商品查询:列表、预警、统计
全部商品(库存升序,预警的排前面):
static async queryProducts(context: common.Context): Promise<Product[]> {
const store = await InventoryDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(InventoryDao.TABLE);
predicates.orderByAsc('stock');
const result = await store.query(predicates);
return InventoryDao.collectProducts(result);
}
库存升序让库存最少的商品排在最前——预警商品(库存接近 0)自然浮到列表顶部,与「预警优先」的运营诉求一致。
预警商品查询(SQL 条件:stock <= warn_line):
static async queryWarn(context: common.Context): Promise<Product[]> {
const store = await InventoryDao.getStore(context);
const result = await store.querySql(
`SELECT * FROM ${InventoryDao.TABLE} WHERE stock <= warn_line ORDER BY stock ASC`
);
return InventoryDao.collectProducts(result);
}
库存统计(一条 SQL 出三个数):
static async statistics(context: common.Context): Promise<InventoryStats> {
const store = await InventoryDao.getStore(context);
const result = await store.querySql(
`SELECT COUNT(*) AS total,
SUM(stock) AS stock_sum,
SUM(CASE WHEN stock <= warn_line THEN 1 ELSE 0 END) AS warn_count
FROM ${InventoryDao.TABLE}`
);
// ...读取 total / stock_sum / warn_count
}
SUM(CASE WHEN stock <= warn_line THEN 1 ELSE 0 END) 统计预警商品数——这是条件聚合的又一次应用(记账本实例用过)。InventoryStats 是显式 interface(禁止内联对象类型)。
六、出入库流水查询:LEFT JOIN 联表带商品名
流水列表要显示商品名,需要 JOIN product 表:
static async queryFlows(context: common.Context, limit: number): Promise<StockFlow[]> {
const store = await InventoryDao.getStore(context);
const result = await store.querySql(
`SELECT f.*, p.name AS product_name
FROM ${InventoryDao.FLOW_TABLE} f
LEFT JOIN ${InventoryDao.TABLE} p ON p.id = f.product_id
ORDER BY f.flow_time DESC
LIMIT ${limit}`
);
// ...逐行读取,product_name 填充 StockFlow.productName
}
LEFT JOIN 与 INNER JOIN 的区别:LEFT JOIN 即使商品被删除(p 无匹配行)也保留流水行(product_name 为 NULL)。进销存场景流水的完整性优先于商品的存在性——历史流水不该因为商品下架而消失,所以用 LEFT。这是本实例第一次正式使用 JOIN(实例 9 订单会深入讲解 JOIN 家族)。
七、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 库存冗余 | stock 字段即时增减 | 读取 O(1),报表不实时算 |
| 流水追加 | stock_flow 只增不改 | 历史可追溯 |
| 逻辑外键 | product_id + 代码级联 | 无物理外键开销 |
| 预警条件 | stock <= warn_line |
缺货自动标红 |
| 联表带名 | LEFT JOIN product | 流水可读性 |
| 统计 | SUM(CASE WHEN…) | 预警数一条 SQL |
八、文章小结
进销存数据层的核心是**「状态 + 流水」双表架构**:product 表冗余当前库存(高频读),stock_flow 表记录历史流水(可追溯),两者靠 product_id 关联。这种架构的代价是写入必须原子——「流水 + 库存」要么同时成功要么同时回滚,这正是下一篇文章(6-3)要讲的 TRANSACTION 事务。6-2 先看 UI:深色数字面板如何把库存、预警、流水呈现得像飞机驾驶舱。
动手练习:思考一下,如果不冗余 stock 字段,改为 SELECT SUM(CASE WHEN type=0 THEN quantity ELSE -quantity END) FROM stock_flow WHERE product_id=? 实时计算库存,有什么优缺点?提示:性能、历史流水被删、批次单价差异。
九、常见问题 FAQ
Q1:为什么商品表不建 category 索引?
A:分类筛选(按分类查商品)在本实例页面不是高频操作,且分类基数小(6 类),SQLite 大概率走全表扫描。如果后续要做分类汇总报表,可以补建 idx_product_category。索引服务于真实查询,不为「可能有用」建。
Q2:stock 字段用 INTEGER,支持小数库存吗?
A:不支持。本实例商品以「件/箱/袋」为单位,库存是整数。若管理按重量/体积计量的商品(如散装米、油),stock 应改为 REAL。字段类型必须匹配业务粒度,整数库存配整数单位,小数库存配小数单位。
Q3:为什么流水表的 remark 不是必填?
A:备注是运营信息(门店配送/促销出库),不是核心数据。默认空串,页面可展示也可忽略。强制必填会阻碍快速操作(仓管员补货时不想敲备注)。
Q4:total_price 冗余会不会导致数据不一致?
A:只要写入时统一 total_price = quantity * unit_price 计算(DAO 内集中处理),就不会不一致。冗余字段的「写一致性」靠「单一写入入口」保证——所有出入库都走 stockInOut 一个方法,杜绝散落的 SQL 绕过计算逻辑。
Q5:商品改名或改分类会影响流水吗?
A:不会。流水只存 product_id 和当时的价格/数量,不存商品名(商品名是联表查询时 JOIN 出来的)。改名只影响 product 表,流水历史保持原样——这正是「快照式流水」的设计:流水记录的是交易发生时的状态,不随主数据变化。
Q6:数据库文件名叫什么?
A:inventory.db(getStore 的 config.name)。每个实例独立一个数据库文件,互不干扰——这是本书 20 实例的组织方式,便于隔离与排查。
Q7:什么时候应该把流水表和商品表合并成一张表?
A:几乎不。状态(库存)与历史(流水)是两种不同生命周期的数据:状态会被覆盖更新,历史只增不改。合并会导致「改库存要改流水」的逻辑纠缠。除非场景极其简单(如一次性事务记录),否则一律双表。
Q8:updated_time 在商品表里的作用?
A:记录商品最近一次库存变动时间,用于「最近变动排序」「最后入库时间展示」。它是冗余的(可从最新流水推导),但高频读取场景冗余换取 O(1) 查询是合理权衡。
十、设计决策复盘
回顾本实例的表设计,有几个可以沉淀的决策原则:
- 冗余字段的取舍:stock(当前库存)和 total_price(流水总额)都是冗余,但都是「高频读取 / 计算成本高」的组合——冗余换来读性能,写入一致性靠单一入口保证;
- 快照 vs 引用:流水保存交易时的单价(快照),商品名通过 JOIN 实时取(引用)——价格要快照(会变),名称可引用(不关键);
- 逻辑外键:不启用物理外键,级联逻辑在 DAO 层控制——RDB 对物理外键支持有限,代码级联更可控。
这些原则在后面的订单实例(9)、CRM 实例(12)会反复出现,先在这里建立认知。
十一、字段命名规范与单位约定
本实例的字段命名遵循全项目的统一规范,值得再强调一次,因为进销存字段多、容易乱:
| 规范 | 示例 | 说明 |
|---|---|---|
| 数据库蛇形 | warn_line、total_price、updated_time |
SQL 风格下划线 |
| 代码驼峰 | warnLine、totalPrice、updatedTime |
ArkTS 风格 |
| 数量统一整数 | quantity、stock |
件数语义 |
| 金额统一 REAL | price、total_price |
元单位 |
| 时间统一毫秒 | flow_time、updated_time |
INTEGER 毫秒时间戳 |
| 布尔/枚举用数字 | type 0/1 |
可参与 CASE WHEN |
单位约定的意义:如果团队一半人用「元」一半人用「分」,报表会差 100 倍;如果时间有的用秒有的用毫秒,区间查询直接错乱。在 DAO 层统一单位并在注释里写明,是进销存这类「数字密集」项目的第一纪律。
十二、扩展阅读:批次与移动平均
真实的进销存系统比本实例复杂得多,一个重要的演进方向是批次管理:同一种商品不同批次进价不同(如大米 6 月进价 89、7 月进价 92),出库时按「先进先出(FIFO)」还是「移动平均」计价?
- FIFO:需要批次表(batch_id、进价、剩余量),出库按批次顺序扣减——精确但复杂;
- 移动平均:
平均价 = (原库存×原均价 + 本次进价×本次数量) / 总库存,出库按均价计价——简单但粗糙。
本实例用「进价」单一字段简化了计价(出库单价由操作者手输),够 Demo 用。读者理解这个演进方向即可——数据模型的复杂度由业务精度需求决定,先简单后演进。
更多推荐




所有评论(0)