Files
junhong_cmp_fiber/openspec/changes/add-employee-collection-bills/design.md
2026-09-10 10:53:07 +08:00

98 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
`proposal.md``specs/employee-collection-bill/spec.md`。现有后台套餐订单、代理充值、企业微信审批和审计已有各自业务事实,但没有将“后台账号代客户经办后的公司应收”作为独立对象保存。现有 `tb_agent_recharge_record` 已有金额、支付凭证和审批实例关联;套餐订单已有 `actual_paid_amount`。这些来源只能提供已确定的金额和关联键,不能被新功能改写。
本设计只处理上线后事件。线下付款并非本系统支付渠道事实,企业微信审批人员以第三方记录核验,因此本地只留存申请人声明、附件、冻结快照和企业微信最终结果。
术语边界:员工代收款账单是后台账号代客户经办业务形成的本地暂挂欠款,不等同于来源订单、客户付款或企业微信审批单。核销申请对应一笔外部付款及一次企业微信审批实例;核销分摊是该申请对单张账单确认的本次金额,账单核销状态与申请审批状态独立保存。企业微信审批实例仅是本地业务单的外部审批渠道生命周期事实;外部交易流水号可由 OCR 预填,但必须经人工确认。线下收款方式是固定注册分类下的业务字典项,不等同于线上支付方式枚举或收款账户目录;已被引用的字典项只可停用并保留历史名称快照。
## 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`164 字符、全局唯一)、`name`1100 字符)、`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`1500 字符)。请求包含 `payment_method_id``paid_amount`(正分)、`payer_name``paid_at`(带时区 RFC3339 时间)、`external_transaction_no``payment_voucher_keys`15 个既有附件键)、`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
1. 新增成对迁移创建字典、账单、申请、分摊、审批快照/冲销关联所需表、唯一约束和查询索引;不修改既有迁移。
2. 先部署可读新表和来源事件的兼容代码,再启用账单生产与核销入口;上线时间作为历史切割点写受控配置或迁移基准。
3. 在隔离环境验证上线前订单/充值不建账、重复事件不重复建账、并发预占、通过/驳回/重提、退款联动及迁移 up/down/up。
4. 回滚时先停止新入口和事件消费;已有账单事实保留,只有维护者确认未产生不可逆业务数据时才执行 down。