拆解芋道 ERP(二):主数据层——被六重校验保护的基础表

本章拆产品、分类、单位、供应商、客户五张主数据表。它们看起来是最没技术含量的 CRUD,但"删除产品"这一个操作背后藏着六重校验——主数据层的核心难点从来不是增删改查,而是怎么防止被引用的数据悬空。
五张表,三个层次
主数据层的表结构都很短。产品 erp_product 13 个字段,分类 erp_product_category 6 个,单位 erp_product_unit 只有 3 个(主键、名字、状态)。供应商 erp_supplier 和客户 erp_customer 各 15 个字段,字段高度相似——都是联系人、电话、税率(taxPercent)、纳税人识别号(taxNo)、开户行三件套。
我把这五张表按职责分成三层:
| 层次 | 表 | 说明 |
|---|---|---|
| 商品定义 | erp_product / category / unit | 描述"货",产品通过 categoryId、unitId 挂到分类和单位 |
| 交易对象 | erp_supplier / erp_customer | 描述"人",供应商带税率信息供采购单计算税额 |
| 资金入口 | erp_account(财务组,但本质是主数据) | 结算账户,付款收款都挂它 |
产品表值得多说两句,它有三个价格字段:
- purchasePrice:采购价,录采购单时的默认单价;
- salePrice:销售价,录销售单时的默认单价;
- minPrice:最低价,通常用于业务员报价的下限控制。
注意这三个价格只是默认值,真正的单价在每张单据的 items 子表里逐行填写。这个"主数据存参考价、单据存成交价"的分离是 ERP 的通用做法——商品调价不应该影响已经生成的历史单据。
还有一个容易忽略的细节:erp_product 有 expiryDay(保质期天数)字段,但库存相关的表里没有任何批次/效期字段。也就是说系统记住了"这个商品保质期 30 天",却不会跟踪"这一批货什么时候过期"。这是它离商用级的差距之一,我在第 10 章会展开。
删除产品的六重校验
主数据层最有讲解价值的代码,是 ErpProductServiceImpl 的 deleteProduct 方法:
@Override
public void deleteProduct(Long id) {
// 校验存在
validateProductExists(id);
// 校验未被任何业务单据明细引用,避免删除后产生悬空引用
if (purchaseOrderService.getPurchaseOrderItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_PURCHASE_ORDER_EXISTS);
}
if (purchaseInService.getPurchaseInItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_PURCHASE_IN_EXISTS);
}
if (purchaseReturnService.getPurchaseReturnItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_PURCHASE_RETURN_EXISTS);
}
if (saleOrderService.getSaleOrderItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_SALE_ORDER_EXISTS);
}
if (saleOutService.getSaleOutItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_SALE_OUT_EXISTS);
}
if (saleReturnService.getSaleReturnItemCountByProductId(id) > 0) {
throw exception(PRODUCT_DELETE_FAIL_SALE_RETURN_EXISTS);
}
// 删除
productMapper.deleteById(id);
}
六张单据明细表逐一 count:采购订单项、采购入库项、采购退货项、销售订单项、销售出库项、销售退货项——只要任何一张表的任何一个明细行引用过这个产品,删除就被拒绝,而且错误码精确到"被哪类单据引用"。
你可能会问:为什么不用数据库外键?加了 FOREIGN KEY 约束,删产品直接报错,一行代码都不用写。
我自己的理解是三点:
- 外键报错的信息不可控。数据库只告诉你"违反外键约束",用户看到的可能是裸 500。应用层校验能给出"该产品已被采购单引用,无法删除"这种业务化提示。
- 性能考量。高并发写入单据明细时,每行 insert 都要检查外键,写放大会更明显;互联网风格的实现倾向于把约束搬到应用层。
- 这是整个芋道体系的统一风格——所有模块都不建外键,bigint 字段只是逻辑引用。这一点在第 10 章讨论商用短板时我会再提:逻辑引用换性能,代价是数据一致性完全靠代码纪律。
顺带一提,validProductList 方法是产品校验的"正向版本"——单据保存时,把 items 里的 productId 集合一次性查出来,校验每个产品都存在且处于启用状态(CommonStatusEnum.isDisable 判断)。一次性批量查、Map 匹配,避免在循环里逐个 selectById,这也是批量校验的标准写法。
分类树的"防呆"逻辑
分类表 erp_product_category 是典型的树形结构:parentId 指向父分类。它的删除校验在 ErpProductCategoryServiceImpl 里,逻辑是双重的:
- 有子分类,不能删(防止树断掉);
- 有产品挂在这个分类下(通过 getProductCountByCategoryId 查),不能删。
单位表 erp_product_unit 同理,删除前通过 getProductCountByUnitId 查产品引用。
你会发现主数据层的删除校验其实是一个通用模式:先查"我有没有孩子"(子节点),再查"谁在引用我"(业务单据),两关都过才放行。供应商和客户的删除校验也是同样的路数。
状态字段:一切校验的前提
五张主数据表都有 status 字段,走芋道通用的 CommonStatusEnum(0 开启 / 1 禁用)。这里有个值得注意的设计选择:主数据不做物理删除之外的第二态,被单据引用后删不掉,业务上只能"停用"。停用的产品在 validProductList 里会被拒绝出现在新单据上,但历史单据不受影响。
这就形成了主数据的生命周期闭环:新建 → 被引用 → 只能停用不能删 → 历史单据永远可追溯。财务和审计视角下这是刚需——一年前的采购单里那个产品,哪怕今天已经下架,单据打开还得能显示它的名字和价格。这也是为什么 ERP 领域几乎所有系统都选择"停用代替删除",而不是激进地做级联删除。
我从小系统里看到的大问题
主数据层是最容易被轻视的一层,因为它写起来确实简单。但我见过不少自研小系统在这一层欠债:产品删了,历史订单详情页打开白屏;供应商改名,去年的对账单跟着变;单位可以随意删除,库存报表突然出现 NULL。
芋道这层代码给出的答案是朴素但完整的:引用检查前置到删除动作,改名改价走新单据新价格,停用作为删除的替代出口。三条纪律守住了,主数据层就不会塌。
下一章我们把镜头拉远,看 33 张表共同的阅读规律——主子表怎么落库、单号怎么生成、金额怎么算才不丢分。掌握了这三个规律,后面拆任何一张单据表都会非常顺。
