From b63942221199be59e0a91dd8eecaca5c336a5f2b Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 6 Oct 2026 13:13:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E9=80=94=E8=BF=98?= =?UTF-8?q?=E6=AC=BE=E8=A2=AB=E5=87=BA=E5=80=9F=E4=BA=BA=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB=E5=B9=B6=E6=8C=89=E5=AE=A1=E6=89=B9=E6=97=B6?= =?UTF-8?q?=E8=B5=84=E6=A0=BC=E7=99=BB=E8=AE=B0=E4=BB=98=E6=AC=BE=E5=90=8E?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E=EF=BC=8809-17-company-loan-principal-repayme?= =?UTF-8?q?nt=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A:出借人存在 reserved 额度占用时,身份关联与收款资料维护在出借人锁内 返回 409 loan.lender_in_flight;页面禁用并提示,修正账户弹窗误导文案 - B:回单 OCR 认领、人工回单参与人、核销按审批时资格认定,不再因原审批人 或出借员工事后离职/撤权、主数据停用而卡死;保留身份核实、版本与付款快照 一致、审批独立性及回单匹配校验;付款前环节仍校验当前资格 - 测试:隔离 PostgreSQL 合成全流程,含确定性并发交错、付款后独立性反例、 离职后经 OCR/人工复核核销、PC/手机按钮禁用;变异测试均变红 - 恢复手册:资金类数据恢复后先暂停付款,对照银行流水补齐并核对占用再开放 - design 按环节修订资格规则 注意:C(同金额回单传错批次错误核销)已复现但尚未修复,借款功能不具备 真实还款验收条件;未推送、未部署。 Co-Authored-By: Claude Opus 5.5 --- .../design.md | 7 +- .../implement.md | 28 +++ .../prd.md | 2 +- client/src/api/companyLoans.ts | 2 +- client/src/api/errorReasons.ts | 1 + client/src/pages/CompanyLoansPage.tsx | 9 +- docs/trialrun/OPS7运维交接Runbook.md | 7 + scripts/qa/company-loan-browser.cjs | 9 + scripts/qa/company-loan-http-fixture.cjs | 214 ++++++++++++++++-- server/src/constants/errorReasons.js | 1 + server/src/jobs/paymentReceiptOcrJob.js | 2 +- server/src/routes/companyLoans.js | 6 +- server/src/services/companyLoanPayments.js | 36 ++- server/src/services/companyLoans.js | 24 +- .../unit/fixtures/error-reason-messages.json | 1 + 15 files changed, 311 insertions(+), 38 deletions(-) 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 3145c7fd..9c18a994 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -35,7 +35,12 @@ ## 4. 权限与回单 -新台账沿财务全账读取与付款审查分离:财务管理台账,审批人只读其有权审批的申请及必要证据,普通员工无全账入口。制单人、提交人、出借收款人身份集合统一传给提交预检/批次复核/人工回单复核/登记重验;在职状态和业务权限不吃历史批准,admin通配不能替代独立身份。 +新台账沿财务全账读取与付款审查分离:财务管理台账,审批人只读其有权审批的申请及必要证据,普通员工无全账入口。制单人、提交人、出借收款人身份集合统一传给提交预检/批次复核/人工回单复核/登记重验;admin通配不能替代独立身份。 + +2026-10-05 修订(取代原“在职状态和业务权限不吃历史批准”):资格按环节区分。 +- 付款前(新建申请、批次复核、导出制单、确认银行失败):校验当前在职、当前角色/权限、出借人就绪(身份已核实、未归档、法人与银行主数据启用)及出借员工在职。 +- 付款后(回单 OCR 认领、人工回单参与人、核销登记):原审批人与出借员工的资格以审批时为准,不再复核其当前在职/角色,也不再复核法人、银行主数据当前启用状态。理由:银行已实际付出的事实不能因事后人员或主数据变化而无法登记,否则本金永久占用且未还本金多记。仍须满足:身份已核实;身份/账户版本与提交时一致(在途期间维护被 `loan.lender_in_flight` 阻止);付款快照收款资料一致;`reviewed_by/reviewed_at` 存在且审批人不是制单人、提交人或出借人关联员工(这三者之间可重合);回单的账号、金额、币种、状态与交易唯一性匹配。审批时资格的依据是复核路由当时已用当前角色校验并写入 `reviewed_by/reviewed_at`。 +- 当前操作人(上传、人工复核、执行、会计交接)始终按当前权限与独立性校验。 银行备注采用短本金语义,年份不一致时不生成误导性单一年份;长事由留OA。对现有建行制单模板使用显式业务用途映射,原报销用途不变。回单识别范围只有经过新来源快照和权限校验才可扩展,不能无条件对新业务调用腾讯;真实样本/云调用另批,未验证前不宣称自动支持。 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 0b4a9937..923d0193 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,31 @@ +## 2026-10-05 复核阻断 B1 修复与回单归属复现(用户批准 A/B/D/恢复手册 + C 复现调研;本地合成,未提交、未部署) + +背景:同模型只读复核(页面与剩余资金流程)结论 BLOCK:在途还款期间做出借人身份/账户维护会使 identity_version 变化,之后登记付款、取消、确认失败全部被拒,已付款项永久无法登记;导出后原审批人或出借员工离职同样卡死。另报回单批次归属未校验。 + +实施: +- A(`services/companyLoans.js` `assertLenderNotInFlight`,`routes/companyLoans.js` 两个维护接口):出借人锁内只要存在 `reserved` 分配即 409 `loan.lender_in_flight`。新建还款先锁出借人再占用,故与维护互斥;导出/上传不新增占用,不需改上传锁。出借人列表新增 `hasInFlightRepayment`,页面禁用两按钮并提示;账户弹窗误导文案已改。原因码已登记三处(server registry、client 映射、内容断言夹具)。 +- B(`services/companyLoanPayments.js` `postPayment`):回单 OCR 认领、人工回单参与人、核销三处按审批时资格认定——不再复核原审批人当前在职/角色、出借员工当前在职及主数据启用;仍校验身份已核实、身份/账户版本与提交时一致、付款快照收款资料一致、`reviewed_by/reviewed_at` 存在且审批人不是制单人/提交人/出借人关联员工(这三者之间可重合)。审批时资格依据:复核路由当时已用 `activeReviewer` 校验当前角色后写入 `reviewed_by/reviewed_at`。新建、复核、导出制单、确认银行失败保持当前资格校验;当前操作人仍须有当前权限与独立性。 +- D:`scripts/qa/company-loan-http-fixture.cjs` 原“在途改身份/改账户”两段按新规则重写(改用新出借人,原出借人因负例循环留有 unknown 占用、按设计不可维护),新增:在途(导出前后)维护被拒、取消/结清后可维护;维护与新建并发 3 轮不留陈旧版本占用;导出后原审批人与出借员工离职,经 OCR 与经人工复核两条路径均从上传回单走到核销成功;付款前导出仍要求审批人当前有资格。浏览器脚本新增 PC/手机在途时两按钮禁用。 +- 恢复手册:`docs/trialrun/OPS7运维交接Runbook.md` §5 增加资金类数据恢复后先暂停付款、对照银行流水补齐、核对余额与在途占用再开放。 + +验证(Docker 隔离 PostgreSQL,`--network none`,合成数据;需 `PAYMENT_ORDER_PYTHON_BIN` 指向 BIFF8 venv): +- `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`:73 迁移、9 组 DB 约束、HTTP 21 组、浏览器 5 组全部 PASS,清理 PASS。 +- 4 个源码变异(OCR/核销/人工复核改回当前资格、去掉在途拦截)均使测试变红,源码已恢复并逐字比对。 +- server `npm test` 226 文件 4452 通过;`prisma:validate`、client `typecheck`/`build`、UI strict、错误码合同、文档审计(0 error)、敏感扫描、`git diff --check` 通过。 +- 未覆盖:独立复核仍为同模型;真实银行/OCR 样本;生产存量(待核实)。 + +C 复现结果(合成全流程,测试只记录结果不设守卫): +- 同一出借人两笔 7.00 在途,P 的成功回单传到 Q:`matched`,Q 执行 200 核销成功——**错误核销成立**。 +- 旧 6.00 批次确认失败并取消后新建同额批次,旧批次晚到的成功回单传到新批次:`matched`,执行 200 核销成功——**错误核销成立**。 +- 调研:BIFF8 制单每行已有唯一客户流水号 `批次号-R00n`;借款附言固定为“归还借款本金”(报销附言含单号);OCR 只提取金额、币种、双方账号、状态和银行流水号,不提取客户流水号/附言;建行回单是否保留二者无仓库证据,需获准的真实回单样本确认。修法待用户确认,未实施。 + +同模型只读复核(同日,独立上下文):本轮 A、B 修复范围 PASS_WITH_NOTES,无阻断;**借款功能整体尚未通过完整验收**(C 错误核销已复现,仍阻断真实还款)。A 互斥成立(reserved 只经 `prepareLoanRepayment` 在出借人锁后创建;幂等重放不新建;迁移触发器禁止 settled/released 回退);B 未放宽过头(`reviewed_by` 全仓唯一写入点在复核路由且已过 `activeReviewer`;出借人无归档写入;停用主数据不降低伪造回单门槛)。按其第 4、5 条补强: +- 并发测试改为确定性交错:独立连接持出借人行锁,两请求按指定顺序排队后释放;新建在先 → 维护 409 `loan.lender_in_flight`,维护在先 → 两者 200 且新建采用新版本;两种顺序均无陈旧占用。“检查挪到加锁前”变异确定性变红。 +- 付款后独立性反例:把已匹配回单批次的 `reviewed_by` 改为制单人或出借员工后执行均 403,恢复后正常核销;“去掉 `assertApprovedIndependently`”变异变红。 +复核另记残余(未处理,计入下列阻塞):人工回单复核人在复核后、执行前离职会使执行被拒(与报销共用,推测可作废重传绕过,未验证);确认银行失败路径在出借员工离职后仍卡死,叠加 A 会长期冻结该出借人维护;付款后关闭提交人部门的付款批次灰度开关会拦执行(重新打开即可恢复)。 + +仍阻止真实还款验收:C 修复;unknown 状态人工解除、作废附件计数、晚到结果对账流程;B 未覆盖确认银行失败路径在出借员工离职后的卡死;AC5 真实样本;独立复核。 + ## 2026-09-18 共享付款回归与交接补充 09-06本地受管重验已覆盖报销主管/财务审批、财务同页进度、批次独立复核、回单人工更正/独立复核、付款核销及AP/票据归档两条主链;完整r6为3通过/1入口矩阵失败,矩阵另在r7通过。此证据支持共享付款旧来源回归,不代替本task真实银行样本、独立资金链审阅或公网验收。当前官方current在09-06,本task仍in_progress;上批review-candidate-r1保护保持。独立问题及真实批次/银行/K1/K2清单见retained-gates-closeout-20260917的independent-and-external-readiness.json,均未进行真实查询或操作。 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 78926c13..80f30a61 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -69,7 +69,7 @@ ## Current Status -T1–T4本地合成实施已完成,AC1–AC4、AC6–AC7有本地证据;AC5真实样本及AC8独立/恢复/发布条件保留开放。精确回执见implement顶部。未部署,不声称真实银行接受或真实批次已通知。 +T1–T4本地合成实施已完成,AC1–AC4、AC6–AC7有本地证据;AC5真实样本及AC8独立/恢复/发布条件保留开放。精确回执见implement顶部。2026-09-27 已随 `70af0c6b` 上线(借款迁移已应用);生产是否已有真实还款待核实,不声称真实银行接受或真实批次已通知。2026-10-05 同模型只读复核报出阻断 B1(在途还款被出借人维护卡死)与回单批次归属缺陷,处理进展见 implement 顶部;真实还款验收在其收口前不得进行。 ## Planning Status(历史时点,已由顶部实施接续替代) diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index fd720163..0f0f3573 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -4,7 +4,7 @@ import type { PaymentBatchDetail } from "./paymentBatches"; export type LoanLender = { id: string; name: string; legalEntityId: string; legalEntityName: string; employeeId: string | null; identityStatus: string; identityVersion: number; accountVersion: number; - bankInstitutionId: string; bankName: string; bankAccountMasked: string | null; accountUnavailable: boolean; + bankInstitutionId: string; bankName: string; bankAccountMasked: string | null; accountUnavailable: boolean; hasInFlightRepayment: boolean; }; export type LoanEvidence = { id: string; kind: string; originalName: string; fileSize: number; mimeType: string; diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index 5176e798..f9269c7b 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -17,6 +17,7 @@ export const ERROR_REASON_MESSAGES: Record = { "loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。", "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", + "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", diff --git a/client/src/pages/CompanyLoansPage.tsx b/client/src/pages/CompanyLoansPage.tsx index e9d7e511..a874c16b 100644 --- a/client/src/pages/CompanyLoansPage.tsx +++ b/client/src/pages/CompanyLoansPage.tsx @@ -133,8 +133,11 @@ export function CompanyLoansPage() { {tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) =>

{lender.name} · {lender.legalEntityName}

{lender.bankName} · {lender.bankAccountMasked ?? "账户不可用"} · {lender.identityStatus === "verified" ? "身份已核实" : "身份待核实"}

-
-
+ {lender.hasInFlightRepayment ?

有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。

: null} +
+
) : null} {tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) =>

{repayment.lenderName} · {repayment.repaymentNo}

@@ -150,7 +153,7 @@ export function CompanyLoansPage() { {({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record)[mode ?? ""]} {mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" : mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" : - mode === "account" ? "更新后须重新确认身份,原未付申请失效,请安全取消后重新申请。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"} + mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}
{error ?

{resolveErrorMessage(error)}

: null} {mode === "lender" ? <> diff --git a/docs/trialrun/OPS7运维交接Runbook.md b/docs/trialrun/OPS7运维交接Runbook.md index ac5f6502..3ed0a2e9 100644 --- a/docs/trialrun/OPS7运维交接Runbook.md +++ b/docs/trialrun/OPS7运维交接Runbook.md @@ -112,6 +112,13 @@ QAOPS8 恢复演练方案(执行前必须再次确认窗口): 5. 使用目标 commit 或部署包启动应用,执行 `/ready`、public bundle、登录、报销详情、附件下载和财务台账检查。 6. 记录 RTO/RPO 实测值、失败点、修正动作和是否允许进入 R7E 或上线窗口。 +资金类数据恢复后的开放条件(2026-10-05 起备份策略为部署前手动轻量快照,恢复点只到最近一次快照): + +1. 恢复旧快照或旧备份后,先暂停报销付款和公司借款还款的制单、付款登记及新还款申请,不对外宣布可用。 +2. 对照银行流水核实快照时间点之后发生的付款与还款事实,在系统内按正常流程补齐登记,不直接改库。 +3. 逐个出借人核对借款本金余额与在途额度占用(未结清的还款申请),确认没有因恢复而重新变成“可申请”的已还本金后,才开放还款与付款。 +4. 未完成以上核对前,任何还款申请一律视为可能重复付款,不得复核或制单。 + R7E 前演练口径:RTO/RPO 先作为演练实测值记录,不承诺正式 SLA;若无法在确认窗口内完成恢复验证,R7E Go/No-Go 必须判定为 Pause 或 No-Go。 QAOPS9 恢复演练 dry-run checklist(2026-05-16,先设计不执行生产恢复): diff --git a/scripts/qa/company-loan-browser.cjs b/scripts/qa/company-loan-browser.cjs index 93da27a0..3157e706 100644 --- a/scripts/qa/company-loan-browser.cjs +++ b/scripts/qa/company-loan-browser.cjs @@ -84,6 +84,15 @@ module.exports = async function runBrowser({ app, finance, ordinary, password, c assert.equal(overflow, false, `${mode} horizontal overflow`); await page.screenshot({ path: path.join(output, `${mode}-repayment.png`), fullPage: true }); checks.push(`${mode}_real_login_register_materials_confirm_submit_and_named_progress`); + await page.goBack(); + await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); + await page.screenshot({ path: path.join(output, `${mode}-lenders-after-submit.png`), fullPage: true }); + await page.getByRole('tab', { name: '出借人', exact: true }).click(); + const frozenCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) }); + await expect(frozenCard).toContainText('有还款正在办理'); + await expect(frozenCard.getByRole('button', { name: '核实身份关联' })).toBeDisabled(); + await expect(frozenCard.getByRole('button', { name: '更新收款资料' })).toBeDisabled(); + checks.push(`${mode}_in_flight_repayment_disables_lender_maintenance`); await context.close(); } const context = await browser.newContext({ serviceWorkers: 'block' }); diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index fdc0a2dc..0f68f699 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -297,32 +297,204 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } }); assert.equal(reviewerDownload.statusCode, 200); await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403); - const { item: stale } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] }); - await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { - expectedVersion: verified.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成收款人关联经理', - }); - await call(manager, 'POST', `/payment-batches/${stale.id}/review`, {}, 403); + // Fresh lenders below: the first lender keeps unknown-state reservations from + // the negative receipt loop above, which by design block its maintenance. + async function verifiedLender(name, employeeId, digit) { + const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name, + bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: digit.repeat(16), accountName: name }); + const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, { + expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' }); + return { ...confirmedLender, bankAccount: digit.repeat(16) }; + } + async function confirmedLoan(lenderId, principal, ref) { + const created = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId, originReference: ref, + originalPrincipal: principal, openingOutstandingPrincipal: principal, receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true }); + for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, created.id); + await call(finance, 'POST', `/company-loans/${created.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }); + return created; + } + const repay = async (lenderId, loanId, amount) => (await call(finance, 'POST', '/loan-repayments', { + requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item; + async function exportOrder(batchId, status = 200, reason) { + const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} }); + assert.equal(response.statusCode, status, `export ${response.statusCode} ${status === 200 ? '' : response.body.slice(0, 200)}`); + if (reason) assert.equal(response.json().details?.reasonCode, reason); + } + const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id); const independent = await actor(['ceo']); - await call(independent, 'POST', `/payment-batches/${stale.id}/review`, {}, 409, 'loan.version_changed'); - await call(finance, 'POST', `/payment-batches/${stale.id}/cancel`, { reason: '合成未发起身份更正' }); - const { item: ownPayee } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] }); + + // Lender linked to a manager: that manager cannot approve, an independent CEO can. + const linked = await verifiedLender('合成出借人乙', manager.id, '4'); + const linkedLoan = await confirmedLoan(linked.id, '500.00', '合成乙借入'); + const ownPayee = await repay(linked.id, linkedLoan.id, '5.00'); await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403); await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {}); await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' }); - checks.push('scoped_reviewer_materials_payee_self_review_and_identity_version_guards'); - const { item: oldAccount } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }); - const changedAccount = await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, { - expectedVersion: lender.accountVersion, bankInstitutionId: bank.id, bankName: '合成变更开户行', - bankAccount: '3'.repeat(16), accountName: '合成出借人', - }); + checks.push('scoped_reviewer_materials_and_payee_self_review'); + + // B1: any reserved allocation freezes lender identity/account maintenance, + // before and after payment-order export, until the repayment settles or is cancelled. + const identityChange = (who, version, status, reason) => call(who, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, { + expectedVersion: version, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成在途改身份' }, status, reason); + const accountChange = (version, status, reason) => call(finance, 'PATCH', `/loan-lenders/${linked.id}/account`, { + expectedVersion: version, bankInstitutionId: bank.id, bankName: '合成变更开户行', bankAccount: '3'.repeat(16), accountName: '合成出借人乙' }, status, reason); + assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false); + const inFlight = await repay(linked.id, linkedLoan.id, '5.00'); + assert.equal((await lenderState(linked.id)).hasInFlightRepayment, true); + await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight'); + await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight'); + await call(independent, 'POST', `/payment-batches/${inFlight.id}/review`, {}); + await exportOrder(inFlight.id); + await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight'); + await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight'); + const inFlightReceipt = await receipt(inFlight.id, '500', 'SYNTHETIC-IN-FLIGHT', { payeeAccount: linked.bankAccount }); + assert.equal(inFlightReceipt.result.status, 'matched'); + assert.equal((await call(finance, 'POST', `/payment-batches/${inFlight.id}/execute`, {})).item.status, 'executed'); + assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false); + // A never-sent request can be cancelled to release the freeze. + const unsent = await repay(linked.id, linkedLoan.id, '5.00'); + await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight'); + await call(finance, 'POST', `/payment-batches/${unsent.id}/cancel`, { reason: '合成未发送取消' }); + checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export'); + + // Account change after release revokes identity and blocks new requests until re-verified. + const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion); assert.equal(changedAccount.identityStatus, 'unverified'); - await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.identity_required'); - await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { - expectedVersion: changedAccount.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成账户变更后重新核实', - }); - await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.version_changed'); - await call(finance, 'POST', `/payment-batches/${oldAccount.id}/cancel`, { reason: '合成旧账户未发起取消' }); - checks.push('account_changes_revoke_identity_and_invalidate_old_unpaid_snapshot'); + await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实', + allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] }, 409, 'loan.identity_required'); + await identityChange(finance, changedAccount.identityVersion); + checks.push('account_changes_revoke_identity_until_reverified'); + + // Maintenance racing creation, with deterministic interleaving: a separate + // connection holds the lender row lock, both requests queue behind it in a + // chosen order, then the lock is released. No reservation may keep stale versions. + const { Pool: LockPool } = req('pg'); + const lockPool = new LockPool({ ...pool.options, max: 2 }); + const lockWaiters = async () => Number((await lockPool.query( + "SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n); + async function untilWaiting(count) { + for (let i = 0; i < 200; i++) { if (await lockWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); } + throw new Error('request did not queue behind the lender lock'); + } + try { + for (const first of ['create', 'maintain']) { + const current = await lenderState(linked.id); + const holder = await lockPool.connect(); + let created; let maintained; + try { + await holder.query('BEGIN'); + await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [linked.id]); + const start = { create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: { + requestKey: randomUUID(), lenderId: linked.id, reason: '合成并发', allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] } }), + maintain: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${linked.id}/identity-confirmation`, headers: finance.headers, payload: { + expectedVersion: current.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成并发改身份' } }) }; + const second = first === 'create' ? 'maintain' : 'create'; + const firstResponse = start[first](); await untilWaiting(1); + const secondResponse = start[second](); await untilWaiting(2); + await holder.query('COMMIT'); + [created, maintained] = first === 'create' ? [await firstResponse, await secondResponse] : [await secondResponse, await firstResponse]; + } finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); } + if (first === 'create') { + assert.equal(created.statusCode, 200); + assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight'); + } else { + assert.equal(maintained.statusCode, 200, 'maintenance first succeeds'); + assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions'); + } + const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: linked.id } }); + const stale = await prisma.loanRepayment.count({ where: { lender_id: linked.id, allocations: { some: { reservation_state: 'reserved' } }, + OR: [{ lender_identity_version: { not: after.identity_version } }, { lender_account_version: { not: after.account_version } }] } }); + assert.equal(stale, 0, `no reserved repayment may hold stale lender versions (${first} first)`); + await call(finance, 'POST', `/payment-batches/${created.json().data.item.id}/cancel`, { reason: '合成并发结束' }); + } + } finally { await lockPool.end(); } + checks.push('lender_maintenance_and_creation_deterministic_interleaving_never_leaves_stale_reservation'); + + // B: after the bank paid, approval-time eligibility decides. A departed + // reviewer or lender employee must not strand settlement via OCR or manual review. + const lenderEmployee = await actor(['employee']); + const staffLender = await verifiedLender('合成出借人丙', lenderEmployee.id, '5'); + const staffLoan = await confirmedLoan(staffLender.id, '500.00', '合成丙借入'); + const setStatus = (who, status) => prisma.employee.update({ where: { id: who.id }, data: { status } }); + const outstanding = async () => (await call(finance, 'GET', `/company-loans/${staffLoan.id}`)).outstanding; + const reviewerA = await actor(['manager']); + const viaOcr = await repay(staffLender.id, staffLoan.id, '10.00'); + await call(reviewerA, 'POST', `/payment-batches/${viaOcr.id}/review`, {}); + await exportOrder(viaOcr.id); + await setStatus(reviewerA, 'archived'); await setStatus(lenderEmployee, 'archived'); + const ocrResult = await receipt(viaOcr.id, '1000', 'SYNTHETIC-POST-PAY-OCR', { payeeAccount: staffLender.bankAccount }); + assert.equal(ocrResult.result.status, 'matched', 'OCR must not fall back to legacy after departures'); + assert.equal((await call(finance, 'POST', `/payment-batches/${viaOcr.id}/execute`, {})).item.status, 'executed'); + assert.equal(await outstanding(), '490.00'); + await setStatus(lenderEmployee, 'active'); + const reviewerB = await actor(['manager']); + const viaManual = await repay(staffLender.id, staffLoan.id, '20.00'); + await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {}); + await exportOrder(viaManual.id); + await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived'); + const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }); + assert.equal(manualUnclear.result.status, 'needs_review'); + const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } }); + const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`; + const manualProposal = await call(finance, 'POST', manualPath, { expectedVersion: manualAttachment.receipt_ocr_version, + reason: '合成付款后人工核对', pages: [{ pageNumber: 1, itemId: viaManual.items[0].id, fields: Object.entries({ + 小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: staffLender.bankAccount, 凭证号: 'SYNTHETIC-POST-PAY-MANUAL', 单据状态: '交易成功', + }).map(([Name, Value]) => ({ Name, Value })) }] }); + const manualSaved = manualProposal.item ?? manualProposal; + await call(manager, 'POST', `${manualPath}/${manualSaved.id}/review`, { expectedVersion: manualSaved.version, decision: 'approve', reason: '合成当前独立复核' }); + assert.equal((await call(finance, 'POST', `/payment-batches/${viaManual.id}/execute`, {})).item.status, 'executed'); + assert.equal(await outstanding(), '470.00'); + await setStatus(lenderEmployee, 'active'); + checks.push('departed_reviewer_or_lender_employee_does_not_strand_paid_repayment_via_ocr_or_manual'); + // Approval-time independence is still enforced after payment: a recorded + // approver equal to the batch creator/submitter or lender employee is refused. + for (const [label, approverId] of [['creator', finance.id], ['lender_employee', lenderEmployee.id]]) { + const forged = await repay(staffLender.id, staffLoan.id, '2.00'); + await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {}); + await exportOrder(forged.id); + const forgedReceipt = await receipt(forged.id, '200', `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount }); + assert.equal(forgedReceipt.result.status, 'matched'); + await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: approverId } }); + await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {}, 403, 'loan.independent_review_required'); + await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: manager.id } }); + assert.equal((await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {})).item.status, 'executed'); + } + checks.push('post_payment_still_rejects_non_independent_recorded_approver'); + // Pre-payment export still requires the approver to be currently eligible. + const reviewerC = await actor(['manager']); + const prePayment = await repay(staffLender.id, staffLoan.id, '1.00'); + await call(reviewerC, 'POST', `/payment-batches/${prePayment.id}/review`, {}); + await setStatus(reviewerC, 'archived'); + await exportOrder(prePayment.id, 403, 'loan.independent_review_required'); + await setStatus(reviewerC, 'active'); + await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' }); + checks.push('pre_payment_export_keeps_current_approver_eligibility'); + + // C (reproduction only, outcome recorded, not yet a guarded behaviour): + // can a genuine receipt of one repayment settle a different same-amount batch? + const probeLender = await verifiedLender('合成出借人丁', null, '6'); + const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入'); + const reproductions = []; + async function probeExecute(batchId) { + const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} }); + return { status: response.statusCode, reasonCode: response.json().details?.reasonCode ?? null }; + } + const c1p = await repay(probeLender.id, probeLoan.id, '7.00'); + const c1q = await repay(probeLender.id, probeLoan.id, '7.00'); + for (const item of [c1p, c1q]) { await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); } + const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount }); + reproductions.push({ scenario: 'two_in_flight_same_amount_receipt_of_p_uploaded_to_q', match: c1.result.status, execute: await probeExecute(c1q.id) }); + const c2old = await repay(probeLender.id, probeLoan.id, '6.00'); + await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {}); + const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); + await call(manager, 'POST', `/payment-batches/${c2old.id}/bank-failure-review`, { attachmentId: c2fail.attachment.id }); + await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' }); + const c2new = await repay(probeLender.id, probeLoan.id, '6.00'); + await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id); + const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount }); + reproductions.push({ scenario: 'old_failed_cancelled_then_late_success_receipt_uploaded_to_new_same_amount', match: c2.result.status, execute: await probeExecute(c2new.id) }); + console.log(JSON.stringify({ stage: 'loan_receipt_attribution_reproduction', reproductions })); + checks.push('receipt_batch_attribution_reproduction_recorded'); if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma }); } diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 8840aa9f..698d70a6 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -17,6 +17,7 @@ const ERROR_REASONS = [ {"code":"loan.bank_state_unsafe","domain":"loan","audience":"user","defaultMessage":"银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。","recovery":"follow_guidance"}, {"code":"loan.handoff_not_ready","domain":"loan","audience":"user","defaultMessage":"本金付款尚未成功登记,或已完成会计交接,请核对付款状态。","recovery":"follow_guidance"}, {"code":"loan.payment_not_ready","domain":"loan","audience":"user","defaultMessage":"还款申请或付款状态已变化,请刷新核对后继续。","recovery":"follow_guidance"}, + {"code":"loan.lender_in_flight","domain":"loan","audience":"user","defaultMessage":"该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。","recovery":"follow_guidance"}, {"code":"config.enabled_query_invalid","domain":"config","audience":"user","defaultMessage":"启用状态筛选无效,请重新选择后查询。","recovery":"follow_guidance"}, {"code":"config.document_type_invalid","domain":"config","audience":"user","defaultMessage":"单据类型标识不符合要求,请重新选择单据类型。","recovery":"follow_guidance"}, {"code":"document_sequence.type_unsupported","domain":"document_sequence","audience":"user","defaultMessage":"当前单据类型不支持编号,请联系管理员核对。","recovery":"follow_guidance"}, diff --git a/server/src/jobs/paymentReceiptOcrJob.js b/server/src/jobs/paymentReceiptOcrJob.js index 0af34f90..e4480c7f 100644 --- a/server/src/jobs/paymentReceiptOcrJob.js +++ b/server/src/jobs/paymentReceiptOcrJob.js @@ -80,7 +80,7 @@ async function claimReceiptJob(prisma) { return null; } if (batch.entity_type === "loan_repayment") { - try { await require("../services/companyLoanPayments").validateLoanPayment(tx, batch); } + try { await require("../services/companyLoanPayments").validateLoanPayment(tx, batch, { postPayment: true }); } catch (error) { if (!error?.details?.reasonCode?.startsWith("loan.")) throw error; await tx.paymentBatchReceiptAttachment.updateMany({ diff --git a/server/src/routes/companyLoans.js b/server/src/routes/companyLoans.js index 3437516b..fa1b9a6d 100644 --- a/server/src/routes/companyLoans.js +++ b/server/src/routes/companyLoans.js @@ -9,6 +9,7 @@ const { LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan, readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender, assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently, + assertLenderNotInFlight, inFlightLenderIds, } = require("../services/companyLoans"); const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage"); const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy"); @@ -83,7 +84,8 @@ async function companyLoansRoutes(fastify) { skip: (query.page - 1) * query.pageSize, take: query.pageSize }), fastify.prisma.loanLender.count({ where }), ]); - return sendData(reply, request, { items: items.map((item) => mapLender(fastify.config, item)), total }); + const inFlight = await inFlightLenderIds(fastify.prisma, items.map((item) => item.id)); + return sendData(reply, request, { items: items.map((item) => mapLender(fastify.config, item, { inFlight: inFlight.has(item.id) })), total }); }); fastify.post("/loan-lenders", auth, async (request, reply) => { @@ -115,6 +117,7 @@ async function companyLoansRoutes(fastify) { const item = await fastify.prisma.$transaction(async (tx) => { const lender = await lockLender(tx, id); assertCurrentVersion(lender.identity_version, body.expectedVersion); + await assertLenderNotInFlight(tx, id); if (body.employeeId && !await tx.employee.findFirst({ where: { id: body.employeeId, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); } @@ -138,6 +141,7 @@ async function companyLoansRoutes(fastify) { const item = await fastify.prisma.$transaction(async (tx) => { const lender = await lockLender(tx, id); assertCurrentVersion(lender.account_version, body.expectedVersion); + await assertLenderNotInFlight(tx, id); await validateLenderReferences(tx, { ...body, legalEntityId: lender.legal_entity_id }); const result = await tx.loanLender.update({ where: { id }, data: { bank_institution_id: body.bankInstitutionId, bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body), diff --git a/server/src/services/companyLoanPayments.js b/server/src/services/companyLoanPayments.js index f2ac9616..109d2bcc 100644 --- a/server/src/services/companyLoanPayments.js +++ b/server/src/services/companyLoanPayments.js @@ -14,8 +14,12 @@ const REPAYMENT_INCLUDE = { }; -function assertLenderReady(lender) { +function assertLenderIdentityVerified(lender) { if (lender?.identity_status !== "verified" || !lender.identity_confirmed_at || !lender.identity_confirmed_by) { throw buildBusinessValidationError("loan.identity_required", {}, 409); }; +} + +function assertLenderReady(lender) { + assertLenderIdentityVerified(lender); if (lender.archived_at || !lender.legal_entity?.enabled || lender.legal_entity.archived_at || !lender.bank_institution?.enabled || lender.bank_institution.archived_at || lender.bank_institution.code !== "CCB") { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); }; } @@ -52,14 +56,31 @@ async function readLoanRepayment(tx, batch) { return repayment; } -async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false } = {}) { +// Approval-time eligibility for facts that already happened at the bank. +// The review route ran activeReviewer() against current roles when it set +// reviewed_by/reviewed_at, so a later departure or role change of that reviewer +// (or of the lender's linked employee) must not strand a paid repayment. +// Independence is still checked against the frozen participants. +function assertApprovedIndependently(batch, excludedIds) { + if (!batch.reviewed_by || !batch.reviewed_at || excludedIds.includes(batch.reviewed_by)) { + throw buildBusinessValidationError("loan.independent_review_required", {}, 403); + } +} + +// postPayment=true is for receipt OCR, manual receipt review and settlement: +// identity/account versions and the frozen payee snapshot must still match +// (maintenance is blocked while reserved, see assertLenderNotInFlight), but +// current employment, roles and master-data enablement are not re-judged. +// Pre-payment paths (creation, review, payment-order export) keep current checks. +async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false, postPayment = false } = {}) { let repayment = await readLoanRepayment(tx, batch); if (lock) { await lockLender(tx, repayment.lender_id); repayment = await readLoanRepayment(tx, batch); } const lender = repayment.lender; - assertLenderReady(lender); + if (postPayment) assertLenderIdentityVerified(lender); + else assertLenderReady(lender); if (lender.identity_version !== repayment.lender_identity_version || lender.account_version !== repayment.lender_account_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; if (repayment.legal_entity_id !== batch.legal_entity_id || repayment.currency_code !== "CNY" || !new Prisma.Decimal(repayment.amount).eq(batch.items[0].amount) || batch.items[0].currency_code !== "CNY" || @@ -68,7 +89,8 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, batch.items[0].snapshot_json?.payee?.accountNameEncrypted !== lender.account_name_encrypted) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; const excluded = [batch.created_by, repayment.submitted_by, lender.employee_id].filter(Boolean); if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock }); - if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock }); + if (postPayment) assertApprovedIndependently(batch, excluded); + else if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock }); if (lock) { const ids = repayment.allocations.map((a) => a.loan_id).sort(); if (!ids.length) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; @@ -81,7 +103,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, assertEvidenceComplete(loan); if (new Prisma.Decimal(loanBalances(loan).available).lt(0)) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); }; } - if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); }; + if (!postPayment && lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); }; return repayment; } @@ -148,7 +170,7 @@ function repaymentMatchesInput(repayment, input) { } async function settleLoanRepayment(tx, batch, request) { - const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true }); + const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true }); if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" }, @@ -175,7 +197,7 @@ async function cancelLoanRepayment(tx, batch, request, reason) { } async function loanReceiptParticipants(tx, batch) { - const repayment = await validateLoanPayment(tx, batch); + const repayment = await validateLoanPayment(tx, batch, { postPayment: true }); return [{ ownerId: repayment.lender.employee_id, externalOwnerVerified: repayment.lender.employee_id === null, submitterId: repayment.submitted_by }]; } diff --git a/server/src/services/companyLoans.js b/server/src/services/companyLoans.js index 4ab08634..4d906341 100644 --- a/server/src/services/companyLoans.js +++ b/server/src/services/companyLoans.js @@ -57,7 +57,7 @@ function encryptLenderAccount(config, input) { } } -function mapLender(config, lender) { +function mapLender(config, lender, { inFlight = false } = {}) { let bankAccountMasked = null; let accountUnavailable = false; try { bankAccountMasked = maskBankAccount(readLenderAccount(config, lender).bankAccount); } catch { accountUnavailable = true; } @@ -66,7 +66,7 @@ function mapLender(config, lender) { legalEntityName: lender.legal_entity?.name ?? null, employeeId: lender.employee_id, identityStatus: lender.identity_status, identityVersion: lender.identity_version, accountVersion: lender.account_version, bankInstitutionId: lender.bank_institution_id, - bankName: lender.bank_name, bankAccountMasked, accountUnavailable, + bankName: lender.bank_name, bankAccountMasked, accountUnavailable, hasInFlightRepayment: inFlight, }; } @@ -105,6 +105,25 @@ async function lockLender(tx, id) { return tx.loanLender.findUniqueOrThrow({ where: { id }, include: LENDER_INCLUDE }); } +// Any reserved allocation means a repayment froze this lender's identity and +// account versions. Creation locks the lender before reserving, so checking +// under the same lender lock serializes with it; later batch steps never add +// reservations. Changing the lender then would strand an already-paid request. +async function assertLenderNotInFlight(tx, lenderId) { + const reserved = await tx.loanRepaymentAllocation.count({ where: { + reservation_state: "reserved", repayment: { lender_id: lenderId }, + } }); + if (reserved > 0) throw buildBusinessValidationError("loan.lender_in_flight", {}, 409); +} + +async function inFlightLenderIds(db, lenderIds) { + if (!lenderIds.length) return new Set(); + const rows = await db.loanRepayment.findMany({ where: { + lender_id: { in: lenderIds }, allocations: { some: { reservation_state: "reserved" } }, + }, select: { lender_id: true }, distinct: ["lender_id"] }); + return new Set(rows.map((row) => row.lender_id)); +} + function assertEvidenceComplete(loan) { const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind)); const required = ["agreement", "incoming", "accounting_confirmation"]; @@ -146,4 +165,5 @@ module.exports = { LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan, loanBalances, readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender, assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently, + assertLenderNotInFlight, inFlightLenderIds, }; diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index f7153360..e449b865 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -16,6 +16,7 @@ "loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。", "loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。", "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", + "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。",