9.6 KiB
Context
见 proposal.md 与 specs/employee-collection-bill/spec.md。现有后台套餐订单、代理充值、企业微信审批和审计已有各自业务事实,但没有将“后台账号代客户经办后的公司应收”作为独立对象保存。现有 tb_agent_recharge_record 已有金额、支付凭证和审批实例关联;套餐订单已有 actual_paid_amount。这些来源只能提供已确定的金额和关联键,不能被新功能改写。
本设计只处理上线后事件。线下付款并非本系统支付渠道事实,企业微信审批人员以第三方记录核验,因此本地只留存申请人声明、附件、冻结快照和企业微信最终结果。
Goals / Non-Goals
Goals:
- 让账单、申请和分摊形成可并发保护、可重提、可审计的本地财务事实。
- 使企业微信是唯一终审来源,同时沿用已有可靠提交、回调和轮询恢复机制。
- 在订单/充值入账、退款和核销之间建立明确且幂等的关联。
Non-Goals:
- 不建设公司收款账户目录,不接入或改造 OCR 契约,不校验同一外部付款在不同申请间的累计分摊。
- 不回填历史业务,不允许“其他”来源手工建账,不处理平台代理 C 端资产钱包充值。
- 不以账单功能重构订单、代理充值或企业微信通用审批模块。
Decisions
1. 账单、申请、分摊分表保存
建立员工代收款账单、核销申请、核销分摊和线下收款方式字典四类事实。账单绑定唯一来源业务;申请绑定一次外部付款和一个企业微信审批实例;分摊连接申请与账单并冻结账单来源摘要。附件复用现有对象存储键/附件模式,审批快照复用既有通用审批上下文能力。
不把多笔付款、账单状态或附件塞入来源订单 JSON:来源订单既有生命周期不等于员工欠款,且一笔付款对多账单是独立关系。
2. 金额统一使用分,分摊在锁定账单中预占
账单应收、已核销、预占和申请付款金额均用 int64 分。提交/重提在同一 GORM 事务中按账单 ID 升序 FOR UPDATE 锁定,重新计算“应收金额 - 已通过分摊 - 其他审批中分摊”,再写申请与分摊。企业微信通过消费同样锁定申请和相关账单,使用审批实例唯一关联/状态条件更新保证至多入账一次。
不使用乐观展示余额或仅在回调时校验;那会让并发审批中申请超额占用同一账单。
3. 企业微信审批作为唯一状态推进器
申请提交事务只写本地申请、冻结快照、审批实例及可靠提交请求。审批回调和既有兜底查询都进入同一个幂等消费用例:通过才计入账单已核销,驳回才释放预占。提交失败或渠道未知保持在途,禁止本地财务人工改审批结果。
已驳回“重提”保留原申请主键,但新增一次审批实例及当次不可变快照;已通过分摊永不更新。这样列表可以按申请聚合,审计仍能回放每次审批。
4. 来源事件采用幂等 Outbox/消费者
线下套餐订单创建成功和代理充值审批入账完成后,在各自成功事务中写唯一来源事件或直接以唯一来源约束创建账单;选择以现有 Outbox 可用模式为准。消费者以来源类型+来源 ID 唯一约束去重。订单创建不得等待企业微信或外部付款;代理充值必须以“已通过且已入账”这一既有终态作为来源。
5. 退款只自动影响未存在已通过分摊的账单
退款处理在退款成功业务事务中查找来源账单并锁定。无已通过分摊时写冲销/关闭事实及更新金额;有已通过分摊时不动账,只写可追溯关联提示。这避免已由企业微信核验的员工欠款被退款回调静默重建或冲销。
业务动作契约
以下是本 Change 新增后台动作的已确认设计;路径遵循既有 /api/admin 路由约定,所有金额字段均为 int64 分,所有成功响应使用既有 pkg/response 包装。
收款方式字典维护
POST /employee-collection-payment-methods:仅超级管理员。请求包含code(1~64 字符、全局唯一)、name(1~100 字符)、sort(非负整数)、enabled、remark(最多 500 字符)。创建后返回字典 ID、字段值和创建时间。PUT /employee-collection-payment-methods/:id:仅超级管理员;不得修改已引用项的code,可修改名称、排序、启停和备注。不存在返回既有“资源不存在”错误;重复编码返回稳定“收款方式编码已存在”错误。DELETE /employee-collection-payment-methods/:id:仅未被申请引用的项可物理删除;已引用返回“收款方式已被引用,只能停用”。每个成功写操作记录操作者和前后快照。
账单查询与关闭
GET /employee-collection-bills:员工强制加debtor_account_id=当前账号;财务、超级管理员按既有数据范围过滤。支持来源类型、来源单号、账单状态、欠款人、客户/店铺、创建时间范围筛选和分页。每行返回账单 ID、来源摘要、欠款人快照、应收、已核销、预占、剩余、状态和创建时间。GET /employee-collection-bills/:id:在同一数据范围校验后返回账单、来源摘要、退款冲销、分摊、申请与审批历史;附件只返回既有授权下载所需的安全引用,不返回对象存储敏感内容。POST /employee-collection-bills/:id/close:仅超级管理员;请求reason必填、最长 500 字符。事务中锁定账单,存在审批中申请返回“账单存在审批中核销申请,不能关闭”;已关闭返回既有状态冲突;成功时仅作废未核销余额并写关闭审计。
核销申请创建、修改与重提
POST /employee-collection-applications:员工为本人可见账单创建,超级管理员可代办但请求必须附acting_reason(1~500 字符)。请求包含payment_method_id、paid_amount(正分)、payer_name、paid_at(带时区 RFC3339 时间)、external_transaction_no、payment_voucher_keys(1~5 个既有附件键)、remark、allocations[];每个分摊包含bill_id和正的amount。- 服务按账单 ID 升序锁定,校验字典启用、账单可见且未关闭、分摊不超过该账单
应收-已核销-其他审批中预占、分摊总额不超过paid_amount。成功返回申请 ID、状态审批中、审批实例 ID、冻结快照与各分摊;任一校验失败时不保存申请、分摊或预占。 PUT /employee-collection-applications/:id:仅申请人或代办超级管理员,且仅已驳回申请可修改;入参同创建。事务释放旧驳回版本无预占事实,重新锁定和校验账单,保存新的不可变材料快照并创建新的企业微信审批实例。已通过、审批中、已撤销/关闭状态返回状态冲突。
企业微信审批结果消费
- 企业微信回调和既有状态恢复任务均按审批实例 ID 进入同一应用用例,不提供后台“通过/驳回”接口。
- 最终通过:锁定申请及按 ID 升序的全部账单;仅当申请仍为审批中时,将每笔分摊从预占转入已核销,重新计算账单
待核销/部分核销/已核销状态,标记申请已通过,并记录审批结果。重复或乱序的同一终态不重复增加已核销金额。 - 最终驳回:仅当申请仍为审批中时释放全部预占,标记已驳回并保存审批意见;重复回调不重复释放。提交失败、回调延迟和未知结果维持在途,由既有查询恢复任务确认,不得人工改写终态。
来源建账与退款冲销
- 后台线下套餐订单成功提交后,以
order.id、operator_account_id、operator_account_type=platform和非空actual_paid_amount判定建账;来源唯一键为order:{id}。重复订单事务、可靠事件重放或消费者重试均返回同一账单,不重复建账。 - 代理线下充值仅在既有审批最终通过且钱包入账完成后,以
agent_recharge.id为唯一来源建账;线上充值、审批未通过或未完成入账不建账。 - 套餐退款成功时锁定来源账单:无已通过分摊的全额退款关闭账单;无已通过分摊的部分退款冲减应收;存在已通过分摊时只新增退款关联提示,不修改应收、已核销或员工欠款。
Risks / Trade-offs
- [企业微信回调重复、乱序或未知] → 以审批实例、申请状态和分摊状态条件更新幂等消费,复用渠道查询恢复。
- [外部付款敏感信息泄露] → 附件使用对象键和既有授权访问;日志/审计只记录脱敏摘要与业务 ID。
- [来源事件与账单创建不一致] → 在来源成功事务写可靠事件,消费者以唯一来源约束重放。
- [超额核销] → 提交、重提和审批通过均锁定账单并校验预占余额。
- [退款与核销并发] → 退款和审批消费按相同账单锁顺序串行,已通过分摊优先保留。
Migration Plan
- 新增成对迁移创建字典、账单、申请、分摊、审批快照/冲销关联所需表、唯一约束和查询索引;不修改既有迁移。
- 先部署可读新表和来源事件的兼容代码,再启用账单生产与核销入口;上线时间作为历史切割点写受控配置或迁移基准。
- 在隔离环境验证上线前订单/充值不建账、重复事件不重复建账、并发预占、通过/驳回/重提、退款联动及迁移 up/down/up。
- 回滚时先停止新入口和事件消费;已有账单事实保留,只有维护者确认未产生不可逆业务数据时才执行 down。