diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 624c6c94..9edcd1c9 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -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. - 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. +- 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. diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md index 7b209f50..b5ddf7fe 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -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-10-06 失败回单归属与银行结果对账方案(第 3 版,待用户审阅,未实施) diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index 8465a4b2..1bb1a700 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -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 本地实施记录(用户批准本地实施、针对性验证与只读复核,并要求纳入三条补充要求;未提交、未推送、未部署、未查询生产) 迁移 `20261008010000_loan_bank_terminal_facts`: diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index 42647ac5..8478bfa5 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -64,6 +64,10 @@ - [ ] AC15(追回,3.5):凭到账证据经独立复核登记,支持部分追回;仅登记不解冻;本期不支持冲抵。 - [ ] AC16(存量):历史批次保留已知的导出次数,新增“历史导出汇总”标明已知与待核实信息;它不是一次真实导出或银行提交,不能据此放行额度;上线前须核实生产存量(S0)。 +- [ ] AC17(补证恢复,SR1–SR3;方案已批准,后端验证见 implement):已确认失败或未付款且本金仍全部占用时,可补充有效证据并重新独立复核;原结论与依据不可改,旧证据/复核可追溯;恢复不得重开付款、导出或改变本金,不适用于已释放/已结清、成功事实或 Q6 人工未提交。 +- [ ] AC18(恢复批准有效性,SR2–SR4;后端验证见 implement):材料、核验版本、提交清单、归属或同额候选变化使旧恢复批准失效;新建再撤回、换回同一流水号、撤回/驳回恢复均不能复活旧批准或回退原释放依据;补证后能重新送审,不永久卡死;当前复核人合格且与全部相关经办身份独立。 +- [ ] AC19(最终恢复释放,SR3–SR5;后端验证见 implement):确认与最终取消均满足 S3 全部条件及等待期;最终事务重验最新恢复批准与证据,释放、取消、消费批准、审计原子且幂等;与证据/提交/核验/候选变化确定性交错时不错误释放;成功事实转对账、未知继续占用。PC/手机完整操作与真实银行接受性分别验收。 + ## Out of Scope - 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、退款或债务冲销。 diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index c0efbbbd..d3037408 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -1,4 +1,9 @@ export const ERROR_REASON_MESSAGES: Record = { + "loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。", + "loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。", + "loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。", + "loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。", + "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", "loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。", diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index fdb785fe..af0ad54a 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -202,7 +202,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { 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: {} }); - 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((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'); @@ -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.delete({ where: { id: fact1.id } })); 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; // History guards in the database. diff --git a/scripts/qa/company-loan-recovery-fixture.cjs b/scripts/qa/company-loan-recovery-fixture.cjs new file mode 100644 index 00000000..1f42c863 --- /dev/null +++ b/scripts/qa/company-loan-recovery-fixture.cjs @@ -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; +}; diff --git a/server/prisma/migrations/20261008020000_loan_release_recoveries/migration.sql b/server/prisma/migrations/20261008020000_loan_release_recoveries/migration.sql new file mode 100644 index 00000000..0c412e0f --- /dev/null +++ b/server/prisma/migrations/20261008020000_loan_release_recoveries/migration.sql @@ -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(); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1d8710bf..ee3d80d8 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -745,6 +745,12 @@ model Employee { loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer") loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer") @@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 { @@ -1992,6 +1998,9 @@ model PaymentBatch { loan_terminal_facts LoanBankTerminalFact[] loan_failure_evidence_uses LoanFailureEvidenceUse[] @@map("payment_batches") + loan_release_recoveries LoanReleaseRecovery[] + loan_material_revision LoanReleaseMaterialRevision? + } model PaymentBatchItem { @@ -4180,3 +4189,58 @@ model LoanFailureEvidenceUse { @@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") +} diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index c30cf4e7..6ea59cc0 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -1,4 +1,9 @@ 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":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"}, {"code":"loan.not_found","domain":"loan","audience":"user","defaultMessage":"借款记录不存在或已不可用,请刷新后重新选择。","recovery":"follow_guidance"}, diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index c02f27d1..42d7ea5d 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -6,6 +6,7 @@ const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRe proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments"); const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation, reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions"); +const releaseRecoveries = require("../services/loanReleaseRecoveries"); const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, reviewTerminalFact, confirmNotPaid, terminalState } = require("../services/loanBankTerminalFacts"); 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 () => { const repayment = await readLoanRepayment(tx, batch); 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, { includePayee: true, @@ -1511,6 +1512,8 @@ async function paymentBatchesRoutes(fastify) { if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); await ensureFeatureEnabled(fastify); 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); } }); } @@ -1552,6 +1555,37 @@ async function paymentBatchesRoutes(fastify) { const result = await attributionTransaction(request, (tx, batch) => reviewSubmissionAttestation(tx, batch, request, attestationId, body)); 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) => { const files = []; try { @@ -2956,9 +2990,14 @@ async function paymentBatchesRoutes(fastify) { await ensureFeatureEnabled(fastify); ensureBatchManagePermission(fastify, request); 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 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, { ...batch, items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)), @@ -2966,7 +3005,7 @@ async function paymentBatchesRoutes(fastify) { if (["executed", "cancelled"].includes(batch.status)) { 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 .filter((item) => ["pending", "failed"].includes(item.status)) .map((item) => item.id); diff --git a/server/src/services/companyLoanPayments.js b/server/src/services/companyLoanPayments.js index 609795f6..63836573 100644 --- a/server/src/services/companyLoanPayments.js +++ b/server/src/services/companyLoanPayments.js @@ -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 }); } -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. const repayment = await readLoanRepayment(tx, batch); 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); }; // Release re-validates the conclusion inside this transaction (batch lock held // 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); - } 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 verification = verificationId && await tx.paymentReceiptVerification.findUnique({ where: { id: verificationId } }); 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 } = {}) { 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 }); - 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); if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); }; return { repayment, competitors }; diff --git a/server/src/services/loanBankTerminalFacts.js b/server/src/services/loanBankTerminalFacts.js index 32d972f2..1f2b4a8d 100644 --- a/server/src/services/loanBankTerminalFacts.js +++ b/server/src/services/loanBankTerminalFacts.js @@ -95,7 +95,7 @@ async function proposeTerminalFact(tx, config, batch, request, input) { const { readLoanRepayment } = loanPayments(); const repayment = await readLoanRepayment(tx, batch); 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); await supersedeStaleTerminalFacts(tx, config, batch, request); const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } }); diff --git a/server/src/services/loanReleaseRecoveries.js b/server/src/services/loanReleaseRecoveries.js new file mode 100644 index 00000000..7a0daafa --- /dev/null +++ b/server/src/services/loanReleaseRecoveries.js @@ -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 }; diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index d5fc088f..73e4171a 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -1,4 +1,9 @@ { + "loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。", + "loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。", + "loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。", + "loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。", + "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", "loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。",