领域模型:34 张表讲清仓储数据底座

读懂一个 WMS,从读懂它的表开始。这一章我把 GreaterWMS 的 34 个数据模型按业务域拆开讲:公共字段约定、多租户隔离键、单据状态机、15 维库存账户、库位三件套、货品主数据的冗余设计。这些表结构是后面所有章节的地基——入库、出库、并发治理、报表,全都是在这些表上做动作。
一、总览:34 张表的分组地图
34 个模型如果平铺着读会晕,按业务域分组后脉络非常清楚:
| 业务域 | 模型 | 数量 |
|---|---|---|
| 入库(ASN) | asnlist、asndetail | 2 |
| 出库(DN) | dnlist、dndetail、pickinglist | 3 |
| 库存 | stocklist、stockbin | 2 |
| 货品主数据 | goods + goodsclass/goodsunit/goodscolor/goodsbrand/goodsshape/goodsspecs/goodsorigin | 8 |
| 仓库与库位 | warehouse、binset、binsize、binproperty | 4 |
| 组织主档 | company、supplier、customer、staff、stafftype | 5 |
| 盘点与流水 | qtyrecorder、cyclecountday、manualcyclecount | 3 |
| 增值业务 | capital、driver、dispatchlist、transportationfee、scanner | 5 |
| 平台 | user_profile、throttle | 2 |
命名约定高度统一:xxxlist 是主单表,xxxdetail 是明细表。比如 asnlist(收货单主表)配 asndetail(收货单明细),dnlist(发货单主表)配 dndetail(发货单明细)。这是单据类业务最经典的主子表结构。
二、公共字段:每张业务表都有身份证
所有业务表都带一组公共审计字段,这是理解全库的前提:
| 字段 | 类型 | 说明 |
|---|---|---|
| `openid` | CharField(255) | **多租户隔离键**(货主/仓库维度) |
| `creater` | CharField(255) | 创建人标识 |
| `is_delete` | BooleanField | 软删除标记 |
| `create_time` | DateTimeField(auto_now_add) | 创建时间 |
| `update_time` | DateTimeField(auto_now) | 更新时间 |
| `bar_code` | CharField(255) | 条码(货品/库位/单据通用) |
| `t_code` | CharField(255) | 事务码(业务流转记录) |
有两个字段值得单独说。
openid 是整个系统的第一关键字。 它不是 OpenID Connect 的那个 openid,而是 GreaterWMS 自定义的租户/货主隔离键。所有业务查询都要求携带 openid 过滤,用户登录后其 openid 存在 user_profile 表里,认证中间件从 JWT 解出用户再取 openid 注入请求上下文。多货主仓库场景下,A 货主永远看不到 B 货主的库存和单据——隔离不靠应用层自觉,而靠每条查询强制过滤。后面第八章做外部集成接口时,request.auth.openid 也是我拿到租户身份的唯一入口。
t_code 是事务码。 库存转移类操作(上架、拣货)会在 stockbin 等表写入 t_code,用来关联"这批库存变化是由哪个作业动作产生的"。它配合 qtyrecorder(数量流水表)构成简单的可追溯机制:每一笔库存数量变化都能回答"谁、何时、因为哪张单、动了多少"。
三、单据模型:主单 + 明细 + 状态机
3.1 入库:asnlist / asndetail
asnlist(ASN = Advanced Shipping Notice,到货通知单)核心字段:
| 字段 | 说明 |
|---|---|
| asn_code | 收货单号 |
| asn_status | 状态机(BigInteger) |
| supplier | 供应商 |
| total_weight / total_volume / total_cost | 总重量/体积/成本 |
| bar_code | 单据条码 |
| transportation_fee | JSONField,运费信息 |
asndetail 明细则落到货品粒度:goods_code / goods_desc、计划数量 goods_qty 与实收数量 goods_actual_qty、已分拣数量 sorted_qty,以及 goods_shortage_qty / goods_more_qty / goods_damage_qty 三个差异字段(短缺/多余/破损)。收货作业的本质就是"计划 vs 实收"的核对,这三个差异字段是财务对账和供应商考核的数据来源。
3.2 出库:dnlist / dndetail / pickinglist
dnlist(DN = Delivery Note,发货单)结构与 ASN 对称,多两个字段:customer(客户)和 back_order_label(缺货挂单标记)。dndetail 的数量字段比入库侧多一层:goods_qty(计划)→ pick_qty(待拣)→ picked_qty(已拣)→ intransit_qty(在途)→ delivery_actual_qty(实发),外加短缺/多余/破损三个差异字段。一条明细从下单到签收,数量在五个字段间流转,每一步都有据可查。
pickinglist(拣货单)是出库侧唯一的独立作业单:
| 字段 | 说明 |
|---|---|
| dn_code / bin_name / goods_code | 出库单 + 库位 + 货品 |
| picking_status | 拣货状态 |
| pick_qty / picked_qty | 待拣 / 已拣 |
| t_code | 事务码 |
注意它的粒度是 出库单 × 库位 × 货品——波次下发时系统按库位拆分拣货任务(一张 DN 的某个货品在两个库位有货,就生成两条拣货任务),PDA 扫码作业时按任务逐条执行。
3.3 状态机:数字编码的流转
ASN 与 DN 都用 asn_status / dn_status 数字字段驱动状态流转,状态变更不靠字段直改,而是通过独立的动作端点触发。以入库为例:
新建收货单(status 初值)
→ POST /asn/preload/<pk>/ 预装载:车到月台
→ POST /asn/presort/<pk>/ 预分拣:卸货暂存
→ PUT /asn/sorted/ 分拣:核对实收数量
→ PUT /asn/movetobin/ 上架:货入库位,库存落账
每个动作端点内部校验当前状态是否允许流转,不允许就抛业务异常。这种"状态机 + 动作端点"的设计比"一个 update 接口随便改状态"健壮得多——状态流转的合法路径被代码固定,前端永远无法把单据从"新建"直接跳到"已上架"。第三章我们会进入这些动作端点的内部看实现。
四、库存账户:15 个维度怎么读
stocklist(货品库存总表)是全系统最核心的表,粒度是 租户 × 货品。它不是"一个数量字段",而是 15 个并存的库存维度:
| 维度 | 字段 | 业务含义 |
|---|---|---|
| 总量 | goods_qty | 账面总数量 |
| 在手 | onhand_stock | 实际在库可用 |
| 可订购 | can_order_stock | 允许被销售下单的量 |
| 已订购 | ordered_stock | 已被订单占用 |
| 待检 | inspect_stock | 质检中 |
| 冻结 | hold_stock | 人工冻结 |
| 破损 | damage_stock | 破损待处理 |
| 在途收 | asn_stock | ASN 已建未到 |
| 在途发 | dn_stock | DN 已建未发 |
| 待装载 | pre_load_stock | 预装载完成 |
| 待分拣 | pre_sort_stock | 预分拣完成 |
| 已分拣 | sorted_stock | 分拣完成待上架 |
| 待拣 | pick_stock | 波次已分配 |
| 已拣 | picked_stock | 拣货完成 |
| 缺货 | back_order_stock | 缺货挂单量 |
读这张表的关键是理解:任何一次库存动作都是维度之间的转移,而不是简单的加减。举例,波次下发把 10 件货分配给拣货任务:onhand_stock -10,pick_stock +10——总账不变,货从"可卖"变成"待拣"。上架动作则是 sorted_stock -10, onhand_stock +10。这套设计的价值在于:任何时刻系统的库存状态都是可解释的,你知道每一件货正处在哪个作业环节。这也是后面第五章并发治理的复杂度来源——一次转移涉及同一个账户的多行更新,并发下必须整体成功或整体回滚。
stockbin(库位库存)则是 租户 × 库位 × 货品 粒度:bin_name / goods_code / goods_qty / pick_qty / picked_qty,冗余了 bin_size / bin_property 库位属性,带 t_code 事务码。stocklist 回答"这个货品总共有多少",stockbin 回答"这些货放在哪些库位、每个库位多少"。拣货路径规划、库位推荐都查它。
五、库位三件套:warehouse / binset / binsize / binproperty
库位体系由四张表撑起,我习惯叫"一仓 + 三件套":
| 模型 | 表名 | 关键字段 | 职责 |
|---|---|---|---|
| warehouse | warehouse | warehouse_name/city/address/contact/manager | 仓库主档 |
| binset | binset | bin_name/size/property/empty_label/bar_code | 库位定义 |
| binsize | binsize | bin_size + w/d/h 三维尺寸 | 库位尺寸规格 |
| binproperty | binproperty | bin_property 属性定义 | 库位用途属性 |
binset 通过 size 关联 binsize(决定这个库位能放多大体积的货),通过 property 关联 binproperty(决定库位用途:正常存储、暂存、破损区、待检区等)。empty_label 记录库位的空/占用状态,扫码枪扫 bar_code 就能定位库位(GET /binset/scannerbintag/<bar_code>/)。
这个设计的巧妙之处在于属性是数据不是代码:新加一种库位用途(比如"冷链专区")只需要插一条 binproperty 记录,不用改任何代码。上架时按货品属性匹配合适的 bin_property 库位,实现物理隔离——破损品进破损区、待检品进待检区,都是这套机制驱动的。
六、货品主数据:1 张主表 + 7 张维度表
goods 是货品主表,字段覆盖编码描述(goods_code / goods_desc)、物理属性(goods_weight / goods_w / goods_d / goods_h / unit_volume)、供应链属性(goods_supplier)、库存策略(safety_stock 安全库存)、财务属性(goods_cost / goods_price)、条码(bar_code)。
有意思的是它的维度处理方式:货品的分类、单位、品牌、颜色、形状、规格、产地,分别是 7 张独立主档表(goodsclass / goodsunit / goodscolor / goodsbrand / goodsshape / goodsspecs / goodsorigin),每张就一个名称字段 + 公共字段。但 goods 表上这些维度是冗余存储的字符串字段,不是外键关联。
| 设计选择 | 代价 | 收益 |
|---|---|---|
| 维度冗余到 goods 表 | 维度主档改名后需同步刷新 goods 冗余字段 | 查询零 JOIN,列表页/报表性能稳定 |
这是典型的读优化取舍。WMS 的查询热点全在 goods 表上(扫码、拣货、报表都要带出货品描述),维度拆表 + 冗余让所有查询都是单表。代价是维护维度主档后必须同步刷新货品冗余字段,二开新增维度同步逻辑时要记得这一点。
七、流水与盘点:qtyrecorder / cyclecountday / manualcyclecount
qtyrecorder:数量变更流水(mode_code / bin / goods / qty / store_code),所有库存数量动作落一条流水,配合t_code可回溯每一笔变化;cyclecountday:日盘,按库位周期盘点(cyclecount_status / bin / goods / qty / physical_inventory / difference);manualcyclecount:手动盘,结构同日盘,由人工触发。
physical_inventory(实盘数)与 difference(差异数)两个字段是盘点的核心:账面数与实盘数的差异记录在案,盈亏处理有据可依。API 层提供 GET /cyclecount/(日盘)、GET /cyclecount/qtyrecorviewset/(流水查询)、GET /cyclecount/manualcyclecount/(手动盘)等端点,全部支持 CSV 导出。
八、组织与增值:供应商、客户、司机、资金
company / supplier / customer / staff:公司、供应商、客户、员工四张主档,字段同构(name/city/address/contact/manager),supplier 和 customer 带level分级。stafftype定义员工类型,staff.is_lock支持锁定账号;capital:资金账户(capital_name/qty/cost);driver+dispatchlist:司机主档与派车单(driver_name + dn_code + contact),出库流程的"车辆调度(dispatch)"动作就是写 dispatchlist;transportationfee:运费价卡(send_city/receiver_city/weight_fee/volume_fee/min_payment),按起止城市 + 重量/体积计费,asnlist.transportation_feeJSONField 存每单的实际运费计算结果;scanner:PDA 扫码记录(mode/code/bar_code),GET /scanner/list/<bar_code>/是扫码枪的统一查询入口。
九、平台表:user_profile 与 throttle
user_profile:用户扩展信息(user_id/name/vip/openid/appid/developer/ip/avatar),注意openid也存在这里——它就是用户与租户的绑定关系,认证链路的钥匙;throttle:限流访问日志(openid/appid/ip/method/t_code),记录每次被限流计数的请求,是排查接口滥用问题的第一现场。
十、二开视角的五个数据层要点
通读 34 张表之后,我把二开必须遵守的数据层约定总结为五条:
- 多租户隔离:所有查询必须携带
openid过滤;新增模型必须保留openid字段并纳入统一过滤逻辑。漏一条就是跨租户数据泄露; - 软删除:统一排除
is_delete=True。所有查询都有这个隐含条件,新写代码最容易漏; - 库存一致性:库存增减是应用层多次 save,多步写入之间没有事务保护(这是后面三章要解决的头号问题);
- 维度冗余:货品维度是字符串冗余,维护维度主档后需同步刷新 goods 冗余字段;
- 金额精度:成本/价格用
FloatField,财务敏感场景必须迁移DecimalField(第六章专讲)。
表结构看懂了,下一章我们进入第一条业务主链路:一张 ASN 收货单,从车到月台到货上架,数据是怎么在这套表之间流动的。
