做进销存和电商后台这些年,我一直觉得“商品单位”是被低估最狠的一个基础功能。外面看就是一个下拉框,选“件”还是“箱”,但真正深入进去你会发现,它是整个库存、价格、采购、财务对账体系的地基。地基没打好,后面盖多少层楼都在摇。
这篇文章我想把“商品单位功能”一次讲透:它到底承担了什么职责、数据模型该怎么设计、精度和舍入规则怎么定、实操怎么落地、线上排查怎么搞。内容全部来自真实项目踩坑的总结,不是那种照抄文档的泛泛之谈。适合正在做或准备做电商ERP、进销存、零售POS、供应链系统的产品经理、后端开发和架构师参考。
1. 商品单位不是“多填一个字段”,而是业务系统的地基
1.1 一次真实事故:单位没设计好,库存和财务全乱
先讲一个我亲历的案例。有个做饮料批发的老客户,某天凌晨反馈说库存对不上:系统显示某款水还有50箱,但仓库实际只翻出来2箱。查了半天,原因是这样的——采购员录进货单的时候,按习惯选了“瓶”(50瓶一单),但系统的默认库存单位是“箱”,而且后端没有做单位方向校验,直接把50当作“箱”存了进去。50瓶实际只有2.08箱,账面却躺着50箱,差异就是这么来的。
这种事在业务系统里太典型了。它不一定是研发水平差,而是从一开始就没有把“单位”当成正经的领域模型来设计,只在商品表上挂了一个孤零零的 unit 字段。结果就是:采购、销售、库存、财务各看各的单位,数据一交叉就出问题。
1.2 单位功能要解决的三件核心事
把问题抽象一下,商品单位体系本质上要同时解决三件事:
- 多单位换算:同一件商品,进货按箱、零售按瓶、仓库盘点点的是托盘,三种单位之间必须有一组可靠、无歧义的换算关系。
- 业务场景的单位分离:采购单默认用采购单位,销售单默认用销售单位,库存流水全部落到最小管理单位,而不是一个单位走天下。
- 精度与舍入规则:重量类商品克和千克之间换算会出小数,数量类商品(瓶、件)则几乎必须是整数,系统必须知道每个单位允许几位小数、尾差怎么处理。
你去看那些成熟的ERP(比如SAP、用友),单位都是独立主数据,商品主数据里面再挂“采购单位、销售单位、库存单位”三个维度的映射,就是这个原因。这个设计不是拍脑袋,而是业务真实逼出来的。
1.3 没有单位体系时,业务方会不断提需求
如果没有在架构上把单位抽出来,你会陆陆续续接到各种看起来“很小”的需求:要支持组合装(一提=6瓶)、要支持重量浮动(一条鱼1.2kg卖了要扣1.2kg库存)、要支持份量概念(一份牛肉200g,但价格是按kg维护的)、要支持不同渠道用不同单位(B2B按箱报价格,B2C按瓶报价格)。这些需求单拆出来都不难,但因为没有统一模型,每接一个就要在业务代码里打个补丁,最后变成一个巨大的补丁山。
所以在做商品中心设计的时候,我强烈建议把“商品单位”当成一个独立域来做,而不是商品表上的一个枚举字段。这一步想清楚,后面能省掉大量补丁工程。
2. 核心设计思路:三个必须遵守的铁律
2.1 铁律一:必须有一个“最小基准单位”
所有换算都必须挂靠到一个基准单位上,不允许在任意两个非基准单位之间直接定义换算率。
举个例子:某商品进货按箱,一箱24瓶;拆零按半打卖,半打6瓶。如果你直接定义“1箱=4个半打、1半打=6瓶”,系统也能算,但一旦算错,查都无从查起。正确做法是:选“瓶”为基准单位,然后只维护两组关系:1箱=24瓶、1半打=6瓶。所有跨单位换算都先折算到瓶,再到目标单位。
为什么这样设计?两个原因。第一,减少冗余关系。n个单位如果两两维护换算率,需要维护n*(n-1)/2条关系,还容易互相矛盾;挂在基准单位上只需要n-1条。第二,换算方向统一。所有计算共用同一个中间锚点,精度误差可控,排查问题时心智负担也小得多。
这里有一个很常见的坑:基准单位选错了。原则是选业务中“最小可交易粒度”,不是“最常用的单位”。比如饮料就选“瓶”,不要选“毫升”;瓷砖就选“片”,不要选“平方米”。选错的话,销售单位还好,一旦碰到损耗、报损、盘点差异,库存流水会被小数烦死。
2.2 铁律二:采购、销售、库存三个单位分开存
商品上不要只留一个 unit 字段,至少要分三张“脸”:
- 采购单位:供应商报价、采购单默认单位。
- 销售单位:渠道报价、前台商品详情展示单位。
- 库存单位(基准单位):所有库存流水、成本核算的落地单位。
这个设计是被“价格联动”的需求逼出来的。比如生鲜门店:供应商按“公斤”报价,门店按“份”售卖,一份200克。如果混用一个单位,要么销售价格没法在上下架时自动算出来,要么采购入库时库存数量跟货不对账。
分开存之后,再加上“换算率”,系统就可以在任何业务环节做自由转换:
- 采购入库时:采购数量 × 采购单位换算率 = 基准库存数量
- 销售出库时:销售数量 × 销售单位换算率 = 基准库存扣减数量
- 价格换算时:采购单价 × 采购换算率 → 基准单位成本 → 再加毛利系数 → 销售单价
这样看起来多费了点存储,但后端逻辑会非常干净。我见过很多系统为了省事只存一个单位,每次接新业务都要在 Service 层写一坨换算补丁,最后没人敢动那几段代码。
2.3 铁律三:换算率的“方向”必须全局统一
这项是团队协作最容易翻车的地方。同样是“1箱=24瓶”,A系统里存的是“24”,B系统里可能存的是“0.0416667”(即1瓶等于1/24箱)。如果前后端没有约定清楚,接口一对接,数据直接错乱。
我的做法是:所有单位换算率一律存“多少基准单位 = 1当前单位”,即换算率是“当前单位到基准单位的放大倍数”。1箱的换算率就是24,1瓶的换算率就是1,半打就是6。任何地方用到换算,都执行:
基准数量 = 当前单位数量 × 当前单位换算率不要在业务代码里临时定义“1箱=24瓶”这种相对比例。一旦有人改了基准单位,相对比例还指向旧基准,库存历史就毁了。换算率字段也建议在商品单位关系表里持久化,不要动态计算。比如组合装后来改了包装(一提从6瓶变8瓶),旧组合装的换算率要能追溯到历史快照,不能因为主数据变了就跟着变。
3. 数据模型与实现要点
3.1 单位表与商品单位关联表的建表建议
讲完原则,直接上可落地的表结构。以下是我在MySQL里常用的一套设计,字段名和注释都比较直白。
-- 单位字典表,全局维护 CREATE TABLE unit ( id BIGINT PRIMARY KEY AUTO_INCREMENT, unit_code VARCHAR(32) NOT NULL COMMENT '单位编码:KG, G, BOX, BOTTLE...', unit_name VARCHAR(64) NOT NULL COMMENT '单位名称:千克、克、箱、瓶...', unit_type TINYINT NOT NULL COMMENT '1=数量单位(整数),2=重量单位(小数),3=体积单位(小数)', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_code (unit_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单位字典表'; -- 商品单位关系表,挂在商品下 CREATE TABLE product_unit ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT '商品ID', unit_id BIGINT NOT NULL COMMENT '单位ID,关联unit表', unit_type TINYINT NOT NULL COMMENT '1=采购单位 2=销售单位 3=库存单位(基准单位) 4=损耗/报损单位', conversion_rate DECIMAL(18,6) NOT NULL COMMENT '换算率:1个当前单位 = ?个基准单位', is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否为该业务场景默认单位', precision_scale INT NOT NULL DEFAULT 0 COMMENT '该单位允许的小数位数,数量单位一般0', rounding_rule TINYINT NOT NULL DEFAULT 1 COMMENT '舍入规则:1=四舍五入 2=向上取整 3=向下取整', barcode VARCHAR(64) DEFAULT NULL COMMENT '该单位对应的条码(多单位多条码场景)', status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_product_unit (product_id, unit_id, unit_type), KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品单位关系表';这两个表就是整套单位体系的核心。商品主表里不需要再存 unit 字段,需要查某个场景的默认单位时,直接查 product_unit 表。
一个值得注意的细节:product_unit 的唯一键是 (product_id, unit_id, unit_type)。这意味着同一个商品可以同时挂多个销售单位,但每个业务类型下只能挂一条同单位记录。如果后续要支持“同一销售单位不同的价格档位”,建议另建价格表,不要把价格字段塞进 product_unit 里,不然价格版本回溯会很痛苦。
3.2 精度与舍入规则:从源头消灭“价格错位”和“库存尾差”
这是单位功能里最考验细节的地方。换算本身就容易丢精度,如果再叠加舍入规则不明确,财务对账就是一场灾难。
先说数据类型的铁律:所有数量、价格、金额的存储和计算,一律用 DECIMAL,禁止用 FLOAT 和 DOUBLE。理由很简单,浮点数在二进制里表示不精确,0.1 + 0.2 可能算出 0.30000000000000004。库存差极小,但一旦乘以大单价,金额差就会被放大。我见过某个系统就是因为价格字段用了 DOUBLE,月底对账差出几万块的案例。
精度怎么定?我的经验是:
- 数量类单位(瓶、件、箱)精度设为0,舍入规则默认向下取整(不超卖)。
- 重量类单位(kg、g)换算率本身最多6位小数,业务单据数量保留2~3位小数,舍入规则默认四舍五入。
- 按份销售时(一份=200g),份单位的精度为0,但最终扣减基准库存时要精确到克,中间换算按6位小数算,最后落库存再按克精度取整。
舍入规则要分场景配置,不要全局写死。举一个真实场景:生鲜订单客户要500g牛肉,但系统里“一份”是300g,如果全局用向下取整,这单永远发不出去;向上取整又会多发。正确做法是:销售端按份下单,按份的基准重量向上取整到可售卖规格;库存扣减端再回到克做精确扣减。两者规则不同,分开配置后各自都合理。
3.3 换算关系上线前必做的六组验证用例
这块是我强烈建议测试同学重点关注的。手工测试常见,但单位换算的用例设计非常容易漏。列一个我经常提到的测试清单:
| 用例编号 | 场景 | 输入 | 预期 |
|---|---|---|---|
| U-01 | 采购入库换算库存 | 采购2箱,1箱=24瓶 | 库存基准增加48瓶 |
| U-02 | 销售出库反向换算 | 卖出3打,1打=6瓶 | 库存基准扣减18瓶 |
| U-03 | 跨单位价格换算 | 采购单价10元/kg,sku销售单位为200g | 单份成本=2.00元 |
| U-04 | 尾差场景 | 库存5瓶,客户下单1箱(24瓶) | 提示库存不足,禁止超卖 |
| U-05 | 小数精度边界 | 库存1.235kg,出库1.234kg | 结余0.001kg,不允许负数 |
| U-06 | 历史快照 | 修改“1提=6瓶”为“1提=8瓶” | 历史订单仍按6瓶换算 |
这组用例看着简单,但每一条背后都是真实踩坑换来的。U-04这种问题,在单位体系没做好的系统里非常常见:系统有两个单位但换算关系没参与可用量校验,导致“纸面上有库存、实际发不出货”。
4. 实操过程:从0到1落地商品单位功能
4.1 基础数据准备:单位字典、商品注册和换算率录入
第一步永远是先梳理业务场景里的“单位全集”。别急着写代码,先把采购、仓库、销售、财务各拉一个代表,问清楚同一个商品在各个角色眼里的计量单位。这一步会暴露很多历史遗留的“口语化单位”,比如“一提”“一扎”“一手”,这些一定要先规范成单位字典里的正式条目。
拿到单位清单后,建立 unit 字典,然后逐一给商品注册 product_unit 记录。这里有个执行技巧:先只做基准单位和采购、销售默认单位的注册,其他非常用单位等业务明确提出再接。因为单位不是越多越好,每多一个单位,就要多维护一套换算和条码映射,数据维护成本是实打实的。
换算率的录入从哪来?第一来源是商品包装本身(箱规上写着一箱多少瓶),第二来源是供应商的报价单(既有千克单价又有箱单价时,可以反推换算率)。录入时千万要做二次校验,至少两个人在后台独立核对一遍物理换算关系,这种基础数据错了,后面再专业的系统也白搭。
4.2 核心接口与关键逻辑:把换算收敛到一个服务里
这部分是落地重点。我的原则是:所有涉及单位换算的逻辑,必须收敛在一个服务或一个工具类里,业务代码禁止在别处自己写乘法。否则后面前端、结算、报表各写一遍,分母方向稍不一致,线上就出乱子。
这里给一个 Java 风格的换算服务接口设计,核心逻辑很简单,但抽象位置非常关键:
/** * 商品单位换算服务 */ public interface UnitConversionService { /** * 把数量从 fromUnit 换算到 toUnit * * @param productId 商品ID * @param quantity 源数量 * @param fromUnit 源单位 * @param toUnit 目标单位 * @return BigDecimal 目标单位数量,按目标单位精度和舍入规则处理 */ BigDecimal convert(Long productId, BigDecimal quantity, Long fromUnit, Long toUnit); /** * 把金额按单位换算(单价是相对于某个单位的) * 例:10元/kg,换算为 200g/份 对应的价格 */ BigDecimal convertPrice(Long productId, BigDecimal unitPrice, Long fromUnit, Long toUnit); /** * 从业务单据的“单位+数量”直接扣减/增加基准库存 * 返回基准库存变动值 */ BigDecimal changeStock(Long productId, BigDecimal quantity, Long unit); }实现思路不复杂:先从 product_unit 表里查出 fromUnit 和 toUnit 相对基准单位的换算率,然后:
基准数量 = 源数量 × fromUnit.rate 目标数量 = 基准数量 ÷ toUnit.rate最后再按 toUnit 的 precision_scale 和 rounding_rule 处理精度。convertPrice 同理,源单价换到基准单价,再到目标单价,不直接做两单位之间的除法,防止两端精度不一致。
为什么强调收敛到一个服务?因为线上排查过一次,销售端和结算端各写了一段换算逻辑,一端用的是“目标=源×24”,另一端用的是“目标=源/(1/24)”,数学上等价,但浮点误差不同,最后对账差了0.01元。排查到崩溃。收敛之后所有换算都走同一套代码,这个问题从架构上就杜绝了。
4.3 旧数据迁移的三个注意点
如果是从老系统升级做单位功能,迁移是非做不可的一步。这块我的建议很直接:
第一,历史订单不能拿当前换算率去重算。老订单里的“箱”是什么时间段的有效箱规,必须从当时的商品快照拿,否则历史毛利报表全变。做法是在迁移前先给商品快照表和订单明细表做“单位+换算率”归档字段,一单一条存下来。
第二,不要试图把历史库存直接除换算率来折算。如果你的老系统里库存已经是“多单位混着存”(一部分数量是瓶、一部分数量是箱),除以换算率只会得到混乱数。正规做法是让仓库实物盘点一次,把实物数量作为基准库存重盘初始化,再开始走新逻辑。用算法猜出来的旧库存,永远不可能是对的。
第三,条码映射要单独处理。多单位多条码场景下,老系统的条码往往只对应默认单位。迁移后如果外部扫码支付或PDA扫描依赖条码反查商品,要在新关系表里把“条码-商品-单位”三元组重建一遍,并做一次线上扫码验证。这块漏了,仓库拣货环节当场报错。
5. 常见问题与排查实录
5.1 高频问题的速查表
下面这张表是我在几个项目里沉淀出来的问题定位清单,遇到类似现象可以直接对着查。
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 采购单入库后库存数量与预期不符 | 采购单位换算率错误或单位方向反了 | 查 product_unit 中采购单位的 rate,用“入库数量×rate”手动算一遍 | 修正换算率,冲销错误库存重录 |
| 订单超卖,库存变负数 | 可用量校验没经过单位换算,或舍入规则用了向上取整 | 审查下单扣减接口是否统一走换算服务 | 补换算逻辑;把销售单位的舍入改为向下取整 |
| 同一商品前台展示0.3kg,结算价和标价差10倍 | 销售单位与价格单位不一致,价格联动缺环节 | 查“按份/按克”的价格换算调用链 | 统一走到 convertPrice 服务 |
| 历史报表毛利变动 | 历史订单换算率被当前商品主数据覆盖 | 查订单明细快照表和单位归档字段 | 重跑历史报表前,核对快照内容 |
| 条码扫描出错误的商品 | 条码映射到商品,但没映射单位 | 查条码-商品-单位三元组 | 重建条码映射关系 |
这张表不是万能的,但排查时从“单位换算”这个维度先看一眼,往往能筛掉一半问题。
5.2 三个高频Bug的根源分析
Bug一:库存不增反减。现象是采购入库后,基准库存反而变少了。查下来是有人把换算率维护反了:入库选了箱,1箱=24瓶,但 rate 填成了0.041667(即1瓶等于1/24箱),入库2箱后系统算出基准数量只有0.0833瓶,再跟原来的库存一减,账面就负数了。这种问题的根源就是第2节说的“换算方向未统一”,解决方式是代码里强制只用一种换算语义,同时在管理后台表单做“物理含义预览”,维护人提交前能看一句“您填写的意思是:1箱 = 24瓶”,当场就能发现反了。
Bug二:四舍五入导致小额超卖。某SKU库存基准单位是克,销售单位是“份”,一份200g,客户下单数量为1份,库存剩余1200g,系统正确扣减了200g。但另一个SKU的套餐销售时,一份等于167g,剩余库存1000g,系统四舍五入后扣了167g,累计几次后尾差越来越大。根源是“多次四舍五入”叠加,解决方式是只允许在最终展示位四舍五入,中间化算一律保留6位小数,并且每天的结转库存误差单独跑对账任务,超出阈值自动告警。
Bug三:订单历史价格错乱。现象是商品改过一次单位换算率之后,之前未发货的订单在财务侧重新计费,发现金额变了。根源是订单明细存的是“商品ID+数量+单位”,但没存换算率和单价快照,改主数据后历史单被“无意识重算”。解决方式是强制订单明细冗余存换算率字段,发货、开票、收款全部基于快照,不允许实时反查主数据。
5.3 团队协作层面的三条避坑准则
单位功能涉及的边界部门多(采购、仓库、销售、财务),光靠后端把关不够,还要在流程上定几条规矩:
- 新增单位必须走审批流,不允许开发在代码里自定义单位,必须进 unit 字典统一管理。谁破坏这个规矩,谁就等着数据治理返工。
- 每次改动基准单位或换算率,必须同时出对比报表,把改动前后的库存总值(按基准单位折算)拉出来对一下。差值不为零,说明迁移有漏。
- 上线前组织跨部门验收,不是开发自测通过就行。采购单录一笔、仓库PDA验一笔、销售POS卖一笔、财务对一笔,四个角色各走一遍,单位功能才算真的可用。
这三点看着像管理动作,但每一笔都对应着真实线上事故的教训。技术能解决的是“怎么算”,流程解决的是“别乱改”,两者缺一不可。
我个人在实际操作中的体会是:商品单位这类基础域的功能,上线前的最后一晚,一定要让测试的人用同一批商品、三个不同的单位各下一单,第二天一早去盘库存结存。这个习惯帮我省掉了很多次半夜救火的电话。单位虽然小,但它贯穿了商品从采购到销售到财务的整条链路,值得在最开始就用最认真的态度把它做稳。