Files
junhong_cmp_fiber/openspec/changes/add-employee-collection-bills/design.md
break 69b37eb89b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
docs(员工代收款): 补充审批中通过后撤销兜底语义并清理死参数
- spec.md 增补「审批中收到通过后撤销」场景与规范条文:释放该次尝试全部审批中预占、转异常终态并记录原因、保留审计、禁止自动重提
- design.md「企业微信审批结果消费」补充审批中命中该决策的兜底处理与理由(避免申请永久停在审批中且预占永久占用账单)
- query/employeecollection 删除 approvalStatusOfAttempts 恒为 true 的 withOpinion 形参、修正失真注释,行为不变

OpenSpec Change: add-employee-collection-bills
2026-09-10 18:45:43 +08:00

130 lines
19 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`。这些来源只能提供已确定的金额和关联键,不能被新功能改写。
本设计只处理上线后事件,且不新增任何运行时开关或配置来表达上线切割点:建账只允许由“本次来源成功事务”携带的来源主键触发,禁止任何按历史订单或充值扫描、补建的代码路径;来源唯一键 `order:{id}` / `recharge:{id}` 兜底幂等。新表对既有旧代码无影响,单批发布即可。
线下付款并非本系统支付渠道事实,企业微信审批人员以第三方记录核验,因此本地只留存申请人声明、附件、冻结快照和企业微信最终结果。
术语边界:员工代收款账单是后台账号代客户经办业务形成的本地暂挂欠款,不等同于来源订单、客户付款或企业微信审批单。核销申请对应一笔外部付款及一次企业微信审批实例;核销分摊是该申请对单张账单确认的本次金额,账单核销状态与申请审批状态独立保存。审批尝试记录是同一核销申请每次提交或重提对应的不可变审批材料与实例关联事实;申请行只保存最新审批实例 ID 用于展示。企业微信审批实例仅是本地业务单的外部审批渠道生命周期事实;外部交易流水号可由 OCR 预填,但必须经人工确认。线下收款方式是固定注册分类下的业务字典项,不等同于线上支付方式枚举或收款账户目录;已被引用的字典项只可停用并保留历史名称快照。
## Goals / Non-Goals
**Goals:**
- 让账单、申请和分摊形成可并发保护、可重提、可审计的本地财务事实。
- 使企业微信是唯一终审来源,同时沿用已有可靠提交、回调和轮询恢复机制。
- 在订单/充值入账、退款和核销之间建立明确且幂等的关联。
**Non-Goals:**
- 不建设公司收款账户目录,不接入或改造 OCR 契约,不校验同一外部付款在不同申请间的累计分摊。
- 不回填历史业务,不允许“其他”来源手工建账,不处理平台代理 C 端资产钱包充值。
- 不以账单功能重构订单、代理充值或企业微信通用审批模块。
- 不引入“财务”角色,也不启用 `RequirePermission` 路由鉴权体系;本期可见性只区分欠款人本人与超级管理员,财务独立可见性作为后续权限 Change 的待办。
- 不新增导出;本期只提供与账单列表同筛选、同数据范围的统计聚合。
- 不实施 AUG26-017 的线下充值材料字段,也不修改企业微信 `offline_recharge_approval` 场景业务字段。
## Decisions
### 1. 账单、申请、分摊分表保存
建立员工代收款账单、核销申请、核销分摊、审批尝试记录和线下收款方式字典事实。账单绑定唯一来源业务;申请绑定一次外部付款,并保存最新企业微信审批实例 ID每次提交或重提新增一条审批尝试记录并创建独立审批实例分摊连接申请与账单并冻结账单来源摘要。附件复用现有对象存储键/附件模式,审批提交与渠道上下文复用既有通用审批能力。
不把多笔付款、账单状态或附件塞入来源订单 JSON来源订单既有生命周期不等于员工欠款且一笔付款对多账单是独立关系。
### 2. 金额统一使用分,分摊在锁定账单中预占
账单应收、已核销、预占和申请付款金额均用 `int64` 分。提交/重提在同一 GORM 事务中按账单 ID 升序 `FOR UPDATE` 锁定,重新计算“应收金额 - 已通过分摊 - 其他审批中分摊”,再写申请与分摊。企业微信通过消费同样锁定申请和相关账单,使用审批实例唯一关联/状态条件更新保证至多入账一次。
不使用乐观展示余额或仅在回调时校验;那会让并发审批中申请超额占用同一账单。
### 3. 企业微信审批作为唯一状态推进器,重提新增审批尝试记录
申请提交事务只写本地申请、冻结快照、审批尝试记录、审批实例及可靠提交请求。审批回调和既有兜底查询都进入同一个幂等消费用例:通过才计入账单已核销,驳回才释放预占。提交失败或渠道未知保持在途,禁止本地财务人工改审批结果。
新增企业微信业务类型 `employee_collection_approval`,其 `business_id` 取本 Change 的**审批尝试记录主键**,申请行只保存最新审批实例 ID 用于展示。因此每次提交或重提都会新增一条审批尝试记录和一个审批实例,历史实例、材料与结果不被覆盖,列表按申请聚合、审计可回放每次审批。既有 `refund_approval``offline_recharge_approval` 两场景继续“一业务单一个审批实例”的语义,**不修改共享的唯一约束**。
企业微信“通过后撤销”不回滚已核销金额:申请转异常终态、保留审计、详情提示、禁止自动重提,与既有退款链路对 `revoked_after_approved` 的处理一致。
### 4. 建账在来源成功事务内直建,不新增来源事件
两类来源都在**各自成功事务内直接创建账单**,以来源唯一键去重,不新增来源 Outbox 事件类型、不引入消费者:建账只能由“本次来源成功事务”携带的来源主键触发,不存在按历史记录扫描或补建的路径。
- 后台线下套餐订单:建账判据为 `payment_method = offline``operator_account_type = platform` ∧ 订单不含赠送套餐 ∧ `actual_paid_amount > 0``actual_paid_amount ≤ 0` 不建账,避免 0 元账单立即成为已核销。建账点位于订单激活事务内、`tx.Create(order)` 之后,来源唯一键 `order:{id}`。创建时付款凭证的放宽范围与该判据**严格互补**:建账订单不再强制创建时凭证,赠送等不建账订单保持既有凭证要求。
- 代理线下预存款/主钱包充值:锚点为“平台账号发起的线下充值入账成功”这一既有事实,金额取充值记录的 `amount`,来源唯一键 `recharge:{id}`。该事实不依赖具体申请表结构,也不使用申请表未落库的新字段;既有企业微信终审通过入账与既有线下充值人工确认入账都产生该事实,因此两条入账入口完成入账时都建账。线上充值、审批未通过或未完成入账不建账。
订单创建不得等待企业微信或外部付款。重复订单事务、重复回调、重放或消费者重试都返回同一账单,不重复建账。该判据是订单建账的**唯一共享事实来源**,因此后台线下套餐订单与 H5 平台代购 offline 入口(同为平台账号操作、`actual_paid_amount > 0`)走同一判据建账,不因入口不同产生差异。
### 5. 退款只自动冲销不存在已通过或审批中分摊的账单
退款处理只在既有的**退款成功事务**内查找来源账单并锁定:接入点是当前已存在的退款成功处理链路,不实现 AUG26-006 的退款审批、状态机或渠道退款。
全额与部分以“**本次退款成功金额 vs 账单应收**”判定,账单应收即来源订单的 `actual_paid_amount`
- 本次退款成功金额等于或超过账单应收且账单不存在已通过分摊 → 关闭账单,原因记为来源订单全额退款;
- 本次退款成功金额小于账单应收且账单不存在已通过分摊 → 按退款金额冲减账单应收;
- 账单存在任一**已通过分摊**或任一**审批中(预占)分摊** → 不自动冲销,只在账单详情写退款关联提示。
把审批中分摊与已通过分摊同等对待,是因为预占余额可能大于冲减后的剩余应收,从而在审批通过后产生 `已核销 > 应收`。冲销写入必须自带唯一幂等事实(`bill_id + refund_id` 唯一),不得依赖退款成功事务的 `changed` 标志——该事务在重复投递时仍会执行资金写入。
本期冲销**只由企业微信审批通过的退款成功事务触发**。既有 legacy 人工退款审批通过入口(`internal/service/refund/service.go``Approve`,受默认开启的 `Approval.LegacyRefundManualEnabled` 开关控制)不在本期接入范围:该入口对携带审批实例的退款直接拒绝,仅历史上无审批实例的存量待审批退款可达,本 Change 上线后新产生的来源订单退款不可达该路径。这是**已登记的缺口**,若后续要求该入口一致冲销,应单独作为后续 Change 处理。
## 业务动作契约
以下是本 Change 新增后台动作的已确认设计;路径遵循既有 `/api/admin` 路由约定,所有金额字段均为 `int64` 分,所有成功响应使用既有 `pkg/response` 包装。
### 收款方式字典维护
- `GET /employee-collection-payment-methods`:登录后台账号均可读取。超级管理员返回全部字典项并支持按启停状态与编码/名称关键字筛选;其他后台账号仅返回启用项,用于新核销申请选择收款方式。分页返回 ID、稳定编码、名称、启用状态、排序、备注与创建/更新时间。
- `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`:仅未被申请引用的项可删除;已引用返回“收款方式已被引用,只能停用”。每个成功写操作记录操作者和前后快照。
- 未引用项的删除以 `deleted_at` 软删除实现(`gorm.Model.DeletedAt`),对外语义与物理删除一致:列表与详情不可见、编码可被后续创建复用;保留软删除只为保住审计快照的可追溯性,不构成对外行为差异。
### 账单查询与关闭
- `GET /employee-collection-bills`:欠款人本人强制加 `debtor_account_id=当前账号`;超级管理员查询全部。支持来源类型、来源单号、账单状态、欠款人、客户/店铺、创建时间范围筛选和分页。每行返回账单 ID、来源摘要、欠款人快照、应收、已核销、预占、剩余、状态和创建时间。
- `GET /employee-collection-bills/statistics`:与列表相同的筛选条件与数据范围,返回应收、已核销、未核销金额合计和待处理账单数。
- `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`。申请人按所选账单确定:本人办理时固定为当前账号且所选账单必须全部属于该账号;超级管理员代办时申请人为所选账单的唯一欠款人,所选账单必须同属该欠款人,代办人与原因分别记录在 `acting_operator_id``acting_reason`
- 服务按账单 ID 升序锁定,校验字典启用、账单可见且未关闭、分摊不超过该账单 `应收-已核销-其他审批中预占`、分摊总额不超过 `paid_amount`。成功时新增一条不可变审批尝试记录(冻结当次收款方式、外部付款、附件、备注与分摊快照)并创建对应企业微信审批实例,申请只保存最新审批实例 ID返回申请 ID、状态 `审批中`、审批实例 ID、冻结快照与各分摊。任一校验失败时不保存申请、分摊或预占。
- 分摊金额由申请人在提交前填写:界面可按账单产生时间从早到晚用本次付款金额预填、最后一张填入剩余金额,但服务端只接受客户端提交的 `allocations[]` 并按其校验,越界分摊一律拒绝;服务端不重新计算或改写申请人提交的分摊金额。
- `PUT /employee-collection-applications/:id`:仅申请人或代办超级管理员,且仅已驳回申请可修改;入参同创建。事务释放旧驳回版本无预占事实,重新锁定和校验账单,新增一条审批尝试记录与一个新的企业微信审批实例,历史尝试、材料与结果不被覆盖。已通过、审批中、已撤销/关闭状态返回状态冲突。
### 企业微信审批结果消费
- 企业微信回调和既有状态恢复任务均按审批实例 ID 进入同一应用用例,不提供后台“通过/驳回”接口。
- 最终通过:锁定申请及按 ID 升序的全部账单;仅当申请仍为审批中时,将每笔分摊从预占转入已核销,重新计算账单 `待核销/部分核销/已核销` 状态,标记申请已通过,并记录审批结果。重复或乱序的同一终态不重复增加已核销金额。
- 最终驳回:仅当申请仍为审批中时释放全部预占,标记已驳回并保存审批意见;重复回调不重复释放。提交失败、回调延迟和未知结果维持在途,由既有查询恢复任务确认,不得人工改写终态。
- 通过后撤销:已通过后收到企业微信撤销时**不回滚已核销金额**;申请转异常终态、保留审计、账单详情提示来源申请异常,并禁止自动重提。若该撤销结果到达时本地申请仍为审批中(回调乱序或通过结果未被消费),同样按异常终态处理并释放本次尝试的全部审批中预占——否则申请会永久停在审批中、预占永久占用账单,既不可关闭也不可重提。
### 来源建账与退款冲销
- 后台线下套餐订单在成功事务内、`tx.Create(order)` 之后按决策 4 的判据建账:以实际操作的平台账号为欠款人、`actual_paid_amount` 为应收、`order:{id}` 为来源唯一键。重复订单事务、重放或重试均返回同一账单;不存在按历史订单扫描建账的路径;创建时付款凭证的放宽与该判据严格互补。
- 代理线下充值在“平台账号发起的线下充值入账成功”事实达成时、于同一入账事务内建账,以充值记录 `amount` 为应收、`recharge:{id}` 为来源唯一键,欠款人为发起充值的平台账号;既有企业微信终审通过入账与既有线下充值人工确认入账两条入口都建账,线上充值、审批未通过或未完成入账不建账;不存在按历史充值扫描建账的路径。
- 套餐退款成功时按决策 5 的口径锁定来源账单并冲销或仅提示;冲销以 `bill_id + refund_id` 唯一事实幂等,重复投递不二次冲减;接入点只在既有退款成功处理事务内,不实现 AUG26-006 的退款审批、状态机或渠道退款。
## Risks / Trade-offs
- [企业微信回调重复、乱序、未知或通过后撤销] → 以审批实例、审批尝试记录、申请状态和分摊状态条件更新幂等消费,复用渠道查询恢复;通过后撤销不回滚已核销,转异常终态并禁止自动重提。
- [外部付款敏感信息泄露] → 附件只保存既有对象键引用并经既有预签名下载访问,不承诺资源级鉴权;日志、审计和错误只记录脱敏摘要与业务 ID。
- [建账与来源事务不一致] → 在来源成功事务内直建账单,来源唯一键 `order:{id}` / `recharge:{id}` 兜底;不新增来源事件与消费者,也不存在历史扫描路径。
- [既有 legacy 线下充值人工确认入账路径] → 该路径(默认开启的兼容入口)同样产生“入账成功”事实并按本设计建账,避免员工欠款漏记;本 Change 不改该开关、不改该入口,开关是否关闭属运维独立决策。
- [超额核销] → 提交、重提和审批通过均锁定账单并校验预占余额。
- [退款与核销并发] → 退款冲销与审批消费按相同账单锁顺序串行;存在已通过或审批中分摊时一律不自动冲销,只写退款关联提示。
- [权限口径缺失] → 既有系统无“财务”角色且未启用 `RequirePermission` 路由鉴权,本期只区分欠款人本人与超级管理员;财务独立可见性留给后续权限 Change。
- [重提与审批核心唯一约束] → 新业务类型以审批尝试记录主键作为 `business_id`,不改共享唯一约束,既有两个审批场景语义不变。
- [财务审计时间线未纳入新业务类型] → `internal/query/audit/finance.go``expandApprovals``approvalBusinessRefs``business_type` 分支、以及资金资源类型清单均未包含 `employee_collection_approval`财务调查视图不会为该场景展开业务关联不报错仅缺失跳转。spec 未要求,**不属本期范围**,登记为后续 Change 待办,不在本次改动内。
## Migration Plan
1. 新增成对迁移(`.up.sql`/`.down.sql`)创建字典、账单、申请、分摊、审批尝试记录与退款冲销关联所需表、唯一约束和查询索引;不修改既有迁移,也不预占迁移编号——实施开始前按当时 `migrations/` 目录的最大编号顺延。
2. 迁移扩展 `tb_wecom_approval_scene``business_type` CHECK 以纳入 `employee_collection_approval`(同样以新迁移扩展,不修改既有迁移)。施工时列全四处注册点:业务类型常量、`validApprovalBusinessType``sceneBusinessFields`、数据库 CHECK、`cmd/worker/main.go` 的审批决策消费者注册。
3. 单批发布即可:新表对既有旧代码无影响,也不存在需要开关或基准数据表达的上线切割点;建账只由部署后发生的来源成功事务触发。
4. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture禁止重置整库仅连接、迁移或实际行为失败时才阻塞对应场景。验证项不存在按历史记录扫描或补建的路径、重复消费来源不重复建账、并发预占、通过/驳回/重提/通过后撤销、退款三类联动、迁移 up/down/up。
5. 回滚时先停止新入口;已有账单事实保留,只有维护者确认未产生不可逆业务数据时才执行 down。