Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

结算

如果说 Order -> Pay -> Refund 解决的是“用户的钱怎么进来、怎么退回去”,那么 SettlePlan -> Settlement 解决的就是“这笔订单最终怎么结给内部账户或合作方账户”。

这块能力在很多项目里往往被放到单独的财务系统里,但 oak-pay-business 已经把它纳入同一套 Oak 模型:订单付款完成之后,可以继续生成结算计划,到时间后自动结算,并把金额记入目标账户。

主要对象

SettlePlan

src/entities/SettlePlan.ts 定义了:

  • when
  • order
  • price
  • settledAt
  • closedAt

状态包括:

  • unsettled
  • settled
  • closed

动作包括:

  • settle
  • close

Settlement

src/entities/Settlement.ts 定义了具体结算明细:

  • account
  • plan
  • price
  • opers
  • settledAt
  • closedAt

状态同样包括:

  • unsettled
  • settled
  • closed

动作也同样是:

  • settle
  • close

可以把它们理解成:

  • SettlePlan 代表“这张订单什么时候结、总共结多少”
  • Settlement 代表“这次结算实际要分到哪些账户、各是多少钱”

组件

这块能力当前和前面几章不一样:oak-pay-business/src/components 里并没有额外提供 settlePlan / settlement 的专用前端组件。

这并不代表功能不完整,而是说明它当前更偏向:

  • 用实体动作驱动
  • 用 trigger / watcher 自动推进
  • 页面层由具体业务项目自己组合

所以如果你的项目需要结算管理页,一般是在项目层基于 settlePlansettlement 这两个实体自己拼页面,而不是直接从公共包里拿成品组件。

真实项目里的页面拆法

虽然公共包没有现成结算组件,但 haina-busitaicang 已经把两种典型接法跑出来了。

1. taicang:面向前台用户的“待结算拍品页”

taicang/src/pages/frontend/settlement/web.tsx 的做法是:

  • 页面层只负责登录判断和 tabs 切换
  • 真正的待结算主体交给项目组件 components/spBid/settlement

也就是说,前台场景通常不是直接展示 settlePlan / settlement 明细,而是先围绕“还有哪些待结算业务对象”组织页面。

2. haina-busi:面向后台财务/运营的计划页和明细页

haina-busi 则单独做了两类项目组件:

  • components/squareBusiness/settlePlan/list
  • components/squareBusiness/settlement/list

它们已经把最常见的后台视角做出来了:

  • settled / unsettled / closed 切页签
  • 按账户、机房、组织等业务维度筛选
  • 展示 payAtwhensettledAtclosedAt
  • 展示比例、分账方向、目标节点等业务字段

这很值得参考,因为它说明了公共包的真实边界:

  • 状态推进与记账逻辑在公共包
  • 财务管理 UI 在项目层

后台规则

SettlePlan checker

src/checkers/settlePlan.ts 是这条线最关键的第一道约束。创建时会检查两件事:

  1. settlement.price 总和必须等于 settlePlan.price
  2. settlePlan.price 不能超过订单当前可结算金额

这里的订单可结算金额,源码里实际按下面这条式子算:

  • order.paid - order.refunded - order.settlePlanned

这就保证了:

  • 不会超额结算
  • 同一订单可以拆多个结算计划,但总额不会越界

SettlePlan trigger

src/triggers/settlePlan.ts 把整条结算流程串了起来。

1. 创建后更新订单的 settlePlanned

每创建一笔 settlePlan,都会把对应订单的 settlePlanned 累加上去。

2. 执行 settle 时自动结算所有 settlement

settlePlan.settlebefore trigger 会遍历所有未结算的 settlement$plan,对每条结算明细执行:

  • settlement.settle

同时创建关联的 accountOper,把金额记入目标账户。

3. settle 后更新订单的 settled

结算计划成功执行后,会把订单的 settled 累加上去。

4. 执行 close 时自动关闭所有结算明细

如果结算计划被关闭,关联的未结算 settlement 也会一起执行 close

5. close 后回退订单的 settlePlanned

关闭结算计划后,订单上预留的 settlePlanned 也会被减回去。

SettlePlan watcher

src/watchers/settlePlan.ts 负责自动执行到期结算。

当前规则是:

  • settlePlan.iState === 'unsettled'
  • when <= now

就自动执行:

  • settlePlan.settle

也就是说,项目层只要创建好计划并设置时间,后面到点后的执行可以继续交给后台 watcher。

when 可以不填,交给项目动作决定何时结算

这点很容易被忽略。when 虽然是结算计划里最显眼的字段,但它不是必须总要有值。

taicang/src/triggers/ship.ts 就给了一个非常典型的例子:

  • 当订单关联的 ship 全部确认收货后
  • 只对 when 不存在的 settlePlan 执行 settle

也就是说,项目完全可以把结算计划分成两类:

  • when 的,交给公共 watcher 定时结算
  • when 的,由项目自己的业务 trigger 在某个动作点触发结算

这个模式对“收货后结算”“验收后结算”特别有用。

和订单的关系

结算这块最容易理解错的地方是:settlePlan 并不是附着在 pay 上,而是附着在 order 上。

因此它和订单的几个金额字段直接联动:

  • paid
  • refunded
  • settlePlanned
  • settled

实际开发里,更推荐把“什么时候结算、结算给谁”都收敛在 order 维度,而不是分散到每一笔 pay 上。

实体适配要求

这章如果只写流程,不写实体适配,很容易误导新手。真实项目里,结算实体往往都会继续扩。

haina-busi 的扩展方式

haina-busi 直接在公共实体上继续加了很多财务字段:

SettlePlan

在公共 SettlePlan 之上补了:

  • roomRevenue
  • systemRevenue
  • platformRevenue
  • installments
  • currentInstallment
  • isManual

Settlement

在公共 Settlement 之上补了:

  • orgSettlement
  • scale
  • type
  • accountSplitErrors
  • order
  • operEntitys

这说明公共实体给的是结算主骨架,复杂平台型项目通常还会补:

  • 分账类型
  • 分账比例
  • 多级组织结算关系
  • 金额误差与操作追踪

taicang 的扩展方式

taicang 就轻很多。它至少把:

  • Settlement.order

这条关系补进去了,方便前台和后台都能直接沿订单维度取结算数据。

因此项目层在适配结算实体时,至少要先想清楚两件事:

  • 你是否需要在 settlement 上直接回查 order
  • 你是否需要额外的分账类型、比例、组织归属字段

项目中如何接入

1. 先确定结算账户模型

Settlement 最终会把钱记到 account 上,所以项目在使用前,应该先明确:

  • 哪些业务对象有 account
  • 订单应该结给哪个 account

2. 创建结算计划时把明细一起带上

最推荐的写法是创建 settlePlan 时,就同时创建 settlement$plan,让 checker 当场验证总额。

2.1 taicang 的真实分账构造方式

taicang/src/utils/order.ts 已经给了一个非常完整的公共实体用法样例。它创建一条 settlePlan 时,会同时生成三条 settlement$plan

  • 商家的佣金部分
  • 系统抽成部分
  • 商家拿到的剩余部分

也就是说,项目层完全可以把“怎么拆金额”这件事收敛到一个 util 里,然后继续让公共 checker / trigger / watcher 去兜底状态推进。

这种分层很推荐:

  • 项目 util 决定“金额怎么拆”
  • 公共包决定“计划怎么校验、怎么记账、怎么推进”

3. 到期自动结算交给 watcher

如果结算时间是确定的,直接设置 when 即可,让 watchers/settlePlan.ts 自动推进。

4. 页面层自己组合

由于公共包目前没有专门的结算页组件,项目层一般会自己做:

  • settlePlan 列表
  • settlement 明细查看
  • 手工 settle / close 按钮

但后台状态推进逻辑最好继续复用公共包,不要自己重写。

4.1 haina-busi 自定义列表组件里常见的参数

如果你要参考项目层怎么拼 UI,haina-busi 这两组组件已经很有代表性:

squareBusiness/settlePlan/list

关键参数包括:

  • machineSystemId
  • settlementState
  • open
  • onCancel

它本质上是“某个业务范围内的结算计划列表”。

squareBusiness/settlement/list

关键参数包括:

  • accountId
  • entity
  • machineSystemId
  • settlementState

并且当 entity='organization' 时,它还额外区分:

  • organizationType='system' | 'room'

也就是说,项目层如果要做财务后台,通常不是简单列 settlement,而是要按账户、组织层级、业务域再包一层组件。

使用示例

1. 创建结算计划与结算明细

await context.operate('settlePlan', {
  id: await generateNewIdAsync(),
  action: 'create',
  data: {
    id: await generateNewIdAsync(),
    orderId,
    when: Date.now() + 24 * 60 * 60 * 1000,
    price: 10000,
    settlement$plan: [
      {
        id: await generateNewIdAsync(),
        action: 'create',
        data: {
          id: await generateNewIdAsync(),
          accountId: sellerAccountId,
          price: 7000,
        },
      },
      {
        id: await generateNewIdAsync(),
        action: 'create',
        data: {
          id: await generateNewIdAsync(),
          accountId: partnerAccountId,
          price: 3000,
        },
      },
    ],
  },
}, {});

这里 7000 + 3000 === 10000,否则 checkers/settlePlan.ts 会直接拒绝这次创建。

2. 手工执行结算

await context.operate('settlePlan', {
  id: await generateNewIdAsync(),
  action: 'settle',
  data: {},
  filter: {
    id: settlePlanId,
  },
}, {});

执行后,关联 settlement 会一起变成 settled,并且目标账户会收到对应的 accountOper

使用建议

结算这块最推荐的做法是:

  1. 把结算计划和结算明细一开始就建完整
  2. settlePlan 管总额和时间,用 settlement 管分配结果
  3. 让 watcher 负责到期自动执行,让 trigger 负责账户记账

再补四条在真实项目里非常重要的注意事项:

  • settlement$plan.price 总和必须始终等于 settlePlan.price,这是公共 checker 的硬约束,不是建议。
  • 只要要落到账,就必须先把目标 account 体系准备好,否则 trigger 在记 accountOper 时就没法闭环。
  • 如果你准备扩 SettlePlan / Settlement,尽量像 haina-busi 那样“在公共实体上继续加字段”,不要破坏公共主字段和动作语义。
  • when 不是必填,它可以代表“定时结算”,也可以完全留空,让项目自己的 shipordertrade trigger 来决定何时结算。

如果把这些逻辑拆到项目层零散页面或财务脚本里,很快就会出现订单金额、结算计划金额和账户流水三边对不上的问题。