结算
如果说 Order -> Pay -> Refund 解决的是“用户的钱怎么进来、怎么退回去”,那么 SettlePlan -> Settlement 解决的就是“这笔订单最终怎么结给内部账户或合作方账户”。
这块能力在很多项目里往往被放到单独的财务系统里,但 oak-pay-business 已经把它纳入同一套 Oak 模型:订单付款完成之后,可以继续生成结算计划,到时间后自动结算,并把金额记入目标账户。
主要对象
SettlePlan
src/entities/SettlePlan.ts 定义了:
whenorderpricesettledAtclosedAt
状态包括:
unsettledsettledclosed
动作包括:
settleclose
Settlement
src/entities/Settlement.ts 定义了具体结算明细:
accountplanpriceoperssettledAtclosedAt
状态同样包括:
unsettledsettledclosed
动作也同样是:
settleclose
可以把它们理解成:
SettlePlan代表“这张订单什么时候结、总共结多少”Settlement代表“这次结算实际要分到哪些账户、各是多少钱”
组件
这块能力当前和前面几章不一样:oak-pay-business/src/components 里并没有额外提供 settlePlan / settlement 的专用前端组件。
这并不代表功能不完整,而是说明它当前更偏向:
- 用实体动作驱动
- 用 trigger / watcher 自动推进
- 页面层由具体业务项目自己组合
所以如果你的项目需要结算管理页,一般是在项目层基于 settlePlan、settlement 这两个实体自己拼页面,而不是直接从公共包里拿成品组件。
真实项目里的页面拆法
虽然公共包没有现成结算组件,但 haina-busi 和 taicang 已经把两种典型接法跑出来了。
1. taicang:面向前台用户的“待结算拍品页”
taicang/src/pages/frontend/settlement/web.tsx 的做法是:
- 页面层只负责登录判断和 tabs 切换
- 真正的待结算主体交给项目组件
components/spBid/settlement
也就是说,前台场景通常不是直接展示 settlePlan / settlement 明细,而是先围绕“还有哪些待结算业务对象”组织页面。
2. haina-busi:面向后台财务/运营的计划页和明细页
haina-busi 则单独做了两类项目组件:
components/squareBusiness/settlePlan/listcomponents/squareBusiness/settlement/list
它们已经把最常见的后台视角做出来了:
- 按
settled / unsettled / closed切页签 - 按账户、机房、组织等业务维度筛选
- 展示
payAt、when、settledAt、closedAt - 展示比例、分账方向、目标节点等业务字段
这很值得参考,因为它说明了公共包的真实边界:
- 状态推进与记账逻辑在公共包
- 财务管理 UI 在项目层
后台规则
SettlePlan checker
src/checkers/settlePlan.ts 是这条线最关键的第一道约束。创建时会检查两件事:
settlement.price总和必须等于settlePlan.pricesettlePlan.price不能超过订单当前可结算金额
这里的订单可结算金额,源码里实际按下面这条式子算:
order.paid - order.refunded - order.settlePlanned
这就保证了:
- 不会超额结算
- 同一订单可以拆多个结算计划,但总额不会越界
SettlePlan trigger
src/triggers/settlePlan.ts 把整条结算流程串了起来。
1. 创建后更新订单的 settlePlanned
每创建一笔 settlePlan,都会把对应订单的 settlePlanned 累加上去。
2. 执行 settle 时自动结算所有 settlement
settlePlan.settle 的 before 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 上。
因此它和订单的几个金额字段直接联动:
paidrefundedsettlePlannedsettled
实际开发里,更推荐把“什么时候结算、结算给谁”都收敛在 order 维度,而不是分散到每一笔 pay 上。
实体适配要求
这章如果只写流程,不写实体适配,很容易误导新手。真实项目里,结算实体往往都会继续扩。
haina-busi 的扩展方式
haina-busi 直接在公共实体上继续加了很多财务字段:
SettlePlan
在公共 SettlePlan 之上补了:
roomRevenuesystemRevenueplatformRevenueinstallmentscurrentInstallmentisManual
Settlement
在公共 Settlement 之上补了:
orgSettlementscaletypeaccountSplitErrorsorderoperEntitys
这说明公共实体给的是结算主骨架,复杂平台型项目通常还会补:
- 分账类型
- 分账比例
- 多级组织结算关系
- 金额误差与操作追踪
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
关键参数包括:
machineSystemIdsettlementStateopenonCancel
它本质上是“某个业务范围内的结算计划列表”。
squareBusiness/settlement/list
关键参数包括:
accountIdentitymachineSystemIdsettlementState
并且当 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。
使用建议
结算这块最推荐的做法是:
- 把结算计划和结算明细一开始就建完整
- 用
settlePlan管总额和时间,用settlement管分配结果 - 让 watcher 负责到期自动执行,让 trigger 负责账户记账
再补四条在真实项目里非常重要的注意事项:
settlement$plan.price总和必须始终等于settlePlan.price,这是公共 checker 的硬约束,不是建议。- 只要要落到账,就必须先把目标
account体系准备好,否则 trigger 在记accountOper时就没法闭环。 - 如果你准备扩
SettlePlan/Settlement,尽量像haina-busi那样“在公共实体上继续加字段”,不要破坏公共主字段和动作语义。 when不是必填,它可以代表“定时结算”,也可以完全留空,让项目自己的ship、order、tradetrigger 来决定何时结算。
如果把这些逻辑拆到项目层零散页面或财务脚本里,很快就会出现订单金额、结算计划金额和账户流水三边对不上的问题。