修复在途还款被出借人维护卡死并按审批时资格登记付款后事实(09-17-company-loan-principal-repayment)
- A:出借人存在 reserved 额度占用时,身份关联与收款资料维护在出借人锁内 返回 409 loan.lender_in_flight;页面禁用并提示,修正账户弹窗误导文案 - B:回单 OCR 认领、人工回单参与人、核销按审批时资格认定,不再因原审批人 或出借员工事后离职/撤权、主数据停用而卡死;保留身份核实、版本与付款快照 一致、审批独立性及回单匹配校验;付款前环节仍校验当前资格 - 测试:隔离 PostgreSQL 合成全流程,含确定性并发交错、付款后独立性反例、 离职后经 OCR/人工复核核销、PC/手机按钮禁用;变异测试均变红 - 恢复手册:资金类数据恢复后先暂停付款,对照银行流水补齐并核对占用再开放 - design 按环节修订资格规则 注意:C(同金额回单传错批次错误核销)已复现但尚未修复,借款功能不具备 真实还款验收条件;未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -35,7 +35,12 @@
|
||||
|
||||
## 4. 权限与回单
|
||||
|
||||
新台账沿财务全账读取与付款审查分离:财务管理台账,审批人只读其有权审批的申请及必要证据,普通员工无全账入口。制单人、提交人、出借收款人身份集合统一传给提交预检/批次复核/人工回单复核/登记重验;在职状态和业务权限不吃历史批准,admin通配不能替代独立身份。
|
||||
新台账沿财务全账读取与付款审查分离:财务管理台账,审批人只读其有权审批的申请及必要证据,普通员工无全账入口。制单人、提交人、出借收款人身份集合统一传给提交预检/批次复核/人工回单复核/登记重验;admin通配不能替代独立身份。
|
||||
|
||||
2026-10-05 修订(取代原“在职状态和业务权限不吃历史批准”):资格按环节区分。
|
||||
- 付款前(新建申请、批次复核、导出制单、确认银行失败):校验当前在职、当前角色/权限、出借人就绪(身份已核实、未归档、法人与银行主数据启用)及出借员工在职。
|
||||
- 付款后(回单 OCR 认领、人工回单参与人、核销登记):原审批人与出借员工的资格以审批时为准,不再复核其当前在职/角色,也不再复核法人、银行主数据当前启用状态。理由:银行已实际付出的事实不能因事后人员或主数据变化而无法登记,否则本金永久占用且未还本金多记。仍须满足:身份已核实;身份/账户版本与提交时一致(在途期间维护被 `loan.lender_in_flight` 阻止);付款快照收款资料一致;`reviewed_by/reviewed_at` 存在且审批人不是制单人、提交人或出借人关联员工(这三者之间可重合);回单的账号、金额、币种、状态与交易唯一性匹配。审批时资格的依据是复核路由当时已用当前角色校验并写入 `reviewed_by/reviewed_at`。
|
||||
- 当前操作人(上传、人工复核、执行、会计交接)始终按当前权限与独立性校验。
|
||||
|
||||
银行备注采用短本金语义,年份不一致时不生成误导性单一年份;长事由留OA。对现有建行制单模板使用显式业务用途映射,原报销用途不变。回单识别范围只有经过新来源快照和权限校验才可扩展,不能无条件对新业务调用腾讯;真实样本/云调用另批,未验证前不宣称自动支持。
|
||||
|
||||
|
||||
@@ -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,均未进行真实查询或操作。
|
||||
|
||||
@@ -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(历史时点,已由顶部实施接续替代)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
||||
"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": "当前单据类型不支持编号,请联系管理员核对。",
|
||||
|
||||
@@ -133,8 +133,11 @@ export function CompanyLoansPage() {
|
||||
{tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) => <section key={lender.id} className="card-std space-y-3 p-4">
|
||||
<h2 className="font-semibold">{lender.name} · {lender.legalEntityName}</h2>
|
||||
<p className="break-words text-sm">{lender.bankName} · {lender.bankAccountMasked ?? "账户不可用"} · {lender.identityStatus === "verified" ? "身份已核实" : "身份待核实"}</p>
|
||||
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => open("identity", lender)}>核实身份关联</Button>
|
||||
<Button variant="outline" onClick={() => open("account", lender)}>更新收款资料</Button></div>
|
||||
{lender.hasInFlightRepayment ? <p id={`${lender.id}-in-flight`} className="text-sm text-amber-800">有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。</p> : null}
|
||||
<div className="flex flex-wrap gap-2"><Button variant="outline" disabled={lender.hasInFlightRepayment}
|
||||
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open("identity", lender)}>核实身份关联</Button>
|
||||
<Button variant="outline" disabled={lender.hasInFlightRepayment}
|
||||
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open("account", lender)}>更新收款资料</Button></div>
|
||||
</section>) : null}
|
||||
{tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) => <section key={repayment.id} className="card-std space-y-2 p-4">
|
||||
<h2 className="font-semibold break-all">{repayment.lenderName} · {repayment.repaymentNo}</h2>
|
||||
@@ -150,7 +153,7 @@ export function CompanyLoansPage() {
|
||||
{({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
|
||||
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" :
|
||||
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
|
||||
mode === "account" ? "更新后须重新确认身份,原未付申请失效,请安全取消后重新申请。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
|
||||
mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
|
||||
{mode === "lender" ? <><Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
|
||||
|
||||
@@ -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,先设计不执行生产恢复):
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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": "单据类型标识不符合要求,请重新选择单据类型。",
|
||||
|
||||
在新工单中引用
屏蔽一个用户