SKU 级核算:v6 一维拍扁 × 物料明细的精细到每个商品
SKU 级核算:v6 一维拍扁 × 物料明细的精细到每个商品
摘要:SKU 级核算不是把订单拆成 SKU 那么简单——它要求「订单 → SKU → 单品利润」三层都保持完整追溯:账单层给出净额,供应链层给出数量与成本,物料明细层把两者用 6 字段(订单费用 / 公摊费用 / 数量 / 单价 / 金额 / 成本)焊在一起。v6 一维拍扁(每行 = 一份 SKU 明细)+
IncomePlanMaterialItem物料明细独立表这套组合,把原本需要头表+子表双层 JOIN 的链路变成单表 SUM,加上公摊费用反写到allocatedFeeAmount,让单品利润的核算精度可以细到「一分钱」。关键词:SKU 级核算、v6 一维拍扁、物料明细、IncomePlanMaterialItem
一个 SKU 的命运:6 字段记录一整个商品的一生
财务经理林姐打开某母婴品牌的 7 月 SKU 利润报表,第一眼就看到了一行红色数字:「SKU-A123 婴儿推车 7 月亏损 3,824.17 元」。她想搞清楚三件事:
- 这个 SKU 卖了多少件? —— 账单显示 286 件 × 单价 1,299 元 = 371,514 元。
- 成本是多少? —— 供应链系统显示 286 件 × 进货成本 968 元 = 276,848 元。
- 费用分摊了多少? —— 这一件商品到底被分摊了多少广告费、平台费、物流公摊?
第一个数字在收入对账计划里查得到;第二个数字在供应链系统里查得到;第三个数字——才是 SKU 级核算的核心难题。如果第三个数字查不到,林姐只能算出「商品实收 − 商品成本 = 毛利 94,666 元」,却算不出「扣完所有公摊后到底赚没赚钱」。
SKU 级核算的工程目标,就是让第 3 个数字可以被精确算出来——精确到每一个 SKU、每一分钱、每一次公摊反写。这背后的核心设计是 v6 一维拍扁 × 物料明细独立表 的一套组合拳。

v6 一维拍扁:为什么把模型拍扁
v5 二维模型的「双层 JOIN」困境
在 v6 之前,供应链侧是典型的二维模型——supply_orders 头表 + supply_order_items 子表。一笔「销售出库 SO-20260717-001」对应 2 个 SKU(SKU-1001、SKU-1002),头表记 1 行,子表记 2 行。
这种二维模型在 10 万订单以下没有问题,但 SKU 级核算一开就有 3 个痛点:
| 痛点 | 原因 | 影响 |
|---|---|---|
| 聚合慢 | 每次按 businessOrderNo 聚合都要 JOIN 头表 + 子表,再 GROUP BY | 10 万订单级别聚合要 8 秒,PG 优化到极限也只能压到 4 秒 [来源:v6 一维拍扁压测记录 2026-09] |
| 追溯断 | 子表 SupplyOrderItem.id 是行级 ID,但「对账成功 → 物料明细来源」这条链路需要 FK 直接对子表 | 子表与物料明细之间要加一层映射,链路长 |
| 行级字段冗余 | 头表 customerCode / orgCode 等业务字段在子表查不到,必须 JOIN | 单表 SUM 不能直接做 |
v6 一维拍扁:每行就是一份 SKU 明细
v6 的核心思路是把头表 + 子表合并为单张扁平表——supply_orders(注意表名没变,但语义变了)。每行 = 一份 SKU 明细。
模型设计有 4 个关键变更:
model SupplyOrder {
// ===== 主键(v6: 自定义唯一码)=====
id String @id @db.VarChar(64)
// 组成规则(buildSupplyOrderId):
// 1. ${sourceErp}:${sourceErpDocId}:${sourceErpEntryId}
// 2. ${sourceErp}:${no}:${entrySeq}
// 3. manual:${businessOrderNo}:${skuCode}:${entrySeq}
// ===== 单据级标识 =====
no String // v5 unique → v6 不再 unique(多行共享)
businessOrderNo String // 业务订单号(对账聚合键)
businessOrderId String? // v6 新增:ERP 系统层业务订单 id
// ===== 行级冗余字段(从 v5 头表下沉到每行)=====
customerCode String? // 行级冗余(v5 在头表)
orgCode String? // 行级冗余
totalAmount Decimal // 行级冗余(v5 在头表)
// ===== v6 行级字段(每行唯一)=====
entrySeq Int? // v6 新增:行序号
skuCode String // 每行就是一份 SKU
skuName String
spuCode String? // v6 新增
spuName String? // v6 新增
quantity Decimal // 4 位精度
unitPrice Decimal // 6 位精度
amount Decimal // 4 位精度(行级金额)
costAmount Decimal // 4 位精度(成本,SKU 级独立核算)
// ...
}
举个具体例子:一笔销售出库 SO-20260717-001 包含 2 个 SKU(SKU-1001、SKU-1002),v6 拍扁后变成 2 行:
| id (PK) | no | businessOrderNo | entrySeq | skuCode | amount |
|---|---|---|---|---|---|
manual:JD12345:SKU-1001:0 | SO-20260717-001 | JD12345 | 0 | SKU-1001 | 89.5 |
manual:JD12345:SKU-1002:1 | SO-20260717-001 | JD12345 | 1 | SKU-1002 | 129.0 |
这样设计带来的 4 个直接收益:
- 聚合不再需要 JOIN:
SELECT SUM(amount) FROM supply_orders WHERE businessOrderNo = 'JD12345'直接出 218.5 元。 - PK 是行级唯一码:
buildSupplyOrderId()拼出sourceErp:sourceErpDocId:sourceErpEntryId或manual:businessOrderNo:skuCode:entrySeq,ETL 阶段幂等。 - 行级字段冗余:
customerCode/orgCode/totalAmount等从 v5 头表下沉到每行,单表 SUM 不再需要「先 JOIN 头表取客户编码」。 - 物料明细 FK 直对扁平表:
IncomePlanMaterialItem.supplyOrderId直接引用SupplyOrder.id(v6 关键变更:从supplyOrderItemId改为supplyOrderId)。
v3 vs v6 架构对比:少一层 JOIN 是性能也是表达力
v6 拍扁带来的不只是性能——它让「SKU」从应用层概念变成数据库层的一等公民。

架构对比图里能看到一条重要的迁移线:v3 时代物料明细要先找到子表、再回溯头表;v6 时代物料明细的 FK 直接对扁平表行。这条迁移让 SKU 级核算的链路长度从「3 跳」(物料 → 子表 → 头表)压缩到「2 跳」(物料 → 扁平表行)。

上图的右侧「SKU 明细」区,每一行都是一个可独立核算的最小单元:账单净额、供应链净额、行级成本、行级 SKU,全部在单行内闭环。这是从「订单级聚合」升级到「SKU 级核算」的数据基础。
物料明细独立表:6 字段成本拆分的设计哲学
为什么物料明细必须是独立表
SKU 级核算的本质是「每个 SKU 的成本拆分」——把订单层的总收入、总费用,按 SKU 拆到每一行,让单品的「收入 − 成本 − 订单费用 − 公摊费用」可以算出来。
如果物料明细跟订单体行塞在同一张表里,会有 3 个问题:
- N+1 存储问题:一个订单有 5 个 SKU,体行存 1 行、SKU 明细存 5 行;如果塞在一张表里,要么用 JSON 字段(破坏范式),要么开 5 行(破坏 1:1 语义)。
- 更新策略不同:体行每跑一次对账都要重写,物料明细只在「对账成功」时重建——失败行不写物料明细。两条数据流合在一起会让「重跑失败行清旧明细」这个语义变模糊。
- 公摊反写隔离:公摊费用反写(费用对账 → 收入对账)只动
IncomePlanMaterialItem.allocatedFeeAmount,不动订单级IncomePlanItem.allocatedFeeAmount。SKU 级的字段独立反写要求物料明细必须是独立表。
所以 v3 设计决议里明确写:物料明细独立表,IncomePlanMaterialItem 对账成功才写,失败不写。这是 30+ 检查项里 product-manager-review 必审的红线之一。
6 字段成本拆分模型
IncomePlanMaterialItem 一共 12 个核心字段,SKU 级核算用得到的有 6 个:
model IncomePlanMaterialItem {
id String @id @default(cuid())
planItemId String // 关联 IncomePlanItem(订单级)
skuCode String // SKU 编码
skuName String
quantity Decimal @db.Decimal(20, 4) // 数量
unitPrice Decimal @db.Decimal(20, 6) // 单价(6 位精度)
amount Decimal @db.Decimal(20, 4) // 金额(4 位精度)
costAmount Decimal @default(0) @db.Decimal(20, 4) // 成本
orderFeeAmount Decimal @default(0) @db.Decimal(20, 4) // 订单费用 SKU 拆分
allocatedFeeAmount Decimal @default(0) @db.Decimal(20, 4) // 公摊费用 SKU 拆分
supplyOrderId String // v6: 直接引用扁平表行 id
matchStrategy String?
matchScore Decimal @default(1.0) @db.Decimal(6, 4)
// ...
}
SKU 级核算的 6 字段成本拆分,逐字段拆解如下:
| 字段 | 数据来源 | SKU 级核算的作用 |
|---|---|---|
quantity | SupplyOrder.quantity | 单品销量(与 unitPrice 相乘 = amount) |
unitPrice | SupplyOrder.unitPrice | 单价 6 位精度(支持 0.000001 元的边界 SKU) |
amount | SupplyOrder.amount | 行级金额(4 位精度,SKU 级聚合) |
costAmount | SupplyOrder.costAmount | 行级成本(SKU 级独立核算单品毛利) |
orderFeeAmount | 订单费用 SKU 拆分(orderFeeAllocations 覆盖式) | 单品承担的订单级费用(佣金 + 技术服务费 + 物流费等) |
allocatedFeeAmount | 公摊费用 SKU 拆分(allocations SKU 行实时反写) | 单品承担的公摊(广告费 + 营销费 + 跨店调拨等) |
6 字段的核心逻辑:
单品利润 = amount − costAmount − orderFeeAmount − allocatedFeeAmount
这 4 个减项,4 个数据源——amount 来自账单,costAmount 来自供应链,orderFeeAmount 来自订单费用 SKU 拆分,allocatedFeeAmount 来自公摊费用实时反写。任何一项缺失,SKU 级核算都算不准。
物料明细写入时机:3 条铁律
v3 设计里有 3 条铁律,决定物料明细什么时候写、什么时候清:
- 对账成功才写:物料明细是「对账成功的确定性 SKU 聚合」,不消费脚本输出——脚本无论返回什么结果(SUCCESS / PARTIAL_SETTLEMENT / INSTANT_REFUND),只要判定成功就重建物料明细。
- 失败不写:对账失败(FAILURE)的订单不写物料明细。
- 重跑覆盖:新一轮对账覆盖旧结果——物料明细先删后建(重跑失败行清旧明细,不残留)。
但有 2 个例外:
- 例外一:脚本返回
matchedSupplyOrderIds——物料明细只聚合命中子集(部分结算场景,避免跨期/重复行订单把未结算供应链行聚进财务应收单体行导致金额虚增)。 - 例外二:脚本返回
skipMaterialItems: true——执行层跳过该行物料重建(整单轧差口径下同一订单正/负体行都判成功,非承载行靠此标记防止一单物料翻倍)。

上面这张截图就是物料明细的真实呈现——6 列成本拆分(订单费用 / 公摊费用 / 数量 / 单价 / 金额 / 成本),每一行都是一个 SKU 的一生。
主体干货 1:从订单到 SKU 的追溯链路(3 跳)
SKU 级核算的核心难点不是计算本身,而是追溯链路——任何一个 SKU 都要能反向追到「哪个订单、哪个账单行、哪个供应链行」。3 跳追溯链是这样的:
账单层 BillRow(平台原始账单行) ← 第 1 跳
│
│ 桥表 plan_source_relations(5 字段)
▼
体行层 IncomePlanItem(订单级对账结果) ← 第 2 跳
│
│ IncomePlanItem.id → planItemId(FK)
▼
物料层 IncomePlanMaterialItem(SKU 明细) ← 第 3 跳
│
│ IncomePlanMaterialItem.supplyOrderId → SupplyOrder.id(FK,v6 直对扁平表行)
▼
供应链层 SupplyOrder(v6 一维拍扁行) ← 第 4 跳(直接落到 SKU 行)
这 4 跳链路的关键设计点:
第 1 跳(账单 → 体行):通过 plan_source_relations 桥表(5 字段最小数据集)实现 1:N 反向追溯,不存任何业务字段,只存「计划 + 账单行 + 金额」三元组。
第 2 跳(体行 → 物料):通过 planItemId FK 一对一关联。一个订单体行 → N 个 SKU 物料行(一对多)。
第 3 跳(物料 → 供应链):通过 supplyOrderId FK 直接对 v6 扁平表行 id。这是 v6 拍扁带来的最大便利——v5 时代还要先找子表再找头表,v6 一步到位。
整条链路里,每个 FK 都是物理存储的「反向索引」,查询时不需要任何 JOIN 推导。这就是数据模型深度的体现:每一个跨表连接都被显式建模,反追溯不需要业务层拼装。
主体干货 2:公摊反写 —— SKU 级核算的最后一公里
为什么公摊反写是 SKU 级核算的核心难题
amount 和 costAmount 是「订单级 → SKU 级」的天然拆分(按 quantity 比例),但 orderFeeAmount 和 allocatedFeeAmount 是「订单级 → SKU 级」的二次拆分,需要沙箱脚本额外计算。
尤其是 allocatedFeeAmount(公摊费用)——它来自费用对账的沙箱分摊,不是账单行本身。比如:
- 一笔广告费 50,000 元,需要分摊到所有订单的 SKU;
- 一笔仓储费 30,000 元,需要按销量分摊;
- 一笔跨店调拨,需要识别调出方和调入方分别承担多少。
这 3 类公摊费用,账单层根本不存在对应的账单行——它来自费用对账沙箱分摊的结果。要把这些公摊费用「反写」到收入对账的 SKU 级物料明细里,需要一套精密的工程机制。
公摊反写的 2 种模式(E1 实时 / E11 覆盖式)
v3 费用对账深化的设计文档里定义了 2 种公摊反写模式:
| 模式 | 触发时机 | 实现方式 | 适用场景 |
|---|---|---|---|
| E1 实时反写 | 沙箱脚本返回后立刻写入 allocatedFeeAmount | 增量更新,SUM = 全部公摊费用 | 增量分摊、可重算 |
| E11 覆盖式反写 | 沙箱分摊批次生成时一次性覆盖所有 SKU 行 | 整批覆盖,先删后写 | 全量重分摊、跨期调整 |
E1 + E11 双模式的工程意义:每一次公摊分摊都可以「无损反悔」——既能增量迭代,又能整批重算。SKU 级核算里这是必需的:某月的广告费分摊方案可能调整 3 次,每次都要让所有 SKU 的 allocatedFeeAmount 重新对齐。
如果走这条路,可以参考把「v6 一维拍扁 + 物料明细 + 公摊反写」整套做好的系统——譬如轻易云智能对账系统。
反写的 3 级命中集:精确到 SKU 行
公摊反写的目标不是「订单级」而是「SKU 级」——一个公摊费用要找到具体哪些 SKU 行去吸收。v3 设计里定义了 3 级命中集:
Level 1 · 命中 IncomePlanItem(订单级)
│ 同一 businessOrderNo 的所有订单级体行
│ 分配规则:按业务订单号精确匹配
▼
Level 2 · 命中 IncomePlanMaterialItem(SKU 级)
│ 该订单体行的所有 SKU 物料明细行
│ 分配规则:按 SKU 的 amount / quantity 比例分摊
▼
Level 3 · 命中 SupplyOrder(供应链行)
│ v6 拍扁后,物料明细直接 FK 到扁平表行
│ 分配规则:每条 supplyOrderId 独立记账
3 级命中集的工程价值:公摊反写的颗粒度可以细到每一个供应链行。比如某订单的 SKU-1001 售出 286 件,SKU-1002 售出 150 件,公摊的广告费 8,000 元按 amount 比例分摊——SKU-1001 承担 5,247 元,SKU-1002 承担 2,753 元,精确到分。

上图展示了 3 级命中集的反写路径——业务订单号先找到体行,体行再找到物料明细,物料明细再找到扁平表行。每一步都有 FK 索引支撑,不需要任何业务层拼装。
公摊反写的边界条件:5 条铁律
公摊反写不是「无脑全写」,v3 设计里有 5 条铁律防止边界异常:
- 目标必须
IncomePlan.status = CONFIRMED:未确认的收入计划不参与公摊反写(避免「对账没确认就被分摊」导致数据污染)。 - 找不到反写数据允许:分摊批次生成时如果某公摊费用找不到反写目标(比如已取消订单),允许该笔费用「无目标」状态存在——不强制报错。
- 撤旧幂等:每次反写都要先清旧再写新,防止「同一笔公摊被重复叠加」(E11 覆盖式)。
- 尾差容差 0.01:分摊完成后
Σ allocatedFeeAmount与原始公摊费用的尾差必须在 0.01 元以内,由末行兜底吸收。 - 跨店不交叉:跨店调拨的公摊费用只在本店 SKU 之间分摊,不跨店——跨店合并通过桥表和 5 元聚合键实现,不在公摊反写阶段做。
主体干货 3:单品利润计算 —— 从 6 字段到 4 减项
单品利润公式(精确到分)
把物料明细的 6 字段成本拆分落到具体的单品利润公式:
单品净利润 = amount − costAmount − orderFeeAmount − allocatedFeeAmount
逐项拆解:
| 项 | 来源 | 典型值(举例 SKU-A123 婴儿推车) |
|---|---|---|
amount | 账单层(SupplyOrder.amount,4 位精度) | 1,299 × 286 = 371,514.00 元 |
costAmount | 供应链层(SupplyOrder.costAmount) | 968 × 286 = 276,848.00 元 |
orderFeeAmount | 订单费用 SKU 拆分(佣金 + 技术服务费) | 22,290.84 元 |
allocatedFeeAmount | 公摊费用 SKU 拆分(广告 + 仓储 + 跨店调拨) | 76,199.33 元 |
| 单品净利润 | 计算 | −3,824.17 元 |
这就是林姐看到的「7 月亏损 3,824.17 元」的真实来源——表面毛利 94,666 元,扣完订单费用 22,290.84 元 + 公摊费用 76,199.33 元,实际亏损 3,824.17 元。
4 个减项的精细化拆解
要让单品利润算到「一分钱」,4 个减项必须各自精确。逐项说明:
减项 1:costAmount(成本) —— 来自供应链系统,是供应链侧最基础的核算单位。v6 拍扁后,每行的 costAmount 都是「这一行 SKU 的实际成本」。如果供应链有「赠品」「B 端特批」「促销让利」等不同进货价,costAmount 应该如实记录。
减项 2:orderFeeAmount(订单费用) —— 来自订单费用 SKU 拆分,由费用对账沙箱脚本计算。一个订单的「订单费用」= 平台佣金 + 技术服务费 + 支付服务费 + 物流费等,这些费用按 SKU 的 amount 比例分摊到每行。沙箱脚本里 orderFeeAllocations 是「覆盖式」写入——重跑会先清后写。
减项 3:allocatedFeeAmount(公摊费用) —— 来自公摊费用 SKU 拆分,是 SKU 级核算的核心难点。公摊费用包括广告费、跨店调拨、仓储共享成本等——这些费用不是订单本身产生的,必须通过分摊计算。沙箱分摊脚本里 allocations 是「实时反写」——每次分摊结果立刻生效。
减项 4:orderFeeAmount + allocatedFeeAmount(订单费用合计 / SKU 级虚拟字段) —— 前端虚拟展示字段,不落库,与订单级 IncomePlanItem 的「订单费用合计」对齐命名。计算公式 订单费用合计 = orderFeeAmount + allocatedFeeAmount,是 SKU 级核算的「最终交付物」。
单品利润的展示形态:6 字段 + 1 虚拟字段
实际展示时,物料明细的 6 字段 + 1 虚拟字段会按下面这 7 列呈现:
| 列 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 1 | SKU 编码 | skuCode | 维度锚定 |
| 2 | SKU 名称 | skuName | 维度锚定 |
| 3 | 数量 | quantity | 销量 |
| 4 | 单价 | unitPrice | 6 位精度 |
| 5 | 金额 | amount | 4 位精度 |
| 6 | 成本 | costAmount | 4 位精度 |
| 7 | 订单费用(虚拟) | orderFeeAmount + allocatedFeeAmount | 前端合计,不落库 |
7 列里,订单费用是虚拟字段(合计),其他 6 列全部落库。SKU 级核算的精度 = 数据库落库的精度 = 前端展示的精度 = 1 分钱。
SKU 级核算的 3 个真实踩坑(来自真实场景)
最后给 3 个 SKU 级核算的真实踩坑:
踩坑 1:公摊反写未实时更新,导致月度利润虚高。某美妆品牌 7 月初跑完对账,到 7 月 15 日才完成广告费分摊。如果按 7 月 1 日的物料明细看「已实现利润」,会漏算 15 日才补的 23 万广告费,月度利润虚高 23 万。修正:公摊反写必须「实时 + 覆盖式」双模式(E1 + E11),任何一次分摊都要立刻生效,不允许「延迟反写」。
踩坑 2:物料明细与订单体行的 costAmount 不同源。某 3C 品牌早期 SKU 利润表里,IncomePlanItem.costAmount 是「头表汇总的供应链成本」,IncomePlanMaterialItem.costAmount 是「行级汇总的供应链成本」——两个字段来自不同 ETL 链路,差异 1.7 万元。修正:v3 设计要求「物料明细的 costAmount 与订单体行的 costAmount 同源同 ETL」,避免字段不一致。
踩坑 3:跨店调拨未识别,调出方多算利润。某母婴品牌的京东自营店 7 月给抖店调了 500 件气炉,调拨单未标记 internalTransferFlag = true,调出方 27 万销售收入被算成调出方利润,调入方又算了一次。修正:跨店调拨必须打 internalTransferFlag = true,合并报表自动抵消;SKU 级核算也要把调拨单的物料明细 costAmount 单独标记,不计入任一方。
这 3 个坑都是「数据模型深度不到位」的具体表征——SKU 级核算不只是「拆得细」,还要「拆得准」「反写全」「边界清」。
收尾:从 6 字段看数据模型深度
回到开头的问题——林姐看到的「SKU-A123 婴儿推车 7 月亏损 3,824.17 元」是怎么算出来的?
答案分 3 层:
- 第 1 层:v6 一维拍扁让 SKU 成为数据库层的一等公民。每行 SupplyOrder 都是一份可独立核算的 SKU 明细,聚合不需要 JOIN,追溯只需要 FK。
- 第 2 层:物料明细独立表让 6 字段成本拆分有处安放。
quantity/unitPrice/amount/costAmount/orderFeeAmount/allocatedFeeAmount6 个字段各司其职,单品利润公式 = 1 减 3。 - 第 3 层:公摊反写让 SKU 级核算的最后一公里走通。3 级命中集(订单级 → SKU 级 → 供应链行)+ 2 种反写模式(E1 实时 + E11 覆盖式)+ 5 条铁律,让单品的「订单费用 + 公摊费用」可以被精确算出来。
轻易云智能对账系统把这 3 层做成了开箱即用的产品。SKU 级核算不是把订单拆得细,而是把数据模型做得深——每个跨表连接都被显式建模,每笔公摊反写都被精确追踪。
写在最后
本篇的 v6 一维拍扁 + 物料明细设计与以下 4 个领域知识互为补充,感兴趣的 CFO 与财务经理可继续深挖:
- 收入对账状态机:7 态迁移(pending → ready → reconciling → reconciled → confirmed / failed / cancelled),物料明细在哪个状态写入、哪个状态删除的完整时序(项目内 income-reconciliation-development 体系)。
- 23 个 diffReason 业务标签码:把「SKU 级核算的差异」翻译成机读 + 人读双轨——PARTIAL_SETTLEMENT / SUBSIDY_DIFF / NET_MERGED 等标签码决定了哪些体行写物料明细、哪些不写(项目内 reconciliation-script-development 体系)。
- 集成转换脚本契约:物料明细的
supplyOrderId是集成转换 scope 的喂数来源,集成转换脚本按命中行逐行出分录(金蝶 AR 集成链路消费FORDERENTRYID,与 v6 拍扁的id字段一致)(项目内 integration-transform-script-development 体系)。 - 测试供应链造数:v6 扁平行直写(每行 = SKU 明细)的 6 场景预设(精确匹配 / 缺单 / 差异 / 分批 / 退货混合 / 大体量),是 SKU 级核算端到端验证的标准场景(项目内 test-supply-order 体系)。