在这里插入图片描述

实例:库存进销存(Inventory)|技术:商品表 + 出入库流水表、事务设计

一、业务需求分析:进销存的数据形态

进销存(进货-销售-库存)是零售行业最经典的信息系统场景。它的数据形态与前面五个实例有本质不同:数据之间存在强一致性约束——每笔出入库都必须同时更新「流水记录」和「商品库存」,两个动作要么都成功、要么都失败,绝不能出现「流水记了但库存没变」的中间状态。

核心业务需求拆解:

  1. 商品管理:商品种类、单价、单位、当前库存、预警线(库存低于预警线提示补货);
  2. 入库操作:进货时增加库存,记录入库流水(数量、单价、总额、时间);
  3. 出库操作:销售/领用时减少库存,记录出库流水——必须先校验库存充足,不足则拒绝
  4. 库存预警库存 <= 预警线 的商品列表,醒目标红;
  5. 流水追溯:最近出入库记录,带商品名、方向、数量、金额。

这五个需求的核心矛盾是一致性:出入库必须原子化。这决定了本实例必须引入 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) 查询是合理权衡。

十、设计决策复盘

回顾本实例的表设计,有几个可以沉淀的决策原则:

  1. 冗余字段的取舍:stock(当前库存)和 total_price(流水总额)都是冗余,但都是「高频读取 / 计算成本高」的组合——冗余换来读性能,写入一致性靠单一入口保证;
  2. 快照 vs 引用:流水保存交易时的单价(快照),商品名通过 JOIN 实时取(引用)——价格要快照(会变),名称可引用(不关键);
  3. 逻辑外键:不启用物理外键,级联逻辑在 DAO 层控制——RDB 对物理外键支持有限,代码级联更可控。

这些原则在后面的订单实例(9)、CRM 实例(12)会反复出现,先在这里建立认知。

十一、字段命名规范与单位约定

本实例的字段命名遵循全项目的统一规范,值得再强调一次,因为进销存字段多、容易乱:

规范 示例 说明
数据库蛇形 warn_linetotal_priceupdated_time SQL 风格下划线
代码驼峰 warnLinetotalPriceupdatedTime ArkTS 风格
数量统一整数 quantitystock 件数语义
金额统一 REAL pricetotal_price 元单位
时间统一毫秒 flow_timeupdated_time INTEGER 毫秒时间戳
布尔/枚举用数字 type 0/1 可参与 CASE WHEN

单位约定的意义:如果团队一半人用「元」一半人用「分」,报表会差 100 倍;如果时间有的用秒有的用毫秒,区间查询直接错乱。在 DAO 层统一单位并在注释里写明,是进销存这类「数字密集」项目的第一纪律。

十二、扩展阅读:批次与移动平均

真实的进销存系统比本实例复杂得多,一个重要的演进方向是批次管理:同一种商品不同批次进价不同(如大米 6 月进价 89、7 月进价 92),出库时按「先进先出(FIFO)」还是「移动平均」计价?

  • FIFO:需要批次表(batch_id、进价、剩余量),出库按批次顺序扣减——精确但复杂;
  • 移动平均平均价 = (原库存×原均价 + 本次进价×本次数量) / 总库存,出库按均价计价——简单但粗糙。

本实例用「进价」单一字段简化了计价(出库单价由操作者手输),够 Demo 用。读者理解这个演进方向即可——数据模型的复杂度由业务精度需求决定,先简单后演进

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐