修复在途还款被出借人维护卡死并按审批时资格登记付款后事实(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]>
这个提交包含在:
2026-10-06 13:13:30 +08:00
共同撰写人 Claude Opus 5.5
父节点 1989f666ac
当前提交 b639422211
共修改 15 个文件,包含 311 行新增和 38 行删除
+1 -1
查看文件
@@ -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;
+1
查看文件
@@ -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": "当前单据类型不支持编号,请联系管理员核对。",
+6 -3
查看文件
@@ -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>