借款对账 SR-A/B:补证恢复与复核后释放(09-17-company-loan-principal-repayment)

保留原银行终态及依据,以恢复轮次绑定材料、候选和独立性;最终取消重验并原子消费批准、释放占用和记录审计。新增增量迁移、HTTP 回归及数据库保护。

验证:78 个迁移、HTTP 72 组、既有浏览器 7 组、数据库约束 9 组通过;7 个变异均被抓住。服务端首跑 4470 通过、6 失败、1 跳过,补齐 BIFF8 测试依赖后相关 146 项通过,未重跑完整套件。

限制:inline 主会话自查不等于独立复核,独立复核待完成;界面未完成,不能单独部署;真实回单待验。新恢复界面留待 S8,S4 另批,整体借款功能尚未完成验收。本次仅本地提交,不推送、不部署、不查询生产。
这个提交包含在:
2026-10-07 20:07:57 +08:00
父节点 14aee82d5c
当前提交 2e00d30fd0
共修改 15 个文件,包含 960 行新增和 9 行删除
@@ -1120,6 +1120,10 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
- Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or independently reviewed, current and exactly matching failed-bank evidence may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state. - Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or independently reviewed, current and exactly matching failed-bank evidence may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state.
- Repayment BIFF8 uses encrypted account-name snapshot and purpose 还款/归还借款本金; receipt matching/manual review explicitly supports loan source and shares the reimbursement claim/alias namespace. Real bank/OCR acceptance is not established by synthetic recognition tests. - Repayment BIFF8 uses encrypted account-name snapshot and purpose 还款/归还借款本金; receipt matching/manual review explicitly supports loan source and shares the reimbursement claim/alias namespace. Real bank/OCR acceptance is not established by synthetic recognition tests.
- Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations, not the full ledger. - Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations, not the full ledger.
- SR recovery of a still-reserved `confirmed_failed`/`confirmed_not_paid` batch never rewrites the bank outcome or reopens export/payment. Once any recovery round exists, final cancellation must consume the latest valid approval; rejected, withdrawn or superseded rounds must not fall back to the original cancellation path. Consumption, allocation release, batch cancellation and audit share the transaction; deferred SQL checks protect the release/consumption pairing.
- Recovery snapshots bind material revisions, submission/fact/receipt identities, verification versions, independent participants and the current same-amount candidate set. Material writers (including OCR persistence) serialize on the batch; candidate formation also locks the lender. A local batch revision alone cannot detect another batch becoming a candidate. Validate at submission, approval and final cancellation, with physical document digest checks before release.
- JSONB can reorder object keys. Compare snapshots using recursive key-canonicalized hashes, retaining array order, rather than hashing raw `JSON.stringify` results before and after storage. Keep snapshot payloads out of public response mapping. New evidence for the same bank transaction still requires new review.
- Verify recovery through `RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs` with the locked BIFF8 Python environment: real HTTP/evidence/OCR, stale-round repair, no fallback after withdrawal, atomic rollback and deterministic interleavings with evidence, submissions, OCR and candidate formation. Existing browser checks do not prove the deferred S8 recovery UI; inline self-review is not independent review.
- ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests. - ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests.
@@ -1,3 +1,63 @@
## 2026-10-07 补证后恢复方案(SR,第 1 版,用户已批准本地实施)
本方案先完成设计,随后用户明确回复“批准”,授权 SR-A、SR-B 本地实施与验证;SR-C 随 S8,提交、推送与部署未授权。S3 已本地提交 `14aee82d`;下文历史设计及实施记录保留其原时点。本方案补充 3.2 的释放流程,不把普通补证归入 S6 的晚到成功。
### SR1 问题与边界
源码依据:`loanBankTerminalFacts.js` 的 `proposeTerminalFact`、`confirmNotPaid` 只接受 `unknown`;作废证据会取代关联事实。`companyLoanPayments.js` 的 `cancelLoanRepayment` 重验当前证据;`guard_loan_repayment_history` 禁止回写既有终态及其依据。因此,已确认未付款但未释放时,旧证据失效后即使取得新的有效终态证据,也无法完成重新登记与释放。
适用:批次仍为 `reviewed`/`failed`,原银行结论为 `confirmed_not_paid` 或 `confirmed_failed`,所有本金分配仍为 `reserved`。包括补换银行书面证据、同一交易换回单或重新识别、补登遗漏提交、候选批次变化导致归属复核失效。正常 `unknown` 仍走 S3;原有结论与依据仍有效时沿原取消流程,不要求额外恢复复核。
不适用:已取消释放、已核销、任一分配不再占用、已出现本笔成功事实。成功事实留待 S6;疑似成功或无法识别时可记录补证需求,但不得通过释放复核,继续占用,不把作废当作否定银行事实。处理中回单只能由同一指令的有效终态事实了结。本期不开放 Q6 人工未提交恢复、不降低 S0 存量核实或 S3 证据门槛。真实已付后退款、冲正、退汇仍不等于未付款。
### SR2 历史结论与当前释放资格分开
- 原 `bank_result_state`、`bank_result_ref`、审批与分配记录不回写,不把终态改回 `unknown`;新增恢复记录引用原结论及此前恢复轮次。补证不说明原未付款判断必然错误,页面表述为“原结论保留,释放依据待补充”。
- 新增恢复轮次(拟表 `loan_release_recoveries`)与审计事件。每轮不可改的内容包括批次、原结论/依据、前轮、发起人、原因;送审时冻结的清单、事实、证据与候选快照另形成不可改的送审记录。
- 轮次状态:`collecting`(补证中)→ `proposed`(待复核)→ `approved`/`rejected`;经办可撤回未批准轮次为 `withdrawn`。资料变化可使待复核或已批准轮次为 `superseded`;成功释放为 `consumed`。受控状态与决定事件同事务写入;不可删除,不可修改既有送审字段、复核意见或把已关闭轮次复活。
- 每批次至多一轮补证中、待复核或已批准记录。需再次恢复时新建轮次;过期轮次的取代与新轮次创建同事务完成。请求键保证重试不生成重复轮次。
- **一旦发起过恢复,最终释放只能依据最新一轮有效批准**。驳回、撤回、资料变回原样、旧批准仍在数据库中,都不能重新启用原结论的释放权限。批准本身不释放额度,仍由财务执行最终取消。
- 不生成付款批次或补记付款,不恢复导出、执行付款、已释放占用;本金在补证、送审、复核期间都不变。S5 的出借人冻结原因如已存在,不能被本流程关闭。
### SR3 补证、送审与独立复核
1. 当前持 `loan.manage` 的财务发起,说明失效原因;服务端验证适用范围并记录原结论。不得用管理员权限跳过。
2. 在有效恢复轮次中,允许终态批次登记新终态事实;只对该入口放开原 `unknown` 限制。证据上传、作废、提交清单登记与复核仍沿 S1/S3 的事实规则;失效事实按既有规则取代,不覆盖旧事实。新登记的事实逐条独立复核。
3. 补登提交后必须重做完整清单复核,并为每次有效提交举证。撤回登记仅是材料纠错,不代表银行撤销;新增再撤回也算清单变化。不能通过撤回真实提交消除已知银行事实。每条导出仍须关联已终止提交,历史汇总继续阻断;没有提交的导出留给 S4。
4. `confirmed_failed` 的恢复也升级为 S3 的逐提交终态证据集合,不能重写原失败核验 ID。原证据仍可读且有效时可引用;换回单、重新识别后必须引用新核验记录。失败归属仍按提交及当前全部同额候选独立复核。
5. 财务送审时,服务端重跑 S3 全部释放条件并冻结完整证据快照,不接受客户端自行宣称“完整”或自报有效版本。
6. 当前合格的经理/总经理独立复核。排除本轮发起/送审人、所有相关证据上传人、回单人工录入人、终态事实登记人,以及本批次制单、提交、出借员工、导出、银行提交登记与清单确认经办人。取人员关系时包含旧记录、作废与撤回记录,不能借换材料消除经办身份。原审批人可参与,但必须满足当前资格与上述独立性;不因其是原审批人而自动放行。
7. 批准时重新取当前材料和候选,比较快照并重跑全部条件;驳回或证据不足继续占用。原审批人和出借员工的在职/角色、主数据当前启用状态不重验,按历史审批与原冻结快照核对;当前操作人始终核验当前权限。
8. 最终取消必须引用这轮批准;核对当前操作人取消权限,再次重验快照、释放条件与分配状态。释放、批次取消、轮次消费和审计一次事务提交,任一步失败全部回滚。已完成的同请求重试返回原结果,不重复释放。
沿用 S3:逐提交覆盖、逐导出关联、提交清单有效、终态证据有效、失败交易唯一性、必要的失败归属、无未解决成功/处理中事实、无历史汇总、距最后导出满内部等待期。对账单、截图、等待期或两个人确认均不能单独放行。未付款的新证据不要求再机械等待 3 天,但仍必须满足最后一次真实导出的等待期。编号对应关系仍待 S0,不靠人工照抄回单号补足银行证明。
### SR4 快照失效与并发
送审快照包含:原结论与依据;原付款快照标识;全部导出;全部提交(含撤回)及清单复核记录;逐提交终态事实与复核记录;银行证据 ID、摘要和作废状态;全部付款回单的 ID、摘要、OCR 版本、核验记录、人工录入/复核记录及作废状态;归属批准及全部候选集合;分配 ID/金额/占用状态。证据只要改变,旧批准就不能沿用,即使银行流水号相同。
使用服务端单调递增的材料修订号并保存上述快照;材料新增、作废、重识别、人工录入及材料复核均推进修订号,防止“改完又改回”复活旧批准。恢复轮次自身的送审、批准和消费决定不改变材料修订号,避免批准使自己失效。候选变化同时重查实际集合,不只依赖目标批次自己的修订号。证据送审与批准之后的缺失、失效、存储读取错误或摘要不符,均不得放行。
锁与检查须覆盖所有写入者,而非仅恢复接口:同批次最终释放与证据作废、付款回单上传/核验落库、提交登记/撤回、清单/事实/归属复核串行;同出借人的候选形成与候选检查也串行。沿既有锁相对顺序:批次 → 回单交易 advisory lock(需用时)→ 出借人 → 按 ID 排序借款行,不引入反向加锁。编码前核对所有受影响入口及后台 OCR 写入的实际锁序;多批次场景不在持有出借人锁后反向锁其他批次。
- 变化先发生:旧送审/批准失效,取消拒绝,本金和占用不变;取代旧轮次后补证重新送审。
- 最终释放先发生:只消费一次批准并释放一次;之后证据不允许改写已关闭记录。后来银行事实须走独立对账流程,本设计不能证明未来不会出现成功,不声称串行化消除晚到风险。
- 独立性与权限在送审/批准及最终动作所需环节重查;经办人集合变化使旧送审失效。旧逐事实复核人事后离职本身不抹掉当时有效的批准,新的恢复复核人必须当前合格。
### SR5 数据、接口与页面安排(设计约定;实现状态见 implement)
预计新增一个增量迁移:恢复轮次/不可变送审与决定记录、材料修订号、唯一索引及历史保护约束;不得放宽原银行终态或已释放分配的保护。存量不自动补造恢复批准、不自动释放;默认未发起恢复,保留 S3 原门禁。回滚前若已有恢复记录,不能退回只按旧结论释放的代码;停止发布并保留数据,另审兼容方案。
拟接口位于 `/payment-batches/:id/loan-release-recoveries`:发起、送审、独立复核、撤回、查询。沿现有附件/事实/清单接口补证,最终仍走批次取消接口并传恢复轮次与幂等请求标识。终态材料写入口须验证当前轮次,不提供通用“重开批次”。拟新增原因类别:恢复必需、证据已变化、仍有银行事实未解决、无合格复核人;复用已有原因码优先,新增时同步前后端错误码合同。
S8 还款详情增加“补充释放依据”:并列显示原结论与当前补证/复核/可释放状态、逐提交缺口、失效原因、操作者与证据查看入口。财务与复核人动作分开,服务器仍强制鉴权;详情不能把“批准”显示成“已释放”。PC/手机使用同一组件并实测全流程,敏感字段默认脱敏。界面与对应后端齐备、完成 S0/真实样本/整体验收前不得部署启用。
### SR6 验收与后续决策
对应 PRD AC17–AC19,覆盖两种原终态、重复补证、重扫同一流水号、补登第二次提交、多次送审、候选变化、自审/离职、事务回滚、并发交错及禁止重付。关键资金检查做变异测试;必须从真实 HTTP 的上传/核验与复核入口走到取消释放,不能直接调用释放函数代替。只替换云 OCR 响应,使用隔离 PostgreSQL 与合成材料。
批准的核心选择是“原终态不变,新增恢复轮次决定释放资格”;它覆盖 S3 后的补证恢复,不含 S4–S8 的实施。当前代码及验证进展见 implement 顶部;inline 模式不得将主会话自查写成独立复核。
## 2026-09-17 实施细化 ## 2026-09-17 实施细化
## 2026-10-06 失败回单归属与银行结果对账方案(第 3 版,待用户审阅,未实施) ## 2026-10-06 失败回单归属与银行结果对账方案(第 3 版,待用户审阅,未实施)
@@ -1,3 +1,63 @@
## 2026-10-07 SR-A / SR-B 收尾与提交授权
用户明确批准“先收尾 SR-A/B,本地提交;S4 另批”。本次将下方 15 项实现、测试及文档作为一个本地提交收口,沿用已完成的运行时验证,收尾只更新记录并重验任务治理及差异格式。未推送、未部署、未查询生产。当前 inline 模式下独立复核仍待完成;界面留在 S8,真实回单待验,不能单独部署启用。整体任务保持进行中,不归档;以下“未提交”和 HEAD 描述为实施验证时点的记录。
## 2026-10-07 SR-A / SR-B 本地实施(用户批准;未提交、未推送、未部署)
- [x] 新增恢复轮次、不可变送审/复核字段、决定事件、材料修订号及数据库保护(增量迁移 `20261008020000_loan_release_recoveries`)。既有银行终态与依据不改,已释放/已结清分配保护不放宽。
- [x] 新增发起、送审、独立复核、撤回、状态查询接口;终态事实登记只在最新轮次为补证中时开放。`confirmed_failed` 的恢复使用完整逐提交证据,不覆盖原失败核验引用。
- [x] 最终取消消费最新批准;发起过恢复后不回退原取消路径。材料/候选变更拒绝,失效轮次可取代重提;重复取消请求只返回原结果,不重复释放。
- [x] 数据库材料触发器覆盖导出、提交、清单、终态证据/事实、归属、回单、核验和人工录入,后台 OCR 也推进修订号;候选形成锁出借人,取消与对账接口遵守同一锁顺序。
- [x] 新恢复接口只返回业务标识和状态;存储快照使用标识及摘要,不存解密账号。数据库 JSONB 会调整键序,摘要实现已改为递归按键排序;真实数据库往返测试已覆盖。
- [x] 全流程 HTTP 用例新增独立 fixture,涵盖两种原终态、同一交易换回单、待定事实了结、补登提交、自审、离职、过期重提、物理文件摘要、等待期、回滚与幂等,以及最终释放对证据/提交/OCR落库/其他候选的两种确定性交错。
- [x] 主会话完成本轮差异的只读检查;7 个变异在隔离源码副本中全部被测试抓住,未改工作区被测源码。最后迁移/约束加固后,重新执行含浏览器的隔离回归,78 迁移、HTTP 72、浏览器 7、数据库约束 9 组全部通过,容器清理 PASS。
最终验证:78 个迁移、HTTP 72 组、既有浏览器 7 组、数据库约束 9 组通过(均为隔离容器、合成数据);UI 规则、错误码合同、敏感扫描、前端类型检查及构建通过。服务端全量首跑 4470 通过、6 失败、1 跳过;6 个失败均为 BIFF8 Python 依赖缺失,按仓库锁定 requirements 在临时 venv 补齐后,涉及两个文件的 146 项全部通过,合并覆盖为 4476 项通过、1 项原有跳过,不声称重新跑过一遍完整套件。
变异证据(7/7):移除恢复轮次门禁、旧结论取消旁路拦截、快照新鲜度、恢复发起人独立性、完整释放条件、物理文件摘要、取消重试请求身份。快照/完整条件/物理摘要/轮次门禁/重试身份移除后出现本应拒绝却返回 200 的响应;旧结论旁路与自审移除后,数据库仍阻止非法操作,但返回 500 而非约定的 409/403,反例抓住了错误响应,**不能表述为这两处变异已经绕过数据库并错误释放**。测试结束校验被变异的工作区源码摘要未变,副本和各次隔离容器已清理。
数据库加固还覆盖:新增轮次的初始字段必须为空、序号/前轮与原银行依据一致;修订号不能回退或删除;送审快照不能在取代时顺便篡改;直接释放但未消费最新恢复批准,提交事务会失败。主会话检查范围为恢复状态机、六个接口、取消消费者、材料触发器、独立性与快照、物理证据读取、迁移和前后端错误码;不替代独立复核。
复现命令(`PAYMENT_ORDER_PYTHON_BIN` 指向按 `server/requirements/payment-order-biff8.txt` 安装的测试 venv):
- `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`。
- `cd server && npm run prisma:validate && npm run prisma:generate`;`npm test`;依赖补齐后的定向命令为 `npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/services/paymentOrderXls.test.js`。
- `cd client && npm run typecheck && npm run build`;`node scripts/qa/check-error-reason-contract.mjs`、`node scripts/qa/check-ui-design-rules.mjs`、`node scripts/qa/check-sensitive-content.mjs`。
工作区:共 15 项未提交(含 1 个新迁移、1 个新服务和 1 个新 HTTP fixture),HEAD 仍为 `14aee82d`;本轮没有提交、推送或部署。文档注册审计退出 0,但保留原有 22 个未登记文件告警;任务/治理/流程合同与 `git diff --check` 通过。
边界:SR-C 界面随 S8,本轮浏览器 7 组是既有流程回归,**不是新恢复界面验收**。S0、真实建行样本、S4–S8 与独立复核仍未完成;未查生产。受 inline 限制,本轮主会话直接实施与检查,没有另派独立 reviewer,不报告“独立复核通过”。应用不得单独部署启用。本轮未执行生产迁移或回填,未恢复/重置任何既有数据库;QA 只创建并清理本次拥有的隔离容器。
## 2026-10-07 SR 补证后恢复设计接续(仅设计,未实施)
用户同意下一步先设计补证后恢复。本轮沿既有任务完成 design 顶部 SR1–SR6、PRD AC17–AC19;不执行 S4–S8,不修改资金代码或迁移,不查询生产、不提交推送部署。S3 已本地提交 `14aee82d`;下方 S3“未提交”等文字保留为历史时点。
本轮清单:
- [x] 核对 S3 终态、终态事实登记及最终取消入口,确定补证被 `unknown` 限制卡住;设计保留终态、另建恢复轮次。
- [x] 写明适用范围、逐事实与整轮独立复核、材料失效、最终释放、并发、回滚和 S6/S8 边界。
- [x] 同步 PRD 的恢复验收条件(未勾选通过)。
- [x] 主会话按 `trellis-check` 完成文档一致性自查;任务 validate、Trellis 治理(含敏感信息扫描)、流程合同 baseline、`git diff --check` 通过。文档注册审计退出 0,报告 22 个未登记 docs 文件的告警;本轮未新增或修改 docs 文件,不扩展处理。未跑数据库、HTTP、浏览器或变异测试,未进行独立复核。
- [ ] 方案审阅及后续代码实施授权;本轮无资金代码实现或运行时通过结论。
方案批准后的执行切片(本轮不执行):
| 切片 | 内容 | 依赖与验收 |
|---|---|---|
| SR-A 数据与补证 | 增量迁移、恢复轮次/送审/决定、材料修订号;只对有效恢复轮次开放终态补证;保护原终态 | S3;AC17;迁移约束、补证往返、多轮取代、历史记录不可改 |
| SR-B 复核与释放 | 全量证据快照、逐事实与整轮权限、最终取消消费批准;覆盖相关写入口锁序 | SR-A;AC18/AC19;HTTP 正反例、失败回滚、并发、关键资金守卫变异 |
| SR-C 界面与收口 | 接入 S8 详情,PC/手机补证→独立复核→取消;旧记录与新资格并列 | SR-B 与 S8;不得因本地后端完成提前部署;整体测试与只读复核另报 |
定向测试矩阵:
- 两种原终态分别走“旧证据失效→发起恢复→上传新证据→逐事实复核→送审→独立复核→取消”,本金不减少,占用仅最后释放。
- 同一流水号重新上传/重新 OCR、清单新增再撤回、归属候选新增、批准后证据作废;旧批准拒绝,重新补证复核可恢复。撤回或驳回恢复不能走原取消旁路。
- 补登遗漏的第二次提交,少任一终态即拒绝;终态只能了结同指令处理中回单,成功事实始终阻断;Q6、未知文件、历史汇总、编号不符均不能代替证据。
- 刚导出时确认与最终取消均拒绝;等待期满才放行;无导出沿现有规则,不为补证另设等待期。
- 自审、多角色自审、旧上传/录入人换身份参与复核、当前人员离职被拒;原审批人/出借员工事后离职不拦按冻结快照登记事实。
- 最终释放分别与证据作废、OCR 核验落库、提交变化、同出借人新候选形成按两种顺序确定性交错;不得靠概率跑圈。释放先行后的新银行事实交后续对账,不宣称已处理。
- 最终事务在分配更新、批次取消、消费批准、审计处注入失败均全回滚;重复请求不重复释放;已释放/结清不可重开,恢复任何阶段不可导出/执行付款。
验证安排:设计阶段仅跑文档/治理校验与 `git diff --check`;代码阶段才跑隔离数据库/HTTP/浏览器及变异,按影响范围回归。inline 主会话审查不冒充独立只读复核。没有已验证的通用新工程规则,本轮方案先留在任务,不提升为模块既定规约。
## 2026-10-07 S3 本地实施记录(用户批准本地实施、针对性验证与只读复核,并要求纳入三条补充要求;未提交、未推送、未部署、未查询生产) ## 2026-10-07 S3 本地实施记录(用户批准本地实施、针对性验证与只读复核,并要求纳入三条补充要求;未提交、未推送、未部署、未查询生产)
迁移 `20261008010000_loan_bank_terminal_facts`: 迁移 `20261008010000_loan_bank_terminal_facts`:
@@ -64,6 +64,10 @@
- [ ] AC15(追回,3.5):凭到账证据经独立复核登记,支持部分追回;仅登记不解冻;本期不支持冲抵。 - [ ] AC15(追回,3.5):凭到账证据经独立复核登记,支持部分追回;仅登记不解冻;本期不支持冲抵。
- [ ] AC16(存量):历史批次保留已知的导出次数,新增“历史导出汇总”标明已知与待核实信息;它不是一次真实导出或银行提交,不能据此放行额度;上线前须核实生产存量(S0)。 - [ ] AC16(存量):历史批次保留已知的导出次数,新增“历史导出汇总”标明已知与待核实信息;它不是一次真实导出或银行提交,不能据此放行额度;上线前须核实生产存量(S0)。
- [ ] AC17(补证恢复,SR1–SR3;方案已批准,后端验证见 implement):已确认失败或未付款且本金仍全部占用时,可补充有效证据并重新独立复核;原结论与依据不可改,旧证据/复核可追溯;恢复不得重开付款、导出或改变本金,不适用于已释放/已结清、成功事实或 Q6 人工未提交。
- [ ] AC18(恢复批准有效性,SR2–SR4;后端验证见 implement):材料、核验版本、提交清单、归属或同额候选变化使旧恢复批准失效;新建再撤回、换回同一流水号、撤回/驳回恢复均不能复活旧批准或回退原释放依据;补证后能重新送审,不永久卡死;当前复核人合格且与全部相关经办身份独立。
- [ ] AC19(最终恢复释放,SR3–SR5;后端验证见 implement):确认与最终取消均满足 S3 全部条件及等待期;最终事务重验最新恢复批准与证据,释放、取消、消费批准、审计原子且幂等;与证据/提交/核验/候选变化确定性交错时不错误释放;成功事实转对账、未知继续占用。PC/手机完整操作与真实银行接受性分别验收。
## Out of Scope ## Out of Scope
- 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、退款或债务冲销。 - 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、退款或债务冲销。
+5
查看文件
@@ -1,4 +1,9 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = { export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
"loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。",
"integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。",
"loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。",
"loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。", "loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。",
+3 -1
查看文件
@@ -202,7 +202,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
return { attachment: response.json().data.items[0], result }; return { attachment: response.json().data.items[0], result };
} }
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} }); const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(exportResponse.statusCode, 200, 'loan bank-order export'); assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1'); assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe'); await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
@@ -1155,6 +1155,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await assert.rejects(prisma.loanBankTerminalFact.update({ where: { id: fact1.id }, data: { terminal_status: 'failed' } })); await assert.rejects(prisma.loanBankTerminalFact.update({ where: { id: fact1.id }, data: { terminal_status: 'failed' } }));
await assert.rejects(prisma.loanBankTerminalFact.delete({ where: { id: fact1.id } })); await assert.rejects(prisma.loanBankTerminalFact.delete({ where: { id: fact1.id } }));
checks.push('s3_evidence_and_fact_history_immutable'); checks.push('s3_evidence_and_fact_history_immutable');
checks.push(...await require('./company-loan-recovery-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
delete config.loanReleaseWaitDays; delete config.loanReleaseWaitDays;
// History guards in the database. // History guards in the database.
@@ -0,0 +1,350 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
// Real HTTP/auth, bank-evidence upload and OCR supplied by the isolated loan fixture.
module.exports = async function recoveryFixture(h) {
const { prisma, pool, call, app, finance, manager, independent, dual, actor, config, req,
verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence,
proposeFact, reviewFact, confirmNotPaid, receipt } = h;
const checks = [];
const lender = await verifiedLender('合成恢复出借人', null, '2');
const loan = await confirmedLoan(lender.id, '5000.00', '合成恢复借款');
const balances = () => call(finance, 'GET', `/company-loans/${loan.id}`);
let amount = 100;
async function setup(kind = 'notpaid', who = finance) {
const cents = ++amount; const value = (cents / 100).toFixed(2);
const batch = await repay(lender.id, loan.id, value);
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {});
await exportOrder(batch.id);
const [exp] = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id } });
const ref = `SYNTHETIC-RECOVERY-${randomUUID()}`.toUpperCase();
const submission = await register(batch.id, ref, exp.id);
await attest(batch.id);
let evidenceId; let attachmentId;
if (kind === 'failed') {
const result = await receipt(batch.id, String(cents), ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' });
attachmentId = result.attachment.id;
await call(manager, 'POST', `/payment-batches/${batch.id}/bank-failure-review`, { attachmentId });
} else {
evidenceId = await uploadEvidence(batch.id);
const fact = await proposeFact(batch.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId });
await reviewFact(batch.id, fact.id);
await confirmNotPaid(batch.id);
}
const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loanRepaymentId } });
return { batch, submission, ref, cents, evidenceId, attachmentId, original, who };
}
const url = (s) => `/payment-batches/${s.batch.id}/loan-release-recoveries`;
const start = (s, payload = { requestKey: randomUUID(), reason: '合成补充有效释放依据' }, status = 200, reason) =>
call(s.who, 'POST', url(s), payload, status, reason);
const submit = (s, r, status = 200, reason) => call(s.who, 'POST', `${url(s)}/${r.id}/submit`, {}, status, reason);
const review = (s, r, decision = 'approve', who = independent, status = 200, reason) =>
call(who, 'POST', `${url(s)}/${r.id}/review`, { decision, reason: '合成独立恢复复核' }, status, reason);
const cancel = (s, r, key = randomUUID(), status = 200, reason) => call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`,
{ reason: '合成补证后最终取消', ...(r ? { recoveryId: r.id, requestKey: key } : {}) }, status, reason);
const withdraw = (s, r) => call(s.who, 'POST', `${url(s)}/${r.id}/withdraw`, { reason: '合成撤回恢复申请' });
async function voidEvidence(s, evidenceId = s.evidenceId) {
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成证据换版' });
}
async function repair(s) {
const evidenceId = await uploadEvidence(s.batch.id);
const fact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId });
await reviewFact(s.batch.id, fact.id);
s.evidenceId = evidenceId;
return evidenceId;
}
async function ready() {
const s = await setup(); await voidEvidence(s); const r = await start(s); await repair(s); await submit(s, r); await review(s, r);
return { s, r };
}
// Both historical outcomes recover through uploaded evidence and independent
// review; neither original terminal field changes, and only cancellation releases.
for (const kind of ['notpaid', 'failed']) {
const s = await setup(kind);
if (kind === 'notpaid') {
await voidEvidence(s);
const probeEvidence = await uploadEvidence(s.batch.id);
await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: probeEvidence }, 409, 'loan.recovery_required');
}
else await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${s.attachmentId}/void`, { reason: '合成失败回单换版' });
const input = { requestKey: randomUUID(), reason: '合成补证请求幂等' };
const r = await start(s, input);
assert.equal((await start(s, input)).id, r.id);
await start(s, undefined, 409, 'loan.recovery_exists');
if (kind === 'notpaid') await repair(s);
else {
const failed = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' });
const f = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: failed.attachment.id });
await reviewFact(s.batch.id, f.id);
}
const before = await balances();
await cancel(s, null, undefined, 409, 'loan.recovery_required');
await submit(s, r); await review(s, r, 'approve', finance, 403);
await review(s, r);
assert.equal((await balances()).reserved, before.reserved);
const cancelKey = randomUUID(); await cancel(s, r, cancelKey); await cancel(s, r, cancelKey);
await cancel(s, r, randomUUID(), 409);
const after = await balances();
assert.equal(after.outstanding, before.outstanding);
assert.equal(Number(after.reserved), Number((Number(before.reserved) - s.cents / 100).toFixed(2)));
const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.original.id } });
assert.equal(original.bank_result_state, s.original.bank_result_state);
assert.equal(original.bank_result_ref, s.original.bank_result_ref);
await start(s, undefined, 409, 'loan.recovery_unavailable');
await call(finance, 'POST', `/payment-batches/${s.batch.id}/payment-order`, {}, 409);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/execute`, {}, 409);
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_RELEASE_RECOVERY_CONSUMED', target_id: s.original.id } }), 1);
}
checks.push('sr_both_terminal_outcomes_recover_without_rewriting_history_idempotent_release');
// Withdraw/reject never revive the original release path, even when its
// original documents remain valid. A new round is required and can succeed.
for (const decision of ['withdraw', 'reject']) {
const s = await setup(); const r = await start(s);
if (decision === 'withdraw') await withdraw(s, r);
else { await submit(s, r); await review(s, r, 'reject'); }
await cancel(s, null, undefined, 409, 'loan.recovery_required');
const next = await start(s); assert.equal(next.sequence, 2);
await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_withdraw_reject_require_fresh_round_no_original_conclusion_fallback');
// Evidence void after approval: the current round becomes stale, can be
// superseded and repaired. Returning to old evidence never restores approval.
{
const { s, r } = await ready(); const before = await balances();
await voidEvidence(s);
await cancel(s, r, undefined, 409);
const state = await call(finance, 'GET', url(s)); assert.equal(state.current.stale, true);
assert.equal((await balances()).reserved, before.reserved);
const next = await start(s); await repair(s); await submit(s, next); await review(s, next); await cancel(s, next);
assert.equal((await prisma.loanReleaseRecovery.findUnique({ where: { id: r.id } })).status, 'superseded');
}
checks.push('sr_stale_approval_can_be_superseded_and_repaired');
// New submission then withdrawal, followed by a fresh attestation, cannot
// revive a saved approval. It also cannot erase an unexplained receipt.
{
const { s, r } = await ready();
const extra = await register(s.batch.id, `SYNTHETIC-EXTRA-${randomUUID()}`.toUpperCase(), null);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成登记纠错' });
await attest(s.batch.id);
await cancel(s, r, undefined, 409, 'loan.recovery_stale');
const next = await start(s); await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_submission_aba_does_not_revive_approval');
// Candidate membership changed in another batch: target's material revision
// is unchanged, but its saved candidate set must no longer authorize release.
{
const { s, r } = await ready();
const competitor = await repay(lender.id, loan.id, (s.cents / 100).toFixed(2));
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
await register(competitor.id, `SYNTHETIC-CANDIDATE-${randomUUID()}`.toUpperCase(), null);
await cancel(s, r, undefined, 409, 'loan.recovery_stale');
const next = await start(s); await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_new_same_amount_candidate_invalidates_approval');
// A newly discovered second submission needs its own independently reviewed
// terminal fact; the original termination cannot stand in for it.
{
const s = await setup(); const r = await start(s);
const extra = await register(s.batch.id, `SYNTHETIC-MISSING-${randomUUID()}`.toUpperCase(), null);
await attest(s.batch.id);
await submit(s, r, 409, 'loan.terminal_coverage_incomplete');
const ev = await uploadEvidence(s.batch.id);
const fact = await proposeFact(s.batch.id, { submissionId: extra.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
await reviewFact(s.batch.id, fact.id);
await submit(s, r); await review(s, r); await cancel(s, r);
}
checks.push('sr_every_additional_submission_needs_own_terminal_fact');
// A late successful receipt blocks recovery even after the receipt is voided.
{
const s = await setup(); const r = await start(s);
const paid = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'success' });
await submit(s, r, 409, 'loan.receipt_unresolved_for_release');
await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${paid.attachment.id}/void`, { reason: '合成成功回单作废不抹除事实' });
await submit(s, r, 409, 'loan.receipt_unresolved_for_release');
await cancel(s, null, undefined, 409, 'loan.recovery_required');
}
checks.push('sr_success_fact_blocks_release_even_after_void');
// Physical bytes, not just a still-valid database ID, must match the evidence.
{
const s = await setup(); const r = await start(s);
const ev = await prisma.loanBankTerminalEvidence.findUnique({ where: { id: s.evidenceId } });
const { readFileBuffer, writeBuffer } = req('./src/services/storage');
const original = await readFileBuffer({ config, storagePath: ev.storage_path });
await writeBuffer({ config, key: ev.storage_path, buffer: Buffer.from('synthetic corrupted document') });
try { await submit(s, r, 409, 'loan.terminal_evidence_invalid'); }
finally { await writeBuffer({ config, key: ev.storage_path, buffer: original }); }
await submit(s, r); await review(s, r); await cancel(s, r);
}
checks.push('sr_physical_evidence_digest_revalidated');
// Re-uploading the same failed transaction after approval changes evidence,
// so approval must be replaced even though the bank reference is identical.
{
const s = await setup('failed'); const r = await start(s);
const oldFact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: s.attachmentId });
await reviewFact(s.batch.id, oldFact.id); await submit(s, r); await review(s, r);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${s.attachmentId}/void`, { reason: '合成同交易换回单' });
const replacement = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' });
await cancel(s, r, undefined, 409);
const next = await start(s);
const newFact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: replacement.attachment.id });
await reviewFact(s.batch.id, newFact.id); await submit(s, next); await review(s, next); await cancel(s, next);
}
checks.push('sr_same_transaction_new_receipt_requires_fresh_review');
// Same-instruction terminal evidence resolves a pending receipt in recovery.
{
const s = await setup(); const r = await start(s);
const pending = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'pending' });
await submit(s, r, 409, 'loan.receipt_unresolved_for_release');
await voidEvidence(s);
const ev = await uploadEvidence(s.batch.id);
const fact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked',
evidenceId: ev, resolvesAttachmentIds: [pending.attachment.id] });
await reviewFact(s.batch.id, fact.id); await submit(s, r); await review(s, r); await cancel(s, r);
}
checks.push('sr_pending_receipt_resolved_by_same_instruction_terminal_fact');
// Finance+manager cannot review own round; current reviewer departure blocks
// approval, while old approver departure does not erase the bank conclusion.
{
const s = await setup('notpaid', dual); const r = await start(s); await submit(s, r);
await review(s, r, 'approve', dual, 403);
const departed = await actor(['manager']);
await prisma.employee.update({ where: { id: departed.id }, data: { status: 'archived' } });
await review(s, r, 'approve', departed, 403);
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try { await review(s, r); await cancel(s, r); }
finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
}
checks.push('sr_current_independence_and_historical_approver_departure');
// Wait applies at submit, approve and final release (test configuration only).
{
const s = await setup(); const r = await start(s);
config.loanReleaseWaitDays = 3; await submit(s, r, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0; await submit(s, r);
config.loanReleaseWaitDays = 3; await review(s, r, 'approve', independent, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0; await review(s, r);
config.loanReleaseWaitDays = 3; await cancel(s, r, undefined, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0; await cancel(s, r);
}
checks.push('sr_wait_period_on_submission_approval_and_final_cancel');
// Real SQL rollback: fail at the LAST audit, after recovery consumption,
// allocation release and batch cancellation; all must roll back together.
{
const { s, r } = await ready(); const before = await balances();
await assert.rejects(prisma.loanReleaseRecovery.update({ where: { id: r.id }, data: { status: 'superseded', ended_at: new Date(), snapshot_json: {} } }));
await assert.rejects(prisma.loanReleaseMaterialRevision.update({ where: { payment_batch_id: s.batch.id }, data: { revision: 0 } }));
await assert.rejects(prisma.loanReleaseMaterialRevision.delete({ where: { payment_batch_id: s.batch.id } }));
await assert.rejects(prisma.$transaction(async tx => {
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: s.batch.loanRepaymentId }, data: { reservation_state: 'released', released_at: new Date(), release_reason: '合成绕过恢复批准' } });
await tx.paymentBatch.update({ where: { id: s.batch.id }, data: { status: 'cancelled' } });
}));
await pool.query(`CREATE FUNCTION synthetic_sr_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW.action='PAYMENT_BATCH_CANCELLED' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_sr_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_sr_audit_failure()');
try { await cancel(s, r, undefined, 500); }
finally { await pool.query('DROP TRIGGER synthetic_sr_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_sr_audit_failure()'); }
assert.equal((await balances()).reserved, before.reserved);
assert.equal((await prisma.paymentBatch.findUnique({ where: { id: s.batch.id } })).status, 'reviewed');
assert.equal((await prisma.loanReleaseRecovery.findUnique({ where: { id: r.id } })).status, 'approved');
await cancel(s, r);
await assert.rejects(prisma.loanReleaseRecovery.update({ where: { id: r.id }, data: { status: 'approved' } }));
await assert.rejects(prisma.loanReleaseRecovery.delete({ where: { id: r.id } }));
const ev = await prisma.loanReleaseRecoveryEvent.findFirst({ where: { recovery_id: r.id } });
await assert.rejects(prisma.loanReleaseRecoveryEvent.update({ where: { id: ev.id }, data: { note: '篡改' } }));
await assert.rejects(prisma.loanReleaseRecoveryEvent.delete({ where: { id: ev.id } }));
}
checks.push('sr_atomic_release_rollback_and_database_history_guards');
// Deterministic batch-lock ordering: mutation-first invalidates approval,
// release-first closes the evidence. Observe actual SQL waits, not timing luck.
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
const waiters = async (n) => {
for (let i = 0; i < 300; i++) {
const r = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
if (r.rows[0].n >= n) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('recovery requests did not queue on database lock');
};
try {
for (const mutationKind of ['evidence', 'submission']) for (const releaseFirst of [true, false]) {
const { s, r } = await ready(); const key = randomUUID(); const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers,
payload: { reason: '合成并发释放', recoveryId: r.id, requestKey: key } });
const mutation = () => mutationKind === 'evidence' ? app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences/${s.evidenceId}/void`,
headers: finance.headers, payload: { reason: '合成并发作废' } }) : app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-submissions`,
headers: finance.headers, payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: `SYNTHETIC-RACE-${randomUUID()}`.toUpperCase(), note: '合成并发补登提交' } });
let first; let second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]);
first = releaseFirst ? release() : mutation(); await waiters(1);
second = releaseFirst ? mutation() : release(); await waiters(2);
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
} finally { await holder.query('ROLLBACK'); holder.release(); }
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 409);
const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } });
assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved');
}
// Other-batch candidate formation shares the lender lock with release.
for (const releaseFirst of [true, false]) {
const { s, r } = await ready();
const competitor = await repay(lender.id, loan.id, (s.cents / 100).toFixed(2));
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers,
payload: { reason: '合成候选并发释放', recoveryId: r.id, requestKey: randomUUID() } });
const mutation = () => app.inject({ method: 'POST', url: `/api/payment-batches/${competitor.id}/loan-bank-submissions`, headers: finance.headers,
payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: `SYNTHETIC-CANDIDATE-RACE-${randomUUID()}`.toUpperCase(), note: '合成候选并发提交' } });
let first; let second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [lender.id]);
first = releaseFirst ? release() : mutation(); await waiters(1);
second = releaseFirst ? mutation() : release(); await waiters(2);
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
} finally { await holder.query('ROLLBACK'); holder.release(); }
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, releaseFirst ? 200 : 409);
const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } });
assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved');
}
// Exercise the actual OCR persistence transaction against final release.
// The source recognition is the real normalized output from the failed PDF.
for (const releaseFirst of [true, false]) {
const s = await setup('failed'); const r = await start(s);
const f = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: s.attachmentId });
await reviewFact(s.batch.id, f.id); await submit(s, r); await review(s, r);
const attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: s.attachmentId } });
const verification = await prisma.paymentReceiptVerification.findFirst({ where: { attachment_id: s.attachmentId }, orderBy: { created_at: 'desc' } });
const recognition = JSON.parse(req('./src/services/crypto').decryptStringOrThrow(config, verification.normalized_encrypted));
const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers,
payload: { reason: '合成核验落库并发释放', recoveryId: r.id, requestKey: randomUUID() } });
const mutation = () => prisma.$transaction(tx => req('./src/services/paymentReceiptVerification').storeReceiptRecognition(tx, config,
{ batchId: s.batch.id, attachmentId: s.attachmentId, sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version, recognition }))
.then(() => ({ statusCode: 200 }), error => { if (error.code === 'receipt_batch_not_editable') return { statusCode: 409 }; throw error; });
let first; let second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]);
first = releaseFirst ? release() : mutation(); await waiters(1);
second = releaseFirst ? mutation() : release(); await waiters(2);
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
} finally { await holder.query('ROLLBACK'); holder.release(); }
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 409);
const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } });
assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved');
}
} finally { await lockPool.end(); }
checks.push('sr_final_release_and_evidence_or_submission_deterministic_both_orders');
checks.push('sr_candidate_formation_and_ocr_persistence_serialize_with_final_release');
return checks;
};
@@ -0,0 +1,142 @@
-- SR: append recovery decisions without reopening the original bank outcome.
CREATE TABLE loan_release_recoveries (
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
sequence INTEGER NOT NULL CHECK(sequence > 0), previous_id UUID REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT,
request_key UUID NOT NULL, original_state VARCHAR(32) NOT NULL CHECK(original_state IN ('confirmed_failed','confirmed_not_paid')),
original_ref TEXT NOT NULL CHECK(length(trim(original_ref)) > 0), reason VARCHAR(1000) NOT NULL CHECK(length(trim(reason))>=5),
started_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, started_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(16) NOT NULL DEFAULT 'collecting' CHECK(status IN ('collecting','proposed','approved','rejected','withdrawn','superseded','consumed')),
submitted_by UUID REFERENCES employees(id) ON DELETE RESTRICT, submitted_at TIMESTAMP(3), snapshot_json JSONB,
reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(500), ended_at TIMESTAMP(3),
cancel_request_key UUID, consumed_by UUID REFERENCES employees(id) ON DELETE RESTRICT,
UNIQUE(payment_batch_id,sequence), UNIQUE(payment_batch_id,request_key),
CHECK(submitted_by IS NULL OR submitted_by=started_by),
CHECK(reviewed_by IS NULL OR (reviewed_by<>started_by AND reviewed_by<>submitted_by)),
CHECK(status NOT IN ('proposed','approved','rejected','consumed') OR (snapshot_json IS NOT NULL AND submitted_by IS NOT NULL AND submitted_at IS NOT NULL)),
CHECK(status NOT IN ('approved','rejected','consumed') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>0)),
CHECK(status<>'consumed' OR (cancel_request_key IS NOT NULL AND consumed_by IS NOT NULL)),
CHECK(status NOT IN ('withdrawn','superseded','rejected','consumed') OR ended_at IS NOT NULL)
);
CREATE UNIQUE INDEX loan_release_recoveries_open ON loan_release_recoveries(payment_batch_id) WHERE status IN ('collecting','proposed','approved');
CREATE TABLE loan_release_recovery_events (
id UUID PRIMARY KEY, recovery_id UUID NOT NULL REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT,
action VARCHAR(32) NOT NULL, actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, note VARCHAR(1000) NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX loan_release_recovery_events_recovery_id_idx ON loan_release_recovery_events(recovery_id);
CREATE TABLE loan_release_material_revisions (
payment_batch_id UUID PRIMARY KEY REFERENCES payment_batches(id) ON DELETE RESTRICT,
revision BIGINT NOT NULL DEFAULT 0 CHECK(revision>=0)
);
CREATE FUNCTION guard_loan_release_recovery_history() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE b payment_batches; r loan_repayments;
BEGIN
IF TG_OP='DELETE' OR TG_TABLE_NAME='loan_release_recovery_events' THEN
RAISE EXCEPTION 'recovery history is immutable' USING ERRCODE='23514';
END IF;
IF TG_OP='INSERT' THEN
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
SELECT * INTO r FROM loan_repayments WHERE id=b.loan_repayment_id;
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') OR
NEW.original_state IS DISTINCT FROM r.bank_result_state OR NEW.original_ref IS DISTINCT FROM r.bank_result_ref OR
NEW.status<>'collecting' OR NEW.snapshot_json IS NOT NULL OR NEW.reviewed_by IS NOT NULL OR
NEW.submitted_by IS NOT NULL OR NEW.submitted_at IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR
NEW.ended_at IS NOT NULL OR NEW.cancel_request_key IS NOT NULL OR NEW.consumed_by IS NOT NULL OR
NOT EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id) OR
EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state<>'reserved') THEN
RAISE EXCEPTION 'invalid recovery source' USING ERRCODE='23514';
END IF;
IF NEW.sequence <> COALESCE((SELECT max(sequence)+1 FROM loan_release_recoveries WHERE payment_batch_id=b.id),1) OR
NEW.previous_id IS DISTINCT FROM (SELECT id FROM loan_release_recoveries WHERE payment_batch_id=b.id ORDER BY sequence DESC LIMIT 1) THEN
RAISE EXCEPTION 'invalid recovery predecessor' USING ERRCODE='23514';
END IF;
RETURN NEW;
END IF;
IF (to_jsonb(NEW)-ARRAY['status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by'])
IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by']) OR
NOT ((OLD.status='collecting' AND NEW.status IN ('proposed','withdrawn')) OR
(OLD.status='proposed' AND NEW.status IN ('approved','rejected','withdrawn','superseded')) OR
(OLD.status='approved' AND NEW.status IN ('superseded','consumed'))) THEN
RAISE EXCEPTION 'invalid recovery transition' USING ERRCODE='23514';
END IF;
IF NOT (OLD.status='collecting' AND NEW.status='proposed') AND
(NEW.submitted_by,NEW.submitted_at,NEW.snapshot_json) IS DISTINCT FROM (OLD.submitted_by,OLD.submitted_at,OLD.snapshot_json) THEN
RAISE EXCEPTION 'immutable recovery submission' USING ERRCODE='23514';
END IF;
IF NOT (OLD.status='proposed' AND NEW.status IN ('approved','rejected')) AND
(NEW.reviewed_by,NEW.reviewed_at,NEW.review_note) IS DISTINCT FROM (OLD.reviewed_by,OLD.reviewed_at,OLD.review_note) THEN
RAISE EXCEPTION 'immutable recovery decision' USING ERRCODE='23514';
END IF;
IF NEW.status<>'consumed' AND (NEW.cancel_request_key,NEW.consumed_by) IS DISTINCT FROM (OLD.cancel_request_key,OLD.consumed_by) THEN
RAISE EXCEPTION 'invalid recovery consumption' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_release_recovery_history BEFORE INSERT OR UPDATE OR DELETE ON loan_release_recoveries FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history();
CREATE TRIGGER loan_release_recovery_event_history BEFORE UPDATE OR DELETE ON loan_release_recovery_events FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history();
-- All receipt/terminal/list writers share the parent lock and monotonically
-- advance material revisions, including background OCR and void/reversal ABA.
CREATE FUNCTION touch_loan_release_material() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bid UUID; b payment_batches; lender UUID; data JSONB;
BEGIN
data := CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END;
bid := (data->>'payment_batch_id')::uuid;
IF bid IS NULL THEN
SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(data->>'attachment_id')::uuid;
END IF;
SELECT * INTO b FROM payment_batches WHERE id=bid FOR UPDATE;
IF b.entity_type='loan_repayment' THEN
IF EXISTS(SELECT 1 FROM loan_release_recoveries WHERE payment_batch_id=bid) AND b.status IN ('cancelled','executed') THEN
RAISE EXCEPTION 'closed recovery evidence is immutable' USING ERRCODE='23514';
END IF;
IF TG_OP='INSERT' AND TG_TABLE_NAME IN ('payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments') THEN
SELECT lender_id INTO lender FROM loan_repayments WHERE id=b.loan_repayment_id;
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
END IF;
INSERT INTO loan_release_material_revisions(payment_batch_id,revision) VALUES(bid,1)
ON CONFLICT(payment_batch_id) DO UPDATE SET revision=loan_release_material_revisions.revision+1;
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_order_exports FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submissions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submission_attestations FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_facts FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_receipt_attributions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_batch_receipt_attachments FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_verifications FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_manual_proposals FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
-- Deferred: consumption, allocations and batch cancellation are one commit.
CREATE FUNCTION check_loan_recovery_release() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bid UUID; latest loan_release_recoveries; b payment_batches; rid UUID;
BEGIN
IF TG_TABLE_NAME='loan_release_recoveries' THEN bid:=NEW.payment_batch_id;
ELSE SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; END IF;
SELECT * INTO latest FROM loan_release_recoveries WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
IF latest.id IS NULL THEN RETURN NEW; END IF;
SELECT * INTO b FROM payment_batches WHERE id=bid;
rid:=b.loan_repayment_id;
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state='released') AND
(latest.status<>'consumed' OR b.status<>'cancelled') THEN
RAISE EXCEPTION 'release requires latest recovery consumption' USING ERRCODE='23514';
END IF;
IF latest.status='consumed' AND (b.status<>'cancelled' OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state<>'released')) THEN
RAISE EXCEPTION 'recovery consumption requires atomic cancellation' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE CONSTRAINT TRIGGER loan_recovery_release_check AFTER INSERT OR UPDATE ON loan_release_recoveries DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release();
CREATE CONSTRAINT TRIGGER loan_recovery_allocation_check AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release();
CREATE FUNCTION guard_loan_material_revision() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' OR NEW.payment_batch_id IS DISTINCT FROM OLD.payment_batch_id OR NEW.revision<>OLD.revision+1 THEN
RAISE EXCEPTION 'material revision must increase once' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_material_revision_history BEFORE UPDATE OR DELETE ON loan_release_material_revisions FOR EACH ROW EXECUTE FUNCTION guard_loan_material_revision();
+64
查看文件
@@ -745,6 +745,12 @@ model Employee {
loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer") loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer")
loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer") loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer")
@@map("employees") @@map("employees")
loan_recoveries_started LoanReleaseRecovery[] @relation("LoanReleaseStarter")
loan_recoveries_submitted LoanReleaseRecovery[] @relation("LoanReleaseSubmitter")
loan_recoveries_reviewed LoanReleaseRecovery[] @relation("LoanReleaseReviewer")
loan_recoveries_consumed LoanReleaseRecovery[] @relation("LoanReleaseConsumer")
loan_recovery_events LoanReleaseRecoveryEvent[]
} }
model PasswordResetCode { model PasswordResetCode {
@@ -1992,6 +1998,9 @@ model PaymentBatch {
loan_terminal_facts LoanBankTerminalFact[] loan_terminal_facts LoanBankTerminalFact[]
loan_failure_evidence_uses LoanFailureEvidenceUse[] loan_failure_evidence_uses LoanFailureEvidenceUse[]
@@map("payment_batches") @@map("payment_batches")
loan_release_recoveries LoanReleaseRecovery[]
loan_material_revision LoanReleaseMaterialRevision?
} }
model PaymentBatchItem { model PaymentBatchItem {
@@ -4180,3 +4189,58 @@ model LoanFailureEvidenceUse {
@@map("loan_failure_evidence_uses") @@map("loan_failure_evidence_uses")
} }
// SR: original bank conclusions remain immutable; recovery controls release only.
model LoanReleaseRecovery {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
sequence Int
previous_id String? @db.Uuid
request_key String @db.Uuid
original_state String @db.VarChar(32)
original_ref String
reason String @db.VarChar(1000)
started_by String @db.Uuid
started_at DateTime @default(now())
status String @default("collecting") @db.VarChar(16)
submitted_by String? @db.Uuid
submitted_at DateTime?
snapshot_json Json?
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String? @db.VarChar(500)
ended_at DateTime?
cancel_request_key String? @db.Uuid
consumed_by String? @db.Uuid
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
starter Employee @relation("LoanReleaseStarter", fields: [started_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
submitter Employee? @relation("LoanReleaseSubmitter", fields: [submitted_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
reviewer Employee? @relation("LoanReleaseReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
consumer Employee? @relation("LoanReleaseConsumer", fields: [consumed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
previous LoanReleaseRecovery? @relation("LoanRecoveryPrevious", fields: [previous_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
next LoanReleaseRecovery[] @relation("LoanRecoveryPrevious")
events LoanReleaseRecoveryEvent[]
@@unique([payment_batch_id, sequence])
@@unique([payment_batch_id, request_key])
@@map("loan_release_recoveries")
}
model LoanReleaseRecoveryEvent {
id String @id @default(uuid()) @db.Uuid
recovery_id String @db.Uuid
action String @db.VarChar(32)
actor_id String @db.Uuid
note String @db.VarChar(1000)
created_at DateTime @default(now())
recovery LoanReleaseRecovery @relation(fields: [recovery_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
actor Employee @relation(fields: [actor_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([recovery_id])
@@map("loan_release_recovery_events")
}
model LoanReleaseMaterialRevision {
payment_batch_id String @id @db.Uuid
revision BigInt @default(0)
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@map("loan_release_material_revisions")
}
+5
查看文件
@@ -1,4 +1,9 @@
const ERROR_REASONS = [ const ERROR_REASONS = [
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
{"code":"loan.recovery_stale","domain":"loan","audience":"user","defaultMessage":"补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。","recovery":"follow_guidance"},
{"code":"integration.erp_retired","domain":"integration","audience":"user","defaultMessage":"ERP 集成已停用,历史记录仅供查询,不能重新投递。","recovery":"follow_guidance"}, {"code":"integration.erp_retired","domain":"integration","audience":"user","defaultMessage":"ERP 集成已停用,历史记录仅供查询,不能重新投递。","recovery":"follow_guidance"},
{"code":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"}, {"code":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"},
{"code":"loan.not_found","domain":"loan","audience":"user","defaultMessage":"借款记录不存在或已不可用,请刷新后重新选择。","recovery":"follow_guidance"}, {"code":"loan.not_found","domain":"loan","audience":"user","defaultMessage":"借款记录不存在或已不可用,请刷新后重新选择。","recovery":"follow_guidance"},
+42 -3
查看文件
@@ -6,6 +6,7 @@ const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRe
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments"); proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments");
const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation, const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions"); reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions");
const releaseRecoveries = require("../services/loanReleaseRecoveries");
const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, reviewTerminalFact, confirmNotPaid, const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, reviewTerminalFact, confirmNotPaid,
terminalState } = require("../services/loanBankTerminalFacts"); terminalState } = require("../services/loanBankTerminalFacts");
const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildBusinessValidationError } = require("../utils/businessValidationError");
@@ -1053,7 +1054,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => { ...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
const repayment = await readLoanRepayment(tx, batch); const repayment = await readLoanRepayment(tx, batch);
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config), return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch) }; bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch) };
})() } : {}), })() } : {}),
...mapBatch(batch, openRiskFlags, legacyPayees, { ...mapBatch(batch, openRiskFlags, legacyPayees, {
includePayee: true, includePayee: true,
@@ -1511,6 +1512,8 @@ async function paymentBatchesRoutes(fastify) {
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
await ensureFeatureEnabled(fastify); await ensureFeatureEnabled(fastify);
try { await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`; try { await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
const repayment = await readLoanRepayment(tx, batch);
await require("../services/companyLoans").lockLender(tx, repayment.lender_id);
return await work(tx, batch); } catch (error) { mapManualReceiptError(error); } return await work(tx, batch); } catch (error) { mapManualReceiptError(error); }
}); });
} }
@@ -1552,6 +1555,37 @@ async function paymentBatchesRoutes(fastify) {
const result = await attributionTransaction(request, (tx, batch) => reviewSubmissionAttestation(tx, batch, request, attestationId, body)); const result = await attributionTransaction(request, (tx, batch) => reviewSubmissionAttestation(tx, batch, request, attestationId, body));
return sendData(reply, request, { id: result.id, status: result.status }); return sendData(reply, request, { id: result.id, status: result.status });
}); });
const RECOVERY_START_SCHEMA = z.object({ requestKey: UUID_SCHEMA, reason: z.string().trim().min(5).max(1000) }).strict();
const RECOVERY_REASON_SCHEMA = z.object({ reason: z.string().trim().min(2).max(500) }).strict();
const recoveryResponse = (r) => ({ id: r.id, sequence: r.sequence, status: r.status });
fastify.post("/payment-batches/:id/loan-release-recoveries", { preHandler: authenticateBatch }, async (request, reply) => {
const input = parseBody(RECOVERY_START_SCHEMA, request.body);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.startRecovery(tx, fastify.config, batch, request, input));
return sendData(reply, request, recoveryResponse(result));
});
fastify.get("/payment-batches/:id/loan-release-recoveries", { preHandler: authenticateBatch }, async (request, reply) => {
const batch = await loadBatchWithItems(fastify.prisma, parseId(request.params.id));
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.recovery_unavailable", {}, 409);
return sendData(reply, request, await releaseRecoveries.recoveryState(fastify.prisma, batch));
});
fastify.post("/payment-batches/:id/loan-release-recoveries/:recoveryId/submit", { preHandler: authenticateBatch }, async (request, reply) => {
parseBody(z.object({}).strict(), request.body ?? {});
const id = parseId(request.params.recoveryId);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.submitRecovery(tx, fastify.config, batch, request, id));
return sendData(reply, request, recoveryResponse(result));
});
fastify.post("/payment-batches/:id/loan-release-recoveries/:recoveryId/review", { preHandler: authenticateBatch }, async (request, reply) => {
const input = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
const id = parseId(request.params.recoveryId);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.reviewRecovery(tx, fastify.config, batch, request, id, input));
return sendData(reply, request, recoveryResponse(result));
});
fastify.post("/payment-batches/:id/loan-release-recoveries/:recoveryId/withdraw", { preHandler: authenticateBatch }, async (request, reply) => {
const { reason } = parseBody(RECOVERY_REASON_SCHEMA, request.body);
const id = parseId(request.params.recoveryId);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.withdrawRecovery(tx, batch, request, id, reason));
return sendData(reply, request, recoveryResponse(result));
});
fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => { fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => {
const files = []; const files = [];
try { try {
@@ -2956,9 +2990,14 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify); await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request); ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id); const batchId = parseId(request.params?.id);
const { reason } = parseBody(REASON_SCHEMA, request.body); const input = parseBody(REASON_SCHEMA.extend({ recoveryId: UUID_SCHEMA.optional(), requestKey: UUID_SCHEMA.optional() }), request.body);
const { reason } = input;
const cancelled = await fastify.prisma.$transaction(async (tx) => { const cancelled = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId); const batch = await lockBatchWithItems(tx, batchId);
if (batch.entity_type === "loan_repayment") {
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
if (await releaseRecoveries.isRecoveryCancelRetry(tx, batch, request, input)) return batch;
}
await ensureBatchSourceDepartmentsEnabled(fastify, tx, { await ensureBatchSourceDepartmentsEnabled(fastify, tx, {
...batch, ...batch,
items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)), items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)),
@@ -2966,7 +3005,7 @@ async function paymentBatchesRoutes(fastify) {
if (["executed", "cancelled"].includes(batch.status)) { if (["executed", "cancelled"].includes(batch.status)) {
throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409); throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409);
} }
if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason, fastify.config); if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason, fastify.config, input);
const openItemIds = batch.items const openItemIds = batch.items
.filter((item) => ["pending", "failed"].includes(item.status)) .filter((item) => ["pending", "failed"].includes(item.status))
.map((item) => item.id); .map((item) => item.id);
+6 -4
查看文件
@@ -236,7 +236,7 @@ async function settleLoanRepayment(tx, batch, request, config) {
await auditLoan(tx, request, "LOAN_PRINCIPAL_SETTLED", repayment.id, { paymentBatchId: batch.id, allocationCount: repayment.allocations.length }); await auditLoan(tx, request, "LOAN_PRINCIPAL_SETTLED", repayment.id, { paymentBatchId: batch.id, allocationCount: repayment.allocations.length });
} }
async function cancelLoanRepayment(tx, batch, request, reason, config) { async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
// Stale identity/account must not prevent cancelling a never-sent request. // Stale identity/account must not prevent cancelling a never-sent request.
const repayment = await readLoanRepayment(tx, batch); const repayment = await readLoanRepayment(tx, batch);
if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) || if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
@@ -250,9 +250,10 @@ async function cancelLoanRepayment(tx, batch, request, reason, config) {
await tx.loanBankTerminalEvidence.count({ where: { payment_batch_id: batch.id } }))) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; await tx.loanBankTerminalEvidence.count({ where: { payment_batch_id: batch.id } }))) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
// Release re-validates the conclusion inside this transaction (batch lock held // Release re-validates the conclusion inside this transaction (batch lock held
// by the caller serializes it with new submissions and receipt uploads). // by the caller serializes it with new submissions and receipt uploads).
if (repayment.bank_result_state === "confirmed_not_paid") { const recovered = await require("./loanReleaseRecoveries").consumeRecovery(tx, config, batch, request, recoveryInput);
if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch); await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch);
} else if (repayment.bank_result_state === "confirmed_failed") { } else if (!recovered && repayment.bank_result_state === "confirmed_failed") {
const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, ""); const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, "");
const verification = verificationId && await tx.paymentReceiptVerification.findUnique({ where: { id: verificationId } }); const verification = verificationId && await tx.paymentReceiptVerification.findUnique({ where: { id: verificationId } });
if (!verification) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; if (!verification) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
@@ -409,7 +410,8 @@ async function plannedLoanClaim(tx, config, batch) {
async function assertAttributableBatch(tx, batch, { requireCompetitors = true } = {}) { async function assertAttributableBatch(tx, batch, { requireCompetitors = true } = {}) {
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true }); const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (repayment.bank_result_state === "confirmed_failed" || repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
if (repayment.bank_result_state === "confirmed_failed") await require("./loanReleaseRecoveries").assertRecoveryCollecting(tx, batch);
const competitors = await competingLoanBatches(tx, batch, repayment); const competitors = await competingLoanBatches(tx, batch, repayment);
if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); }; if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); };
return { repayment, competitors }; return { repayment, competitors };
+1 -1
查看文件
@@ -95,7 +95,7 @@ async function proposeTerminalFact(tx, config, batch, request, input) {
const { readLoanRepayment } = loanPayments(); const { readLoanRepayment } = loanPayments();
const repayment = await readLoanRepayment(tx, batch); const repayment = await readLoanRepayment(tx, batch);
assertTerminalBatch(batch, repayment); assertTerminalBatch(batch, repayment);
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); if (repayment.bank_result_state !== "unknown") await require("./loanReleaseRecoveries").assertRecoveryCollecting(tx, batch);
if (!TERMINAL_KINDS.includes(input.kind)) throw buildBusinessValidationError("loan.invalid_input", {}, 400); if (!TERMINAL_KINDS.includes(input.kind)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
await supersedeStaleTerminalFacts(tx, config, batch, request); await supersedeStaleTerminalFacts(tx, config, batch, request);
const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } }); const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } });
@@ -0,0 +1,209 @@
const { createHash } = require("node:crypto");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { getErrorReason } = require("../constants/errorReasons");
const { auditLoan } = require("./companyLoans");
const payments = () => require("./companyLoanPayments");
const terminal = () => require("./loanBankTerminalFacts");
const fail = (code) => { throw buildBusinessValidationError(getErrorReason(code).code, {}, 409); };
const OPEN = ["collecting", "proposed", "approved"];
// JSONB does not preserve object key order. Canonicalize keys both before
// persistence and after reading; array order is deliberately significant.
const digest = (value) => createHash("sha256").update(JSON.stringify(value, (_key, item) => {
if (typeof item === "bigint") return String(item);
return item && typeof item === "object" && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item;
})).digest("hex");
async function latestRecovery(tx, batchId) {
return tx.loanReleaseRecovery.findFirst({ where: { payment_batch_id: batchId }, orderBy: { sequence: "desc" } });
}
async function recoverable(tx, batch) {
const repayment = await payments().readLoanRepayment(tx, batch);
if (!["reviewed", "failed"].includes(batch.status) ||
!["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
!repayment.allocations.length || repayment.allocations.some((a) => a.reservation_state !== "reserved")) fail("loan.recovery_unavailable");
return repayment;
}
// Only the current collecting round opens terminal-fact entry. It never opens
// export, payment execution, or the historical bank-result fields.
async function assertRecoveryCollecting(tx, batch) {
await recoverable(tx, batch);
const latest = await latestRecovery(tx, batch.id);
if (latest?.status !== "collecting") fail("loan.recovery_required");
return latest;
}
async function participants(tx, batch, repayment) {
const rounds = await tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id } });
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } }, select: { proposed_by: true } });
return [...new Set([...(await terminal().terminalParticipants(tx, batch, repayment)),
...rounds.flatMap((r) => [r.started_by, r.submitted_by]), ...manual.map((m) => m.proposed_by)].filter(Boolean))].sort();
}
// Stored snapshot contains identifiers/digests, never decrypted bank data.
// The database revision covers every material writer (including OCR workers).
// Candidates are re-read under the lender lock, even when another batch changed.
async function snapshot(tx, batch) {
const repayment = await payments().readLoanRepayment(tx, batch);
const [revision, exports, submissions, lists, facts, evidences, receipts, attributions, competitors] = await Promise.all([
tx.loanReleaseMaterialRevision.findUnique({ where: { payment_batch_id: batch.id } }),
tx.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankSubmission.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankSubmissionAttestation.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" },
include: { verifications: { orderBy: { id: "asc" } }, manual_proposals: { orderBy: { id: "asc" } } } }),
tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
payments().competingLoanBatches(tx, batch, repayment),
]);
const summarize = (rows) => rows.map((row) => ({ id: row.id, digest: digest(row) }));
return { revision: String(revision?.revision ?? 0), originalState: repayment.bank_result_state,
originalRef: repayment.bank_result_ref, payment: digest(batch.items.map((i) => ({ id: i.id, snapshot: i.snapshot_json, amount: i.amount }))),
allocations: repayment.allocations.map((a) => ({ id: a.id, loanId: a.loan_id, amount: String(a.amount), state: a.reservation_state })),
exports: summarize(exports), submissions: summarize(submissions), lists: summarize(lists), facts: summarize(facts),
evidences: summarize(evidences), receipts: summarize(receipts), attributions: summarize(attributions),
competitors, participants: await participants(tx, batch, repayment) };
}
async function unchanged(tx, batch, round) {
return Boolean(round.snapshot_json) && digest(round.snapshot_json) === digest(await snapshot(tx, batch));
}
async function event(tx, request, batch, round, action, note) {
await tx.loanReleaseRecoveryEvent.create({ data: { recovery_id: round.id, action, actor_id: request.employee.id, note } });
await auditLoan(tx, request, `LOAN_RELEASE_RECOVERY_${action.toUpperCase()}`, batch.loan_repayment_id,
{ paymentBatchId: batch.id, recoveryId: round.id });
}
async function startRecovery(tx, config, batch, request, input) {
const existing = await tx.loanReleaseRecovery.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
if (existing) {
if (existing.started_by !== request.employee.id || existing.reason !== input.reason) fail("loan.duplicate_registration");
return existing;
}
const repayment = await recoverable(tx, batch);
const previous = await latestRecovery(tx, batch.id);
if (previous && OPEN.includes(previous.status)) {
if (previous.status === "collecting" || await unchanged(tx, batch, previous)) fail("loan.recovery_exists");
await tx.loanReleaseRecovery.update({ where: { id: previous.id }, data: { status: "superseded", ended_at: new Date() } });
await event(tx, request, batch, previous, "superseded", "材料变化,另建恢复轮次");
}
const round = await tx.loanReleaseRecovery.create({ data: { payment_batch_id: batch.id, sequence: (previous?.sequence ?? 0) + 1,
previous_id: previous?.id ?? null, original_state: repayment.bank_result_state, original_ref: repayment.bank_result_ref,
request_key: input.requestKey, reason: input.reason, started_by: request.employee.id } });
await event(tx, request, batch, round, "started", input.reason);
return round;
}
async function currentRound(tx, batch, id) {
const round = await latestRecovery(tx, batch.id);
if (!round || round.id !== id) fail("loan.recovery_unavailable");
return round;
}
async function assertReleaseEvidence(tx, config, batch) {
await payments().validateLoanPayment(tx, batch, { lock: true, postPayment: true });
await recoverable(tx, batch);
await terminal().assertNotPaidReleasable(tx, config, batch);
// Read/hash all currently relied-on physical documents, not merely metadata.
const facts = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
const evidenceIds = facts.map((f) => f.evidence_id).filter(Boolean);
const evidence = await tx.loanBankTerminalEvidence.findMany({ where: { id: { in: evidenceIds } } });
const receipts = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id },
include: { verifications: true, manual_proposals: true } });
const { decryptStringOrThrow } = require("./crypto");
const { batchCandidates } = require("./paymentReceiptVerification");
const { matchBankSlip } = require("./paymentReceiptMatching");
for (const receipt of receipts) {
if (receipt.manual_proposals.some((p) => p.status !== "rejected")) fail("loan.receipt_unresolved_for_release");
for (const verification of receipt.verifications) {
const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted));
if (verification.status === "matched" || (recognition.pages ?? []).some((page) => page.transactionStatus === "success" &&
matchBankSlip(page, batchCandidates(config, batch)).status === "matched")) fail("loan.receipt_unresolved_for_release");
}
}
const { openReadStream } = require("./storage");
for (const file of [...evidence, ...receipts]) {
try {
const opened = await openReadStream({ config, storagePath: file.storage_path });
const hash = createHash("sha256"); let bytes = 0;
for await (const chunk of opened.stream) { bytes += chunk.length; hash.update(chunk); }
if (bytes !== file.file_size || hash.digest("hex") !== file.file_sha256) fail("loan.terminal_evidence_invalid");
} catch { fail("loan.terminal_evidence_invalid"); }
}
}
async function submitRecovery(tx, config, batch, request, id) {
const round = await currentRound(tx, batch, id);
if (round.status !== "collecting") fail("loan.recovery_unavailable");
if (round.started_by !== request.employee.id) throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
await assertReleaseEvidence(tx, config, batch);
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: "proposed", submitted_by: request.employee.id,
submitted_at: new Date(), snapshot_json: await snapshot(tx, batch) } });
await event(tx, request, batch, updated, "submitted", "补证完整,提交独立复核");
return updated;
}
async function reviewRecovery(tx, config, batch, request, id, input) {
const round = await currentRound(tx, batch, id);
if (round.status !== "proposed") fail("loan.recovery_unavailable");
const repayment = await recoverable(tx, batch);
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch, repayment), { lock: true });
if (input.decision === "approve") {
await assertReleaseEvidence(tx, config, batch);
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
}
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: input.decision === "approve" ? "approved" : "rejected",
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason,
...(input.decision === "reject" ? { ended_at: new Date() } : {}) } });
await event(tx, request, batch, updated, updated.status, input.reason);
return updated;
}
async function withdrawRecovery(tx, batch, request, id, reason) {
const round = await currentRound(tx, batch, id);
await recoverable(tx, batch);
if (!["collecting", "proposed"].includes(round.status)) fail("loan.recovery_unavailable");
if (round.started_by !== request.employee.id) throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: "withdrawn", ended_at: new Date() } });
await event(tx, request, batch, updated, "withdrawn", reason);
return updated;
}
// Called inside the existing cancel transaction, before allocations change.
// Once any round exists there is NO fallback to the original bank conclusion.
async function consumeRecovery(tx, config, batch, request, input) {
const round = await latestRecovery(tx, batch.id);
if (!round) {
if (input.recoveryId || input.requestKey) fail("loan.recovery_unavailable");
return false;
}
if (round.id !== input.recoveryId || round.status !== "approved" || !input.requestKey) fail("loan.recovery_required");
await assertReleaseEvidence(tx, config, batch);
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
const updated = await tx.loanReleaseRecovery.update({ where: { id: round.id }, data: { status: "consumed", ended_at: new Date(),
cancel_request_key: input.requestKey, consumed_by: request.employee.id } });
await event(tx, request, batch, updated, "consumed", "复核依据重验通过,原子取消释放");
return true;
}
async function isRecoveryCancelRetry(tx, batch, request, input) {
if (batch.status !== "cancelled" || !input.recoveryId || !input.requestKey) return false;
const round = await latestRecovery(tx, batch.id);
return round?.status === "consumed" && round.id === input.recoveryId && round.cancel_request_key === input.requestKey && round.consumed_by === request.employee.id;
}
async function recoveryState(tx, batch) {
const rows = await tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" } });
const current = rows[0];
const stale = current && ["proposed", "approved"].includes(current.status) && !await unchanged(tx, batch, current);
return { current: current ? { id: current.id, sequence: current.sequence, status: current.status, stale: Boolean(stale) } : null,
history: rows.map((r) => ({ id: r.id, sequence: r.sequence, status: r.status, reason: r.reason, startedBy: r.started_by,
submittedBy: r.submitted_by, reviewedBy: r.reviewed_by, reviewNote: r.review_note, originalState: r.original_state })) };
}
module.exports = { startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
isRecoveryCancelRetry, recoveryState, assertRecoveryCollecting, latestRecovery };
@@ -1,4 +1,9 @@
{ {
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
"loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。",
"integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。",
"loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。",
"loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。", "loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。",