All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m13s
运单管理支持导入下游对账重量并锁定渠道重算应付;费用明细按最高计费阶段合计,避免预报与入库叠算。 Co-authored-by: Cursor <cursoragent@cursor.com>
325 lines
14 KiB
Plaintext
325 lines
14 KiB
Plaintext
---
|
||
name: OMS应收应付计费逻辑
|
||
overview: OMS 小包订单应收(卖价)与应付(渠道买价)的完整计费逻辑说明,含生命周期、计费重、附加服务、计费条件、公式、偏远分区、发布校验与二期能力;供后续开发/运营查阅。
|
||
isProject: false
|
||
---
|
||
|
||
# OMS 应收 / 应付计费逻辑(完整说明)
|
||
|
||
> 状态:已落地(2026-08)。仓库:前端 `E:\oms0414`,后端 `oms0414-back`。
|
||
> SQL:`sql/20260801_billing_additional_services.sql`、`sql/20260801_billing_phase2.sql`。
|
||
|
||
---
|
||
|
||
## 1. 概念与命名
|
||
|
||
| 术语 | 含义 |
|
||
|------|------|
|
||
| **应收** | 向客户收取的费用,`OrderFee.Type = receivable`,来自**销售产品卖价**发布快照 |
|
||
| **应付** | 付给下游渠道的成本,`OrderFee.Type = payable`,来自**服务渠道买价**发布快照 |
|
||
| **计费重** | `OrderWeight.ChargeableWeight`:计重规则(实重/体积重)+ 进位后的重量;**金额一律按计费重,不用实重** |
|
||
| **计费条件** | 卖价/买价项字段 `ChargingCondition`(中文公式);空或「无」= 恒真 |
|
||
| **附加服务** | 产品可选、客户下单勾选;字典 `additional_service`;写入订单 `AdditionalServices` JSON |
|
||
| **价格分区** | KPI `t_kpi_area`(price):地址 → 分区键;定价因素 `zone`/`价格分区`(旧名「分区」仍兼容)。产品表不再配置 `PriceAreaId`,由卖价分区反推进快照 |
|
||
| **偏远分区** | KPI remote:① 产品/渠道 `RemoteAreaId` → 布尔「是否偏远」;② 定价因素 `remoteZone`/`偏远分区` 可按 RemoteAreaId 配偏远费明细行 |
|
||
|
||
**命名陷阱**:`Order.PayableAmount` = 客户应付(营销后应收净额),**不是**渠道应付合计。
|
||
|
||
---
|
||
|
||
## 2. 总览流水线
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
placeOrder[下单勾选附加服务] --> weightF[预报件重尺算计费重]
|
||
weightF --> remoteP[产品偏远分区写 IsRemote]
|
||
remoteP --> recvF[卖价:条件+匹配+公式 → 应收 forecast]
|
||
recvF --> payF[排货:渠道偏远上下文 → 应付 forecast]
|
||
payF --> snap[固化 ForecastReceivable/Payable 快照]
|
||
snap --> marketing[营销折算 PayableAmount]
|
||
inbound[入库] --> weightI[收货件重尺算计费重]
|
||
weightI --> wipeR[清空全部应收后写 inbound 应收]
|
||
wipeR --> wipeP[清空全部应付后重排货写 inbound 应付]
|
||
wipeP --> refreshM[刷新营销金额]
|
||
paid[客户支付] --> freeze[应收硬冻结]
|
||
outbound[出库] --> shipOnly[仅更新 ship 件重尺/状态]
|
||
shipOnly --> noRecv[不再写应收]
|
||
reconcile[导入对账件重尺] --> downW[写 downstream 件重尺]
|
||
downW --> payFinal[锁定当前渠道重算应付 outbound]
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 生命周期与落库规则
|
||
|
||
### 3.1 各节点行为
|
||
|
||
| 节点 | 应收 | 应付 | 备注 |
|
||
|------|------|------|------|
|
||
| **下单** | 预报件重尺 → 计费重 → 写 `forecast` | 同计费重尝试排货,成功则写 `forecast` 应付 | 排货失败不阻断下单,记 `AllocationPendingReason` |
|
||
| **入库** | 同步只写 receive 重尺/收货时间,状态先 `pendingAllocation`;应收在异步任务 `inbound-billing` 中清空并写 `inbound` | 异步任务内 `TryConfirm` 重排货写应付;失败仍待排货 | **不校验服务渠道、不阻断扫码**;有效账单仍只保留一套 |
|
||
| **出库** | **不写应收** | 不自动重算 | 只更新 ship 件重尺与状态 |
|
||
| **已支付** | 禁止重算/加应收/改附加服务触发重算;禁止重跑入库计费 | 仍可重算应付 / 导入对账重 | `EnsureReceivableMutable` |
|
||
| **导入对账件重尺** | **不改** | 写 `downstream` 重尺 → 锁定当前渠道重算应付(阶段 `outbound`) | 运单管理:`GET/POST /order/weight/downstream-*`;尺寸可留空回落收货/发货 |
|
||
|
||
### 3.2 重量口径(硬约束)
|
||
|
||
1. 用件重尺 + 计重规则 → 体积重 / 原始计费重
|
||
2. 进位规则 → `ChargeableWeight`
|
||
3. 应收、应付、排货预览**同一** `ChargeableWeight`
|
||
4. **禁止**用 `ActualWeight` 直接乘单价
|
||
5. **有效应收/应付只保留一套**:入库或重算后不得叠算预报+入库;合计按最高计费阶段(出库 > 入库 > 预报)
|
||
|
||
### 3.3 预报快照(二期)
|
||
|
||
下单完成后固化,入库不覆盖:
|
||
|
||
- `Order.ForecastReceivableAmount`
|
||
- `Order.ForecastPayableAmount`
|
||
|
||
运单详情费用 Tab 展示「预报 → 当前」差额与毛利(应收 − 应付)。
|
||
|
||
---
|
||
|
||
## 4. 单条价格项怎么算出来
|
||
|
||
引擎:`ParcelBillingCalculator`。
|
||
|
||
对每个**启用**卖价/买价项:
|
||
|
||
```
|
||
1. 计费条件求值(ChineseFormulaEvaluator)→ 假则跳过
|
||
2. 分区 / 货物属性 / 仓库 / 重量区间匹配明细
|
||
3. 按 calculation_formula 算金额
|
||
4. 同阶段同 CostItem 多命中 → 保留 SerialNumber 最小一条
|
||
```
|
||
|
||
### 4.1 计费条件
|
||
|
||
- 字段:`ChargingCondition`(UI 文案「计费条件」)
|
||
- 空 / 「无」→ 恒真
|
||
- 语法:与自动触发相同(中文字段 + `包含` / `&&` `||` / 比较)
|
||
- AI Skill:`oms-billing-condition-formula`(scene `billingConditionFormula`)
|
||
- 常用示例:
|
||
- `附加服务包含('insurance')`
|
||
- `是否偏远()==1`
|
||
- `申报总金额()>1000`
|
||
|
||
**应收上下文**:订单字段 + 产品偏远命中后的 `IsRemote` + `AdditionalServices`。
|
||
**应付上下文**:同一引擎,但 **`是否偏远` 按渠道 `RemoteAreaId` 命中**注入;不用客户附加服务驱动应付。
|
||
|
||
### 4.2 价格分区 / 偏远分区匹配
|
||
|
||
字典 `pricing_factor`:
|
||
|
||
| code | 展示名 | 因素值来源 |
|
||
|------|--------|------------|
|
||
| `zone` | 价格分区 | KPI `areaType=price` |
|
||
| `remoteZone` | 偏远分区 | KPI `areaType=remote` |
|
||
|
||
1. 发布卖价时 `PriceAreaSnapshotHelper` 从卖价明细**价格分区**键反推 AreaId,固化 `PriceAreaInfos`
|
||
2. 校验:可解析的价格分区键必须同属一个 AreaId,否则拒绝发布
|
||
3. 下单/查价:地址匹配规则 → `zoneKeys` → 对明细 `分区`/`价格分区`/`zone` 因子
|
||
4. **偏远分区因素**:明细含 `偏远分区`/`remoteZone` 时,须偏远命中且因素值 = 生效 `RemoteAreaId`(或同名);非偏远或键不匹配则该明细不命中;明细无该因素则不筛偏远
|
||
|
||
产品配置**不再**维护 `PriceAreaId`(保存时置 null)。
|
||
|
||
### 4.2.1 客户组 / 全部客户
|
||
|
||
- 字典 `customer_group` 含 `all`(全部客户);与其它客户组**互斥**(前端选中 all 后其它置灰)
|
||
- 价格表只生成一列「全部客户」时,`CustomerGroupValues` JSON 键为「全部客户」
|
||
- 取价优先级:`全部客户`/`all` → 客户 `PriceTierKey` 精确键 → 「普通客户」回落
|
||
|
||
### 4.3 计算公式(字典 `calculation_formula`)
|
||
|
||
| code | 标签 | 金额 |
|
||
|------|------|------|
|
||
| `total` | 总价 | 明细单价(可含客户分组价) |
|
||
| `unit` | 单价 | 单价 × 计费重 |
|
||
| `continue_unit` → `continue` | 续单价 | 首重总价 + 续重 |
|
||
| `percent_declared` | 货值比例 | `round(DeclaredSum × rate / 100, 2)` |
|
||
| `percent_freight` | 运费比例 | 两趟计算:先算非运费比例项,再 `round(同币种运费合计 × rate / 100, 2)` |
|
||
|
||
约定:
|
||
|
||
- `rate` 存在 `calculatedValue`(百分数,如 `0.5` = 0.5%)
|
||
- `percent_declared`:`DeclaredSum<=0` 或与卖价币种不一致(有申报币种时)→ 跳过
|
||
- 无计费重时:仅允许不依赖重量的公式路径(如 total / percent_declared;percent_freight 依赖同批次运费)
|
||
|
||
### 4.4 费用项字典(`fee_item`)
|
||
|
||
常用:`freight` 运费、`handling` 操作费、`insurance` 保险费、`fuelSurcharge` 燃油附加费、`remoteFee` 偏远费。
|
||
|
||
---
|
||
|
||
## 5. 附加服务
|
||
|
||
### 5.1 配置
|
||
|
||
- 字典类型:`additional_service`
|
||
- `insurance` 保险
|
||
- `repackage` 更换包装
|
||
- 产品:`SalesProduct.AdditionalServices` = JSON 数组,如 `["insurance","repackage"]`
|
||
- 订单:`Order.AdditionalServices` = 客户勾选结果
|
||
|
||
### 5.2 下单 / 改服务
|
||
|
||
- PC / H5 下单:按产品可选服务渲染勾选 → `additionalServices: string[]`
|
||
- 非法 code(不在产品配置内)→ 拒绝
|
||
- **未支付**可改:`POST /order/additional-services/update` → 校验 → 重写服务 → 按当前阶段重算应收 → 刷新营销 → 尽力重算应付
|
||
- **已支付**禁止改(走应收冻结)
|
||
|
||
### 5.3 与收费关系
|
||
|
||
附加服务**不直接**挂费用;通过卖价计费条件触发,例如保险费项条件:
|
||
|
||
```text
|
||
附加服务包含('insurance')
|
||
```
|
||
|
||
条件留空的运费等基础项:始终尝试收取(再走分区/重量匹配)。
|
||
|
||
---
|
||
|
||
## 6. 偏远费
|
||
|
||
| 侧 | 配置 | 运行时 |
|
||
|----|------|--------|
|
||
| 应收 | 销售产品 `RemoteAreaId` + 卖价因素「偏远分区」可选 | 命中 → `Order.IsRemote=1`;条件 `是否偏远()==1`;明细偏远分区键 = 产品 RemoteAreaId |
|
||
| 应付 | 服务渠道 `RemoteAreaId` + 买价因素「偏远分区」可选 | 排货时按**渠道**偏远注入条件上下文;明细偏远分区键 = 渠道 RemoteAreaId |
|
||
|
||
两套分区可不同。偏远费项发布/保存时强制条件含「是否偏远」。推荐:运费项只挂价格分区;偏远费项挂偏远分区 + `是否偏远()==1`。
|
||
|
||
部署后请重新导入 OMS 预置字典(`pricing_factor` / `customer_group` 有变更)。
|
||
|
||
---
|
||
|
||
## 7. 发布与保存校验
|
||
|
||
卖价保存/发布:
|
||
|
||
- `costItem=insurance` → 计费条件不能为空/「无」
|
||
- `remoteFee`(或名称含「偏远」)→ 条件必须含「是否偏远」
|
||
- 分区因子不得混用多个 AreaId
|
||
|
||
买价:偏远费条件校验同上。
|
||
|
||
---
|
||
|
||
## 8. 理赔与保险(闭环)
|
||
|
||
`POST /order/mark-claimed`:
|
||
|
||
1. `ClaimAmount > 0`
|
||
2. 若 `DeclaredSum > 0` 且索赔额 > 申报额 → 拒绝
|
||
3. 订单含 `insurance` 附加服务:`HasInsurance=true`,索赔币种默认可取申报币种
|
||
4. 未购保险:允许理赔,备注侧可打 `[未购保险]`
|
||
|
||
前端理赔弹窗展示保险提示与申报上限。
|
||
|
||
---
|
||
|
||
## 9. 关键 API
|
||
|
||
| API | 作用 |
|
||
|-----|------|
|
||
| `POST /order/parcel/create` | 下单:应收 forecast + 尝试应付 + 预报快照 |
|
||
| `POST /order/price-check` | 查价(可带 additionalServices / DeclaredSum) |
|
||
| `POST /operation/inbound/create` | 入库:清空后重算应收/应付 inbound |
|
||
| `POST /operation/outbound/create` | 出库:不写费用 |
|
||
| `GET /order/weight/downstream-import-template` | 对账件重尺导入模板 |
|
||
| `POST /order/weight/downstream-import` | 导入 downstream 重尺并锁定渠道重算应付 |
|
||
| `POST /order/fee/recalculate-receivable` | 重算应收(已支付拒绝) |
|
||
| `POST /order/fee/recalculate-payable` | 重算应付(已有渠道时锁定渠道,不换渠道) |
|
||
| `POST /order/fee/rerun-inbound-billing` | 重跑入库计费(已支付拒绝,应收已锁) |
|
||
| `POST /order/fee/add` | 手工加费(应收已支付拒绝) |
|
||
| `POST /order/additional-services/update` | 未支付改附加服务并重算 |
|
||
| `POST /channel/sales-product/billing/simulate` | 用当前库内卖价做计费模拟 |
|
||
| `POST /order/mark-claimed` | 理赔(含保险规则) |
|
||
|
||
---
|
||
|
||
## 10. 前端落点
|
||
|
||
| 能力 | 位置 |
|
||
|------|------|
|
||
| 产品附加服务、去掉价格分区 | `/channel/salesProducts` 产品配置 |
|
||
| 计费条件(紧凑:输入 + AI + 插入字段) | 卖价/买价基础信息 |
|
||
| 计费模拟 | 卖价 Tab 工具栏 |
|
||
| 货值比例 / 运费比例 | 价格表明细计算公式字典 |
|
||
| 渠道偏远分区 | `/channel/serviceChannels` |
|
||
| 下单勾选附加服务 | PC `placeOrder`、H5 `H5PlaceOrderSheet` |
|
||
| 毛利 / 预报差额 / 改附加服务 | 运单管理 `OrderDetailDrawer` 费用 Tab |
|
||
| 导入对账件重尺 | 运单管理「更多」→「导入对账件重尺」 |
|
||
| 预置字典种子 | `system/dict/dict-batch-import.ts`(下载 OMS 预置字典) |
|
||
|
||
---
|
||
|
||
## 11. 核心代码索引
|
||
|
||
| 模块 | 路径 |
|
||
|------|------|
|
||
| 金额引擎 | `Oms.Application/.../ParcelBillingCalculator.cs` |
|
||
| 下单/入库/出库编排 | `OrderService.ParcelBilling.cs` |
|
||
| 改服务 / 冻结 / 对账导入 | `OrderService.OrderEdit.cs` |
|
||
| 对账件重尺 Excel | `OrderDownstreamWeightImportHelper.cs` |
|
||
| 查价 | `OrderService.ParcelQuote.cs` |
|
||
| 计费模拟 | `SalesProductService.BillingSimulate.cs` |
|
||
| 分区快照/混表校验 | `PriceAreaSnapshotHelper.cs` |
|
||
| 公式字段目录 | `AutoTriggerFormulaMeta.cs` / `formulaMeta.ts` |
|
||
| 计费条件编辑器 | `salesProducts/components/BillingConditionEditor.vue` |
|
||
| 单测 | `Oms.Tests/ParcelBillingCalculatorTests.cs` |
|
||
|
||
---
|
||
|
||
## 12. 运营配置速查
|
||
|
||
### 保险费(客户勾选才收)
|
||
|
||
1. 产品勾选附加服务 `insurance`
|
||
2. 卖价项:费用项=保险费,公式=货值比例,费率如 `0.5`
|
||
3. 计费条件:`附加服务包含('insurance')`
|
||
4. 客户下单勾选保险且填写申报金额 → 收取
|
||
|
||
### 偏远费(应收)
|
||
|
||
1. 产品绑偏远分区
|
||
2. 卖价项:`remoteFee` + 条件 `是否偏远()==1` + 总价/单价
|
||
|
||
### 偏远费(应付)
|
||
|
||
1. 渠道绑偏远分区
|
||
2. 买价项:同上条件与公式
|
||
|
||
### 燃油等按运费比例
|
||
|
||
卖价项:公式=运费比例,`calculatedValue`=百分数;一般条件留空或按国家等过滤。
|
||
|
||
---
|
||
|
||
## 13. 明确不做 / 后续可做
|
||
|
||
**已不做(或刻意不做):**
|
||
|
||
- 卖价项级「计费时机」字段
|
||
- 出库改已付应收
|
||
- 物理 drop `t_sales_product.PriceAreaId`
|
||
- 导入任意重量类型自动改应收(对账导入只动应付)
|
||
|
||
**可后续:**
|
||
|
||
- 查价页批量勾选附加服务
|
||
- 运费比例之外的多币种兑换
|
||
- DDP/关税代垫调整单
|
||
- 一票多件计费维度
|
||
- 发布前更完整的毛利模拟(含多候选渠道对比)
|
||
- 费用争议走调整单而非改原费用行
|
||
- 对账导入异步任务(超 2000 行)与应付版本审计
|
||
|
||
---
|
||
|
||
## 14. 相关计划
|
||
|
||
- 实施过程稿:[`附加服务与计费条件_b5d360e1.plan.md`](附加服务与计费条件_b5d360e1.plan.md)
|
||
- 本文件为**已落地逻辑**的权威查阅版;业务规则变更时优先更新本文件。
|