借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)

- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时,
  最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过,
  证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用
- 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、
  回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须
  当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、
  人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改
- 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选
  判断,核销之后才出现的新证据留待晚到结果对账流程处理
- 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认
- 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、
  PC 浏览器完整登记复核重登流程;变异测试均变红

注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认
失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-06 20:20:51 +08:00
共同撰写人 Claude Opus 5.5
父节点 b639422211
当前提交 b88790d396
共修改 13 个文件,包含 718 行新增和 31 行删除
@@ -1,3 +1,27 @@
## 2026-10-06 C 回单批次归属保守拦截(用户批准实施;本地合成,未提交、未部署)
规则:同一出借人、同金额的其他借款批次只要曾导出制单或上传回单(含确认失败后已取消的),本批次的匹配回单就不能证明归属。
- 最终拦截在 `settleLoanRepayment`(`assertReceiptAttributed`):执行事务内 `claimVerifiedReceipts` 已为本项生成交易指纹认领,OCR 匹配与已通过的人工回单复核都经过此处;只有绑定同一交易指纹、且覆盖当前全部候选批次的已复核归属确认才能放行,否则 409 `loan.receipt_attribution_required`,本金与占用不变。
- 确认银行失败:存在候选批次即 409,不释放占用(失败回单暂无归属确认路径,留待对账流程设计)。
- 归属确认与人工回单复核分离(新表 `loan_receipt_attributions`,迁移 `20261006010000_loan_receipt_attribution`):财务登记依据类型(客户方流水号、附言含批次号、银行书面查询结果、其他候选均已对应其他交易或确认未付;不含“时间吻合”)+ 依据说明 + 排除其他候选的理由;绑定 `claimVerifiedReceipts(readOnly)` 计划认领的交易指纹和当时候选集合。独立复核人须当前有资格,且不是登记人、制单人、提交人或出借人关联员工;批准时回单证据或候选集合变化即拒绝。出现新候选后原批准失效。DB:每批次至多一条待复核/已通过(部分唯一索引)、复核人≠登记人、已复核记录与提案字段不可改、禁止删除。
- 页面:还款详情显示候选批次与归属状态,财务登记、经理/总经理独立复核;说明“时间吻合只能作辅助说明”。
- 代价:此后同一出借人同金额的正常还款(如固定金额分期)都需走归属确认。精确关联(附言写入批次号、OCR 提取客户方流水号/附言)待获准的真实建行回单样本确认字段后再做。
验证:
- `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`:74 迁移,HTTP 27 组、浏览器 5 组、DB 约束全部 PASS。原两个复现用例改为回归断言:错误回单执行 409 且本金/占用不变;C2 经 OCR 与经人工回单复核均被拦。新增:正确回单在有候选时同样需归属确认、确认后核销成功;重复登记 409;“时间吻合”类型 400;登记人兼经理不能自审、驳回后仍拦;新候选出现使批准失效;歧义失败回单不能释放;无候选时不需也不接受归属确认;已复核记录 DB 层不可改/删。
- 5 个变异(去掉核销拦截、候选排除已取消批次、去掉失败拦截、覆盖校验恒真、允许登记人复核)均变红,源码已恢复。
- server 4456 通过;prisma validate、client typecheck/build、UI strict、错误码合同通过。
- 首轮同模型只读复核:BLOCK——已批准的归属一旦因新候选或换回单失效,既不能重新登记(exists)、也不能改(触发器)、又因已导出不能取消,占用永久卡死并阻塞出借人维护(复核人副本实测成立)。已修:新增终态 `superseded`(及 `superseded_at`),重新登记时同一事务内把失效的待复核/已通过记录标为已取代;触发器只放行 proposed→approved/rejected/superseded 与 approved→superseded,复核字段不可改。同轮按非阻断意见补:复核人另排除导出制单人与所认领回单的上传人;`receipt-readiness` 在需归属确认时返回 `ready:false, receiptAttributionRequired:true`;状态返回 `stale`(回单或候选已变化);页面在失效时允许重新登记、财务按钮要求 `loan.manage`,并写明同额分期每期都需确认、存在同额候选时失败回单不能释放额度须线下对账。新增回归:失效批准被重新登记取代后核销成功;换回单后旧批准不能放行、导出人兼经理不能复核、重新登记后核销成功;已通过不能改为驳回、已取代不能复活。3 个变异(不取代、复核不排除导出人、就绪不检查)均变红。
- 第二轮同模型只读复核:PASS_WITH_NOTES,死局已解除,无阻断。按其意见加固:复核人另排除同一交易所有回单的上传人及人工回单提案人;驳回不再依赖回单(无回单时待复核记录可关闭);无已核验回单时登记返回 `loan.receipt_attribution_unavailable`,状态返回 `receiptAvailable`,页面不显示无效的登记按钮;批次详情中的归属展示在回单重建异常(如密钥不一致)时降级而非 500;触发器禁止未复核记录写入复核字段;取代审计记录原因(交易变化/候选变化)。新增回归:人工回单提案人不能复核归属、独立人员批准后核销;回单作废后待复核记录可驳回、重新登记返回 unavailable。
- 后续项(第二轮非阻断 5):存在同额候选且银行确实失败时系统内无释放路径,需设计线下对账结论回写(如复用归属复核确认失败),与 unknown 解除、作废附件计数、晚到结果一并处理。
- 复核其余非阻断(未处理,记录):已执行的历史同额批次永远是候选,等额分期从第二期起每期需确认,且一旦银行真实失败将无法确认失败、释放占用(与“失败归属/对账流程”一并后续设计);部分唯一索引、CHECK、触发器不在 Prisma schema 中,今后 `migrate dev`/diff 可能误报漂移(推测,仓库已有 r46 先例);`ON DELETE RESTRICT` 使带归属记录的批次/员工无法物理删除(当前无此路径)。
- 第三轮补齐(用户转述的复核意见指出前述回执遗漏的三处):
- 证据绑定:归属记录新增 `verification_ids`、`manual_proposal_ids`(迁移内同表,提案后不可改),批准与核销均要求与当前计划认领的证据集合一致;同一交易重新扫描或重新人工录入即失效,取代审计原因记为 `receipt_evidence_changed`。核销处在执行事务内以只读方式重建计划认领比对。
- 并发窗口:借款批次回单上传在批次锁后取出借人锁(批次→出借人,与既有链一致),与核销的候选检查串行。确定性测试:独立连接持出借人锁,两种排队顺序分别验证“核销在先→200、随后上传成功”“上传在先→核销 409”。去掉上传锁的变异使排队断言确定性变红。
- 浏览器:PC 端两个独立登录会话完成财务登记→经理独立复核→新增同额候选后显示“须重新登记并复核”→重新登记→再次复核→核销 200;登记人看不到复核按钮、复核人看不到登记按钮。测试环境整页刷新会丢会话,改用每步新会话。
- 验证:HTTP 32 组、浏览器 7 组、74 迁移全部 PASS;去掉证据绑定、去掉上传出借人锁两个变异均变红;server 4456、prisma validate、client typecheck/build、UI strict、错误码合同、敏感扫描、diff 检查通过。
- 未覆盖:手机端未操作归属弹窗(与 PC 共用组件,仅 PC 浏览器覆盖);真实回单字段;独立复核仍为同模型。
## 2026-10-05 复核阻断 B1 修复与回单归属复现(用户批准 A/B/D/恢复手册 + C 复现调研;本地合成,未提交、未部署) ## 2026-10-05 复核阻断 B1 修复与回单归属复现(用户批准 A/B/D/恢复手册 + C 复现调研;本地合成,未提交、未部署)
背景:同模型只读复核(页面与剩余资金流程)结论 BLOCK:在途还款期间做出借人身份/账户维护会使 identity_version 变化,之后登记付款、取消、确认失败全部被拒,已付款项永久无法登记;导出后原审批人或出借员工离职同样卡死。另报回单批次归属未校验。 背景:同模型只读复核(页面与剩余资金流程)结论 BLOCK:在途还款期间做出借人身份/账户维护会使 identity_version 变化,之后登记付款、取消、确认失败全部被拒,已付款项永久无法登记;导出后原审批人或出借员工离职同样卡死。另报回单批次归属未校验。
+12
查看文件
@@ -22,6 +22,18 @@ export type LoanRepayment = {
bankResultState: string; paymentBatchId: string; status: string; bankResultState: string; paymentBatchId: string; status: string;
accountingHandoffRef: string | null; accountingHandoffAt: string | null; accountingHandoffRef: string | null; accountingHandoffAt: string | null;
allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>; allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>;
receiptAttribution?: LoanReceiptAttributionState;
};
export type LoanAttributionBasisKind = "customer_serial" | "bank_memo" | "bank_written_confirmation" | "competitors_resolved";
export type LoanReceiptAttributionState = {
required: boolean;
competitors: Array<{ id: string; batchNo: string; status: string }>;
receiptAvailable: boolean | null;
current: null | {
id: string; status: "proposed" | "approved" | "rejected" | "superseded"; basisKind: LoanAttributionBasisKind; basisNote: string; exclusionNote: string;
proposedBy: string; proposedByName: string | null; proposedAt: string; reviewedByName: string | null; reviewedAt: string | null;
reviewNote: string | null; coversCurrentCompetitors: boolean; stale: boolean;
};
}; };
export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) { export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) {
return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal }); return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal });
+4
查看文件
@@ -18,6 +18,10 @@ export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
"loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。",
"loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。",
"loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。",
"loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。",
"loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。",
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",
"config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。",
"document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。",
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
import { downloadLoanEvidence, saveLoanAction, type LoanRepayment } from "../../api/companyLoans"; import { downloadLoanEvidence, saveLoanAction, type LoanAttributionBasisKind, type LoanRepayment } from "../../api/companyLoans";
import type { PaymentBatchReceiptAttachment } from "../../api/paymentBatches"; import type { PaymentBatchReceiptAttachment } from "../../api/paymentBatches";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
@@ -10,18 +10,33 @@ import { downloadBlob } from "../../lib/download";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { formatDisplayDate } from "../../lib/displayFormat"; import { formatDisplayDate } from "../../lib/displayFormat";
const attributionBasisLabels: Record<LoanAttributionBasisKind, string> = {
customer_serial: "回单所载客户方流水号与本批次制单流水号一致",
bank_memo: "回单附言或用途载明本批次号",
bank_written_confirmation: "银行书面查询结果指明该交易对应本批次指令",
competitors_resolved: "其他候选批次均已对应其他交易或经银行确认未付款",
};
const attributionStatusLabels = { proposed: "待独立复核", approved: "已通过独立复核", rejected: "已驳回", superseded: "已失效(已被新登记取代)" } as const;
export function LoanPaymentDetails({ repayment, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) { export function LoanPaymentDetails({ repayment, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) {
const { me } = useAuth(); const cache = useQueryClient(); const { me, permissions } = useAuth(); const cache = useQueryClient();
const active = useRef(true); const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
const [action, setAction] = useState<"handoff" | "failure" | "download" | null>(null); const [action, setAction] = useState<"handoff" | "failure" | "download" | "attribute" | "attributionReview" | null>(null);
const [basisKind, setBasisKind] = useState<LoanAttributionBasisKind | "">(""); const [exclusionNote, setExclusionNote] = useState("");
const [decision, setDecision] = useState<"approve" | "reject">("approve");
const [text, setText] = useState(""); const [attachmentId, setAttachmentId] = useState(""); const [text, setText] = useState(""); const [attachmentId, setAttachmentId] = useState("");
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
const finance = me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)); const finance = me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r));
const loanManager = Boolean(finance && permissions.includes("loan.manage"));
const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)); const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r));
const mutation = useMutation({ mutationFn: async () => { const mutation = useMutation({ mutationFn: async () => {
if (action === "handoff") await saveLoanAction(`/loan-repayments/${repayment.id}/accounting-handoff`, { reference: text }); if (action === "handoff") await saveLoanAction(`/loan-repayments/${repayment.id}/accounting-handoff`, { reference: text });
if (action === "failure") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/bank-failure-review`, { attachmentId }); if (action === "failure") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/bank-failure-review`, { attachmentId });
if (action === "attribute") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/loan-receipt-attribution`, { basisKind, basisNote: text, exclusionNote });
if (action === "attributionReview" && attribution?.current) {
await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/loan-receipt-attribution/${attribution.current.id}/review`, { decision, reason: text });
}
if (action === "download" && evidence) { if (action === "download" && evidence) {
const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId); const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId);
if (active.current) downloadBlob(result.blob, result.filename ?? evidence.name); if (active.current) downloadBlob(result.blob, result.filename ?? evidence.name);
@@ -30,12 +45,29 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
if (!active.current) return; if (!active.current) return;
setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] })));
} }); } });
function open(next: typeof action) { setText(""); setAttachmentId(""); mutation.reset(); setAction(next); } function open(next: typeof action) { setText(""); setAttachmentId(""); setBasisKind(""); setExclusionNote(""); setDecision("approve"); mutation.reset(); setAction(next); }
const attribution = repayment.receiptAttribution;
const openAttribution = attribution?.current && ["proposed", "approved"].includes(attribution.current.status) && !attribution.current.stale ? attribution.current : null;
const payable = ["reviewed", "failed"].includes(repayment.status);
return <section className="card-std space-y-3 p-4" aria-label="本金还款分配"> return <section className="card-std space-y-3 p-4" aria-label="本金还款分配">
<h2 className="font-semibold">原借款分配与会计交接</h2> <h2 className="font-semibold">原借款分配与会计交接</h2>
<p className="break-words text-sm">{repayment.reason}</p> <p className="break-words text-sm">{repayment.reason}</p>
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证;本金保持占用,不能取消释放或重复付款。</p> : null} {repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证;本金保持占用,不能取消释放或重复付款。</p> : null}
{repayment.bankResultState === "confirmed_failed" ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null} {repayment.bankResultState === "confirmed_failed" ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null}
{attribution?.required && payable ? <div className="space-y-2 rounded-md border border-amber-300 p-3 text-sm" aria-label="回单归属确认">
<p className="font-medium text-amber-800">回单归属待确认:同一出借人另有 {attribution.competitors.length} 笔同金额还款曾制单或上传回单({attribution.competitors.map((item) => item.batchNo).join("、")})。金额与账号一致不能证明回单属于本批次,确认前不能核销或释放额度。同一出借人同金额的每一期还款都需单独确认归属;存在同金额候选时,银行失败回单也不能释放额度,须线下对账。</p>
{attribution.current ? <div className="space-y-1">
<p>归属确认:{attributionStatusLabels[attribution.current.status]}{attribution.current.stale ? "(回单或候选批次已变化,须重新登记并复核)" : ""}</p>
<p className="break-words">依据:{attributionBasisLabels[attribution.current.basisKind]};{attribution.current.basisNote}</p>
<p className="break-words">排除其他批次的理由:{attribution.current.exclusionNote}</p>
<p>登记:{attribution.current.proposedByName ?? "—"}{attribution.current.reviewedByName ? ` · 复核:${attribution.current.reviewedByName}` : ""}{attribution.current.reviewNote ? `(${attribution.current.reviewNote})` : ""}</p>
</div> : null}
<div className="flex flex-wrap gap-2">
{attribution.receiptAvailable === false ? <p className="text-amber-800">尚无已核验的银行回单,须先完成回单识别或人工核对再登记归属。</p> : null}
{loanManager && !openAttribution && attribution.receiptAvailable !== false ? <Button variant="outline" onClick={() => open("attribute")}>登记回单归属依据</Button> : null}
{reviewer && attribution.current?.status === "proposed" && !attribution.current.stale && attribution.current.proposedBy !== me?.id ? <Button variant="outline" onClick={() => open("attributionReview")}>独立复核回单归属</Button> : null}
</div>
</div> : null}
{repayment.allocations.map((allocation) => <div key={allocation.loanId} className="space-y-2 rounded-md border p-3 text-sm"> {repayment.allocations.map((allocation) => <div key={allocation.loanId} className="space-y-2 rounded-md border p-3 text-sm">
<p className="break-all font-medium">{allocation.loanNo} · 本次归还 ¥{allocation.amount}</p> <p className="break-all font-medium">{allocation.loanNo} · 本次归还 ¥{allocation.amount}</p>
<p>借入 {formatDisplayDate(allocation.receivedDate)} · 核对截至 {formatDisplayDate(allocation.balanceAsOf)}</p> <p>借入 {formatDisplayDate(allocation.receivedDate)} · 核对截至 {formatDisplayDate(allocation.balanceAsOf)}</p>
@@ -49,11 +81,23 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
{reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? <Button variant="outline" onClick={() => open("failure")}>复核银行失败证据</Button> : null} {reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? <Button variant="outline" onClick={() => open("failure")}>复核银行失败证据</Button> : null}
</div> </div>
<Dialog open={action !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) setAction(null); }}> <Dialog open={action !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) setAction(null); }}>
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" : "下载原借款材料"}</DialogTitle> <DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" :
<DialogDescription>{action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader> action === "attribute" ? "登记回单归属依据" : action === "attributionReview" ? "独立复核回单归属" : "下载原借款材料"}</DialogTitle>
<DialogDescription>{action === "attribute" ? "写明回单中能指向本批次的依据,以及逐一排除其他候选批次的理由。交易时间与制单时间吻合只能作辅助说明,不能单独作为依据;证据不足请保持待对账。" : action === "attributionReview" ? "核对依据能否排除其他候选批次。登记人、批次创建人、提交人、导出制单人、回单上传人和出借人关联员工不能复核。" : action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader>
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}> <form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null} {mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
{action === "failure" ? <label className="grid gap-2 text-sm">银行失败回单<select required className="min-h-11 rounded border px-3" value={attachmentId} onChange={(e) => setAttachmentId(e.target.value)}> {action === "attribute" ? <>
<label className="grid gap-2 text-sm">归属依据类型<select required className="min-h-11 rounded border px-3" value={basisKind} onChange={(e) => setBasisKind(e.target.value as LoanAttributionBasisKind)}>
<option value="">请选择</option>{Object.entries(attributionBasisLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select></label>
<label className="grid gap-2 text-sm">依据说明<textarea required minLength={10} maxLength={1000} className="min-h-24 rounded border px-3 py-2" value={text} onChange={(e) => setText(e.target.value)} /></label>
<label className="grid gap-2 text-sm">排除其他候选批次的理由<textarea required minLength={10} maxLength={1000} className="min-h-24 rounded border px-3 py-2" value={exclusionNote} onChange={(e) => setExclusionNote(e.target.value)} /></label>
</> : action === "attributionReview" ? <>
<label className="grid gap-2 text-sm">复核结论<select required className="min-h-11 rounded border px-3" value={decision} onChange={(e) => setDecision(e.target.value as "approve" | "reject")}>
<option value="approve">依据充分,确认归属本批次</option><option value="reject">依据不足,驳回</option>
</select></label>
<label className="grid gap-2 text-sm">复核说明<Input required minLength={2} maxLength={500} value={text} onChange={(e) => setText(e.target.value)} /></label>
</> : action === "failure" ? <label className="grid gap-2 text-sm">银行失败回单<select required className="min-h-11 rounded border px-3" value={attachmentId} onChange={(e) => setAttachmentId(e.target.value)}>
<option value="">请选择</option>{attachments.filter((item) => !item.archivedAt).map((item) => <option key={item.id} value={item.id}>{item.originalName}</option>)} <option value="">请选择</option>{attachments.filter((item) => !item.archivedAt).map((item) => <option key={item.id} value={item.id}>{item.originalName}</option>)}
</select></label> : <label className="grid gap-2 text-sm">{action === "handoff" ? "会计凭证或交接引用" : "下载用途"}<Input required minLength={2} maxLength={action === "handoff" ? 200 : 500} value={text} onChange={(e) => setText(e.target.value)} /></label>} </select></label> : <label className="grid gap-2 text-sm">{action === "handoff" ? "会计凭证或交接引用" : "下载用途"}<Input required minLength={2} maxLength={action === "handoff" ? 200 : 500} value={text} onChange={(e) => setText(e.target.value)} /></label>}
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button> <Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
+61 -1
查看文件
@@ -6,7 +6,7 @@ const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'client/package.json')); const req = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = req('@playwright/test'); const { chromium, expect } = req('@playwright/test');
module.exports = async function runBrowser({ app, finance, ordinary, password, company, bank, prisma }) { module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi }) {
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-')); const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
const origin = await app.listen({ host: '127.0.0.1', port: 0 }); const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
@@ -95,6 +95,66 @@ module.exports = async function runBrowser({ app, finance, ordinary, password, c
checks.push(`${mode}_in_flight_repayment_disables_lender_maintenance`); checks.push(`${mode}_in_flight_repayment_disables_lender_maintenance`);
await context.close(); await context.close();
} }
if (attributionUi) {
// Two separate sessions: finance registers, an independent manager reviews.
async function session(who) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`/payment-batches?id=${attributionUi.batchId}`)}`);
await page.getByTestId('login-identifier').fill(who.email);
await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
const panel = page.getByLabel('回单归属确认');
await expect(panel).toContainText('回单归属待确认');
return { context, page, panel };
}
async function register(page, panel) {
await panel.getByRole('button', { name: '登记回单归属依据' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('归属依据类型').selectOption('customer_serial');
await dialog.getByLabel('依据说明').fill('合成浏览器:回单客户方流水号与本批次制单流水号一致');
await dialog.getByLabel('排除其他候选批次的理由').fill('合成浏览器:其他候选批次的制单流水号均不一致');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(panel).toContainText('待独立复核');
}
async function approve(page, panel) {
await expect(panel).toContainText('待独立复核');
await panel.getByRole('button', { name: '独立复核回单归属' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('复核结论').selectOption('approve');
await dialog.getByLabel('复核说明').fill('合成浏览器独立复核');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(panel).toContainText('已通过独立复核');
}
// Each step uses a fresh login: a full reload drops the session in this harness.
let view = await session(finance);
await expect(view.panel.getByRole('button', { name: '独立复核回单归属' })).toHaveCount(0);
await register(view.page, view.panel);
await expect(view.panel.getByRole('button', { name: '独立复核回单归属' })).toHaveCount(0);
await view.context.close();
view = await session(manager);
await expect(view.panel.getByRole('button', { name: '登记回单归属依据' })).toHaveCount(0);
await approve(view.page, view.panel);
await view.context.close();
checks.push('desktop_attribution_register_and_independent_review');
// A new same-amount competitor reaches the bank: the approval is stale and
// finance can re-register, then the manager reviews again.
await attributionUi.addCompetitor();
view = await session(finance);
await expect(view.panel).toContainText('须重新登记并复核');
await register(view.page, view.panel);
await view.context.close();
view = await session(manager);
await approve(view.page, view.panel);
await view.page.screenshot({ path: path.join(output, 'desktop-attribution.png'), fullPage: true });
await view.context.close();
const settled = await attributionUi.execute();
assert.equal(settled.statusCode, 200, 'settles after UI re-registration and review');
checks.push('desktop_stale_attribution_reregistered_reviewed_and_settled');
}
const context = await browser.newContext({ serviceWorkers: 'block' }); const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage(); const page = await context.newPage();
+216 -15
查看文件
@@ -448,11 +448,11 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('departed_reviewer_or_lender_employee_does_not_strand_paid_repayment_via_ocr_or_manual'); 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 // Approval-time independence is still enforced after payment: a recorded
// approver equal to the batch creator/submitter or lender employee is refused. // approver equal to the batch creator/submitter or lender employee is refused.
for (const [label, approverId] of [['creator', finance.id], ['lender_employee', lenderEmployee.id]]) { for (const [label, approverId, amount] of [['creator', finance.id, '2.00'], ['lender_employee', lenderEmployee.id, '2.50']]) {
const forged = await repay(staffLender.id, staffLoan.id, '2.00'); const forged = await repay(staffLender.id, staffLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {}); await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {});
await exportOrder(forged.id); await exportOrder(forged.id);
const forgedReceipt = await receipt(forged.id, '200', `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount }); const forgedReceipt = await receipt(forged.id, String(Math.round(Number(amount) * 100)), `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount });
assert.equal(forgedReceipt.result.status, 'matched'); assert.equal(forgedReceipt.result.status, 'matched');
await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: approverId } }); 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 call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {}, 403, 'loan.independent_review_required');
@@ -470,20 +470,168 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' }); await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' });
checks.push('pre_payment_export_keeps_current_approver_eligibility'); checks.push('pre_payment_export_keeps_current_approver_eligibility');
// C (reproduction only, outcome recorded, not yet a guarded behaviour): // C: a matched receipt does not prove which same-lender, same-amount batch
// can a genuine receipt of one repayment settle a different same-amount batch? // the bank paid. Settlement and failure release need an independently
// reviewed attribution; OCR matches and manual receipt reviews cannot bypass it.
const probeLender = await verifiedLender('合成出借人丁', null, '6'); const probeLender = await verifiedLender('合成出借人丁', null, '6');
const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入'); const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入');
const reproductions = []; const probeBalance = async () => { const b = await call(finance, 'GET', `/company-loans/${probeLoan.id}`); return `${b.outstanding}/${b.reserved}`; };
async function probeExecute(batchId) { const attribute = (who, batchId, body, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-receipt-attribution`, {
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} }); basisKind: 'customer_serial', basisNote: '合成回单客户方流水号与本批次制单流水号一致', exclusionNote: '合成其他候选批次流水号均不一致', ...body }, status, reason);
return { status: response.statusCode, reasonCode: response.json().details?.reasonCode ?? null }; const reviewAttribution = (who, batchId, id, decision = 'approve', status = 200, reason) => call(who, 'POST',
} `/payment-batches/${batchId}/loan-receipt-attribution/${id}/review`, { decision, reason: '合成独立复核归属' }, status, reason);
const detail = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}`)).item ?? (await call(finance, 'GET', `/payment-batches/${batchId}`));
// C1: two same-amount repayments in flight; P's genuine receipt uploaded to Q.
const c1p = await repay(probeLender.id, probeLoan.id, '7.00'); const c1p = await repay(probeLender.id, probeLoan.id, '7.00');
const c1q = 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); } for (const item of [c1p, c1q]) { await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
const beforeC1 = await probeBalance();
const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount }); 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) }); assert.equal(c1.result.status, 'matched');
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal(await probeBalance(), beforeC1, 'C1 wrong receipt must not settle or release');
const qState = (await detail(c1q.id)).loanRepayment.receiptAttribution;
assert.equal(qState.required, true); assert.deepEqual(qState.competitors.map((item) => item.id), [c1p.id]);
// Correct receipt for P still needs attribution because Q is a competitor.
await receipt(c1p.id, '700', 'SYNTHETIC-C1-P-OWN', { payeeAccount: probeLender.bankAccount });
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
await attribute(finance, c1p.id, { basisKind: 'time_alignment' }, 400);
const proposed = await attribute(finance, c1p.id, {});
await attribute(finance, c1p.id, {}, 409, 'loan.receipt_attribution_exists');
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
await reviewAttribution(finance, c1p.id, proposed.id, 'approve', 403);
const dual = await actor(['finance', 'manager']);
await reviewAttribution(dual, c1p.id, proposed.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {})).item.status, 'executed');
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
checks.push('same_amount_wrong_batch_receipt_blocked_and_reviewed_attribution_unlocks_settlement');
// A proposer holding manager rights cannot review their own attribution.
const selfLender = await verifiedLender('合成出借人戊', null, '7');
const selfLoan = await confirmedLoan(selfLender.id, '100.00', '合成戊借入');
const s1 = await repay(selfLender.id, selfLoan.id, '3.00'); const s2 = await repay(selfLender.id, selfLoan.id, '3.00');
for (const item of [s1, s2]) { await call(independent, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
await receipt(s1.id, '300', 'SYNTHETIC-SELF-REVIEW', { payeeAccount: selfLender.bankAccount });
const selfProposal = await attribute(dual, s1.id, {});
await reviewAttribution(dual, s1.id, selfProposal.id, 'approve', 403);
await reviewAttribution(manager, s1.id, selfProposal.id, 'reject');
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
// An approval stops covering once a new competitor reaches the bank.
const reproposal = await attribute(finance, s1.id, {});
await reviewAttribution(manager, s1.id, reproposal.id, 'approve');
const s3 = await repay(selfLender.id, selfLoan.id, '3.00');
await call(independent, 'POST', `/payment-batches/${s3.id}/review`, {}); await exportOrder(s3.id);
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
const staleState = (await detail(s1.id)).loanRepayment.receiptAttribution.current;
assert.equal(staleState.coversCurrentCompetitors, false); assert.equal(staleState.stale, true);
checks.push('attribution_self_review_rejection_and_new_competitor_invalidate_approval');
// The stale approval must not deadlock the batch: re-registering supersedes it.
const readiness = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}/receipt-readiness`)).item;
assert.deepEqual(await readiness(s1.id), { ready: false, receiptAttributionRequired: true });
const renewed = await attribute(finance, s1.id, {});
assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: reproposal.id } })).status, 'superseded');
await reviewAttribution(manager, s1.id, renewed.id, 'approve');
assert.deepEqual(await readiness(s1.id), { ready: true });
assert.equal((await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {})).item.status, 'executed');
checks.push('stale_approval_superseded_by_reregistration_then_settles');
// Swapping the receipt after approval: the old approval cannot unlock the
// new transaction, and a new registration for the new receipt works.
const swapReviewer = await actor(['manager']);
const swapExporter = await actor(['finance', 'manager']);
const swap = await repay(selfLender.id, selfLoan.id, '3.00');
await call(independent, 'POST', `/payment-batches/${swap.id}/review`, {});
const swapExport = await app.inject({ method: 'POST', url: `/api/payment-batches/${swap.id}/payment-order`, headers: swapExporter.headers, payload: {} });
assert.equal(swapExport.statusCode, 200, 'exporter with manager role exports');
const firstReceipt = await receipt(swap.id, '300', 'SYNTHETIC-SWAP-FIRST', { payeeAccount: selfLender.bankAccount });
const swapApproved = await attribute(finance, swap.id, {});
await reviewAttribution(swapExporter, swap.id, swapApproved.id, 'approve', 403);
await reviewAttribution(swapReviewer, swap.id, swapApproved.id, 'approve');
await call(finance, 'POST', `/payment-batches/${swap.id}/receipt-attachments/${firstReceipt.attachment.id}/void`, { reason: '合成回单上传错误作废' });
await receipt(swap.id, '300', 'SYNTHETIC-SWAP-SECOND', { payeeAccount: selfLender.bankAccount });
await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal((await detail(swap.id)).loanRepayment.receiptAttribution.current.stale, true);
const swapRenewed = await attribute(finance, swap.id, {});
await reviewAttribution(swapReviewer, swap.id, swapRenewed.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {})).item.status, 'executed');
checks.push('receipt_swap_invalidates_approval_exporter_cannot_review_and_reregistration_settles');
// A proposal whose receipt was voided can still be rejected (no receipt needed).
const orphanReceipt = await receipt(s2.id, '300', 'SYNTHETIC-ORPHAN', { payeeAccount: selfLender.bankAccount });
const orphan = await attribute(finance, s2.id, {});
await call(finance, 'POST', `/payment-batches/${s2.id}/receipt-attachments/${orphanReceipt.attachment.id}/void`, { reason: '合成回单作废' });
assert.equal((await detail(s2.id)).loanRepayment.receiptAttribution.receiptAvailable, false);
await attribute(finance, s2.id, {}, 409, 'loan.receipt_attribution_unavailable');
await reviewAttribution(swapReviewer, s2.id, orphan.id, 'reject');
checks.push('proposal_without_receipt_can_be_rejected');
// Same transaction, different evidence (re-scanned receipt with the same bank
// reference): the approval is bound to the reviewed evidence and stops unlocking.
const rescan = await repay(selfLender.id, selfLoan.id, '3.00');
await call(independent, 'POST', `/payment-batches/${rescan.id}/review`, {}); await exportOrder(rescan.id);
const rescanFirst = await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
const rescanApproved = await attribute(finance, rescan.id, {});
await reviewAttribution(swapReviewer, rescan.id, rescanApproved.id, 'approve');
await call(finance, 'POST', `/payment-batches/${rescan.id}/receipt-attachments/${rescanFirst.attachment.id}/void`, { reason: '合成重新扫描' });
await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal((await detail(rescan.id)).loanRepayment.receiptAttribution.current.stale, true);
const rescanRenewed = await attribute(finance, rescan.id, {});
const supersedeAudit = await prisma.auditLog.findFirst({ where: { action: 'LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED' }, orderBy: { created_at: 'desc' } });
assert.ok(JSON.stringify(supersedeAudit).includes('receipt_evidence_changed'));
await reviewAttribution(swapReviewer, rescan.id, rescanRenewed.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {})).item.status, 'executed');
checks.push('same_transaction_new_evidence_invalidates_approval_until_rereviewed');
// Settlement's competitor check and a competitor's first receipt upload are
// serialized by the lender lock. Deterministic interleaving in both orders.
const raceLender = await verifiedLender('合成出借人庚', null, '2');
const raceLoan = await confirmedLoan(raceLender.id, '100.00', '合成庚借入');
const uploadOnly = (batchId) => {
const boundary = `synthetic-${randomUUID()}`;
return app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
};
const executeRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} });
const racePool = new LockPool({ ...pool.options, max: 2 });
const raceWaiters = async () => Number((await racePool.query(
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
async function raceWaiting(count) {
for (let i = 0; i < 300; i++) { if (await raceWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); }
throw new Error(`expected ${count} requests queued behind the lender lock`);
}
try {
for (const [order, amount, cents] of [['settle_first', '9.00', '900'], ['upload_first', '8.00', '800']]) {
const settling = await repay(raceLender.id, raceLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${settling.id}/review`, {}); await exportOrder(settling.id);
assert.equal((await receipt(settling.id, cents, `SYNTHETIC-RACE-${order}`, { payeeAccount: raceLender.bankAccount })).result.status, 'matched');
const competitor = await repay(raceLender.id, raceLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
const holder = await racePool.connect();
let executed; let uploaded;
try {
await holder.query('BEGIN');
await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [raceLender.id]);
if (order === 'settle_first') {
executed = executeRequest(settling.id); await raceWaiting(1);
uploaded = uploadOnly(competitor.id); await raceWaiting(2);
} else {
uploaded = uploadOnly(competitor.id); await raceWaiting(1);
executed = executeRequest(settling.id); await raceWaiting(2);
}
await holder.query('COMMIT');
[executed, uploaded] = [await executed, await uploaded];
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
assert.equal(uploaded.statusCode, 200, `${order} competitor upload`);
if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed');
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); }
// Keep the shared OCR queue clean for later scenarios.
await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' });
}
} finally { await racePool.end(); }
checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders');
// C2: old batch confirmed failed and cancelled; its late success receipt
// lands on a new same-amount batch, via OCR and via manual receipt review.
const c2old = await repay(probeLender.id, probeLoan.id, '6.00'); const c2old = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {}); 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'] }); const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
@@ -491,12 +639,65 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' }); await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' });
const c2new = await repay(probeLender.id, probeLoan.id, '6.00'); const c2new = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id); await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id);
const beforeC2 = await probeBalance();
const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount }); 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) }); assert.equal(c2.result.status, 'matched');
console.log(JSON.stringify({ stage: 'loan_receipt_attribution_reproduction', reproductions })); await call(finance, 'POST', `/payment-batches/${c2new.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
checks.push('receipt_batch_attribution_reproduction_recorded'); assert.equal(await probeBalance(), beforeC2, 'C2 late receipt must not settle the new batch');
const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id);
const beforeManual = await probeBalance();
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } });
const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`;
const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对',
pages: [{ pageNumber: 1, itemId: c2manualBatch.items[0].id, fields: Object.entries({ 小写金额: '6.00', 币别: 'CNY', 付款人账号: payerAccount,
收款人账号: probeLender.bankAccount, 凭证号: 'SYNTHETIC-C2-MANUAL', 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value })) }] });
const c2saved = c2proposal.item ?? c2proposal;
await call(manager, 'POST', `${c2path}/${c2saved.id}/review`, { expectedVersion: c2saved.version, decision: 'approve', reason: '合成人工复核' });
await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal(await probeBalance(), beforeManual, 'manual receipt review must not bypass attribution');
// The person who keyed the manual receipt cannot also approve its attribution.
const c2attribution = await attribute(finance, c2manualBatch.id, {});
await reviewAttribution(dual, c2manualBatch.id, c2attribution.id, 'approve', 403);
await reviewAttribution(independent, c2manualBatch.id, c2attribution.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {})).item.status, 'executed');
checks.push('late_receipt_of_cancelled_batch_blocked_via_ocr_and_manual_review');
// A failed receipt cannot release a reservation while competitors exist.
const c2failNew = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/review`, {});
const c2failReceipt = await receipt(c2failNew.id, '600', 'SYNTHETIC-C2-AMBIGUOUS-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failReceipt.attachment.id }, 409, 'loan.receipt_attribution_required');
await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '歧义失败不得释放' }, 409, 'loan.bank_state_unsafe');
checks.push('ambiguous_failed_receipt_cannot_release_reservation');
// No competitor: attribution is neither required nor accepted.
const soloLender = await verifiedLender('合成出借人己', null, '1');
const soloLoan = await confirmedLoan(soloLender.id, '50.00', '合成己借入');
const solo = await repay(soloLender.id, soloLoan.id, '4.00');
await call(manager, 'POST', `/payment-batches/${solo.id}/review`, {}); await exportOrder(solo.id);
await receipt(solo.id, '400', 'SYNTHETIC-SOLO', { payeeAccount: soloLender.bankAccount });
await attribute(finance, solo.id, {}, 409, 'loan.receipt_attribution_not_required');
assert.equal((await call(finance, 'POST', `/payment-batches/${solo.id}/execute`, {})).item.status, 'executed');
checks.push('unique_amount_settles_without_attribution');
// Database guards: reviewed history immutable, reviewer differs from proposer.
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { review_note: '改写' } }));
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { status: 'rejected' } }));
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: reproposal.id }, data: { status: 'approved' } }));
await assert.rejects(prisma.loanReceiptAttribution.delete({ where: { id: proposed.id } }));
checks.push('attribution_history_immutable_in_database');
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { 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 }); // Attribution UI scenario: a matched receipt on one of two same-amount exported batches.
const uiLender = await verifiedLender('合成浏览器归属出借人', null, '0');
const uiLoan = await confirmedLoan(uiLender.id, '100.00', '合成浏览器归属借入');
const uiExported = async () => { const item = await repay(uiLender.id, uiLoan.id, '5.00');
await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); return item; };
const uiTarget = await uiExported(); await uiExported();
assert.equal((await receipt(uiTarget.id, '500', 'SYNTHETIC-UI-ATTRIBUTION', { payeeAccount: uiLender.bankAccount })).result.status, 'matched');
const attributionUi = { batchId: uiTarget.id, addCompetitor: uiExported,
execute: () => app.inject({ method: 'POST', url: `/api/payment-batches/${uiTarget.id}/execute`, headers: finance.headers, payload: {} }) };
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi });
} }
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); } } finally { await app.close(); await prisma.$disconnect(); }
@@ -0,0 +1,83 @@
-- Receipt-to-batch attribution for loan repayments. When another same-lender,
-- same-amount loan batch was ever exported or had receipts, a matched receipt
-- alone does not prove which batch it paid; settlement then requires an
-- independently reviewed attribution bound to the exact transaction fingerprint
-- and to the receipt evidence (verification and manual-proposal ids) reviewed.
-- CreateTable
CREATE TABLE "loan_receipt_attributions" (
"id" UUID NOT NULL,
"payment_batch_id" UUID NOT NULL,
"fingerprint" VARCHAR(64) NOT NULL,
"key_id" VARCHAR(64) NOT NULL,
"verification_ids" JSONB NOT NULL,
"manual_proposal_ids" JSONB NOT NULL,
"competitor_batch_ids" JSONB NOT NULL,
"basis_kind" VARCHAR(32) NOT NULL,
"basis_note" VARCHAR(1000) NOT NULL,
"exclusion_note" VARCHAR(1000) NOT NULL,
"status" VARCHAR(16) NOT NULL DEFAULT 'proposed',
"proposed_by" UUID NOT NULL,
"proposed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewed_by" UUID,
"reviewed_at" TIMESTAMP(3),
"review_note" VARCHAR(500),
"superseded_at" TIMESTAMP(3),
CONSTRAINT "loan_receipt_attributions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "loan_receipt_attributions_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN
('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved')),
CONSTRAINT "loan_receipt_attributions_notes_check" CHECK (
char_length(btrim("basis_note")) >= 10 AND char_length(btrim("exclusion_note")) >= 10),
CONSTRAINT "loan_receipt_attributions_evidence_check" CHECK (
jsonb_typeof("verification_ids") = 'array' AND jsonb_array_length("verification_ids") > 0
AND jsonb_typeof("manual_proposal_ids") = 'array'),
CONSTRAINT "loan_receipt_attributions_competitors_check" CHECK (
jsonb_typeof("competitor_batch_ids") = 'array' AND jsonb_array_length("competitor_batch_ids") > 0),
CONSTRAINT "loan_receipt_attributions_review_check" CHECK (
("status" = 'proposed' AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL)
OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL)
OR "status" = 'superseded'),
CONSTRAINT "loan_receipt_attributions_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
CONSTRAINT "loan_receipt_attributions_independent_check" CHECK (
"reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
);
-- CreateIndex
CREATE INDEX "loan_receipt_attributions_batch_fingerprint_idx" ON "loan_receipt_attributions"("payment_batch_id", "fingerprint");
-- At most one open or approved attribution per batch; rejected and superseded rows stay as history.
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch"
ON "loan_receipt_attributions"("payment_batch_id") WHERE "status" IN ('proposed', 'approved');
-- AddForeignKey
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- History is append-only: proposed -> approved/rejected/superseded, approved ->
-- superseded (when the receipt or competitor set changed); nothing else, no deletes.
CREATE FUNCTION guard_loan_receipt_attribution_history() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan receipt attribution history is immutable'; END IF;
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan receipt attribution is immutable'; END IF;
IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded' OR NEW."reviewed_by" IS DISTINCT FROM OLD."reviewed_by"
OR NEW."reviewed_at" IS DISTINCT FROM OLD."reviewed_at" OR NEW."review_note" IS DISTINCT FROM OLD."review_note") THEN
RAISE EXCEPTION 'approved loan receipt attribution can only be superseded';
END IF;
IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded')
AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN
RAISE EXCEPTION 'unreviewed loan receipt attribution cannot carry review fields';
END IF;
IF NEW."payment_batch_id" <> OLD."payment_batch_id" OR NEW."fingerprint" <> OLD."fingerprint" OR NEW."key_id" <> OLD."key_id"
OR NEW."verification_ids" <> OLD."verification_ids" OR NEW."manual_proposal_ids" <> OLD."manual_proposal_ids"
OR NEW."competitor_batch_ids" <> OLD."competitor_batch_ids" OR NEW."basis_kind" <> OLD."basis_kind"
OR NEW."basis_note" <> OLD."basis_note" OR NEW."exclusion_note" <> OLD."exclusion_note"
OR NEW."proposed_by" <> OLD."proposed_by" OR NEW."proposed_at" <> OLD."proposed_at" THEN
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_receipt_attribution_history BEFORE UPDATE OR DELETE ON "loan_receipt_attributions"
FOR EACH ROW EXECUTE FUNCTION guard_loan_receipt_attribution_history();
+34
查看文件
@@ -735,6 +735,8 @@ model Employee {
loan_lender_identities LoanLender[] loan_lender_identities LoanLender[]
loan_evidence_uploads LoanEvidence[] loan_evidence_uploads LoanEvidence[]
loan_receipt_attributions_proposed LoanReceiptAttribution[] @relation("LoanReceiptAttributionProposer")
loan_receipt_attributions_reviewed LoanReceiptAttribution[] @relation("LoanReceiptAttributionReviewer")
@@map("employees") @@map("employees")
} }
@@ -1974,6 +1976,7 @@ model PaymentBatch {
@@index([legal_entity_id], map: "idx_payment_batches_legal_entity") @@index([legal_entity_id], map: "idx_payment_batches_legal_entity")
@@index([payment_account_id], map: "idx_payment_batches_payment_account") @@index([payment_account_id], map: "idx_payment_batches_payment_account")
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by") @@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
loan_receipt_attributions LoanReceiptAttribution[]
@@map("payment_batches") @@map("payment_batches")
} }
@@ -3979,3 +3982,34 @@ model LoanEvidence {
@@unique([loan_id, file_sha256, kind]) @@unique([loan_id, file_sha256, kind])
@@map("loan_evidences") @@map("loan_evidences")
} }
// Independently reviewed receipt-to-batch attribution for ambiguous loan
// repayments (same lender and amount as another exported/receipted batch).
// Partial unique index (one proposed/approved per batch) and history trigger
// live in migration 20261006010000_loan_receipt_attribution.
model LoanReceiptAttribution {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
fingerprint String @db.VarChar(64)
key_id String @db.VarChar(64)
verification_ids Json
manual_proposal_ids Json
competitor_batch_ids Json
basis_kind String @db.VarChar(32)
basis_note String @db.VarChar(1000)
exclusion_note String @db.VarChar(1000)
status String @default("proposed") @db.VarChar(16)
proposed_by String @db.Uuid
proposed_at DateTime @default(now())
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String? @db.VarChar(500)
superseded_at DateTime?
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
proposer Employee @relation("LoanReceiptAttributionProposer", fields: [proposed_by], references: [id], onDelete: Restrict)
reviewer Employee? @relation("LoanReceiptAttributionReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict)
@@index([payment_batch_id, fingerprint], map: "loan_receipt_attributions_batch_fingerprint_idx")
@@map("loan_receipt_attributions")
}
+4
查看文件
@@ -18,6 +18,10 @@ const ERROR_REASONS = [
{"code":"loan.handoff_not_ready","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.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":"loan.lender_in_flight","domain":"loan","audience":"user","defaultMessage":"该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_required","domain":"loan","audience":"user","defaultMessage":"该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_not_required","domain":"loan","audience":"user","defaultMessage":"本还款没有同金额的其他候选批次,无需确认回单归属。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_unavailable","domain":"loan","audience":"user","defaultMessage":"尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_exists","domain":"loan","audience":"user","defaultMessage":"本还款已有待复核或已通过的回单归属确认,请勿重复提交。","recovery":"follow_guidance"},
{"code":"config.enabled_query_invalid","domain":"config","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":"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"}, {"code":"document_sequence.type_unsupported","domain":"document_sequence","audience":"user","defaultMessage":"当前单据类型不支持编号,请联系管理员核对。","recovery":"follow_guidance"},
+40 -4
查看文件
@@ -1,8 +1,9 @@
const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering"); const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering");
const { ensureLoanManager, auditLoan, createIdempotently } = require("../services/companyLoans"); const { ensureLoanManager, auditLoan, createIdempotently, lockLender } = require("../services/companyLoans");
const { loadPendingPaymentResponsibility } = require("../services/paymentResponsibility"); const { loadPendingPaymentResponsibility } = require("../services/paymentResponsibility");
const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure } = require("../services/companyLoanPayments"); activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure,
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments");
const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons"); const { ERROR_REASONS } = require("../constants/errorReasons");
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification"); const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
@@ -1045,7 +1046,10 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
: []; : [];
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name])); const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
return { return {
...(batch.entity_type === "loan_repayment" ? { loanRepayment: mapRepayment(await readLoanRepayment(tx, batch), batch) } : {}), ...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
const repayment = await readLoanRepayment(tx, batch);
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config) };
})() } : {}),
...mapBatch(batch, openRiskFlags, legacyPayees, { ...mapBatch(batch, openRiskFlags, legacyPayees, {
includePayee: true, includePayee: true,
includeExpenseItems, includeExpenseItems,
@@ -1489,6 +1493,32 @@ async function paymentBatchesRoutes(fastify) {
requestKey: UUID_SCHEMA, lenderId: UUID_SCHEMA, reason: z.string().trim().min(2).max(500), requestKey: UUID_SCHEMA, lenderId: UUID_SCHEMA, reason: z.string().trim().min(2).max(500),
allocations: z.array(z.object({ loanId: UUID_SCHEMA, amount: z.string().regex(/^\d{1,12}(\.\d{1,2})?$/) }).strict()).min(1).max(100), allocations: z.array(z.object({ loanId: UUID_SCHEMA, amount: z.string().regex(/^\d{1,12}(\.\d{1,2})?$/) }).strict()).min(1).max(100),
}).strict(); }).strict();
const ATTRIBUTION_PROPOSAL_SCHEMA = z.object({
basisKind: z.enum(["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"]),
basisNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000),
}).strict();
const ATTRIBUTION_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(2).max(500) }).strict();
async function attributionTransaction(request, work) {
const id = parseId(request.params.id);
return fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, id);
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
await ensureFeatureEnabled(fastify);
try { await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
return await work(tx, batch); } catch (error) { mapManualReceiptError(error); }
});
}
fastify.post("/payment-batches/:id/loan-receipt-attribution", { preHandler: authenticateBatch }, async (request, reply) => {
const body = parseBody(ATTRIBUTION_PROPOSAL_SCHEMA, request.body);
const result = await attributionTransaction(request, (tx, batch) => proposeReceiptAttribution(tx, fastify.config, batch, request, body));
return sendData(reply, request, { id: result.id, status: result.status });
});
fastify.post("/payment-batches/:id/loan-receipt-attribution/:attributionId/review", { preHandler: authenticateBatch }, async (request, reply) => {
const attributionId = parseId(request.params.attributionId);
const body = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
const result = await attributionTransaction(request, (tx, batch) => reviewReceiptAttribution(tx, fastify.config, batch, request, attributionId, body));
return sendData(reply, request, { id: result.id, status: result.status });
});
fastify.post("/payment-batches/:id/bank-failure-review", { preHandler: authenticateBatch }, async (request, reply) => { fastify.post("/payment-batches/:id/bank-failure-review", { preHandler: authenticateBatch }, async (request, reply) => {
const id = parseId(request.params.id); const id = parseId(request.params.id);
const { attachmentId } = parseBody(z.object({ attachmentId: UUID_SCHEMA }).strict(), request.body); const { attachmentId } = parseBody(z.object({ attachmentId: UUID_SCHEMA }).strict(), request.body);
@@ -1938,6 +1968,9 @@ async function paymentBatchesRoutes(fastify) {
if (!["reviewed", "failed"].includes(batch.status)) return { ready: false }; if (!["reviewed", "failed"].includes(batch.status)) return { ready: false };
try { try {
if (["reimbursement", "loan_repayment"].includes(batch.entity_type)) await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true }); if (["reimbursement", "loan_repayment"].includes(batch.entity_type)) await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true });
if (batch.entity_type === "loan_repayment" && !await loanAttributionSatisfied(tx, fastify.config, batch)) {
return { ready: false, receiptAttributionRequired: true };
}
return { ready: true }; return { ready: true };
} catch (error) { } catch (error) {
if (!(error instanceof ReceiptVerificationError)) throw error; if (!(error instanceof ReceiptVerificationError)) throw error;
@@ -2028,6 +2061,9 @@ async function paymentBatchesRoutes(fastify) {
if (lockedBatch.entity_type === "loan_repayment") { if (lockedBatch.entity_type === "loan_repayment") {
const repayment = await readLoanRepayment(tx, lockedBatch); const repayment = await readLoanRepayment(tx, lockedBatch);
// Becoming a same-amount competitor must serialize with settlement's
// competitor check, which runs under the same lender lock (batch -> lender).
await lockLender(tx, repayment.lender_id);
if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
} }
@@ -2656,7 +2692,7 @@ async function paymentBatchesRoutes(fastify) {
successfulReimbursementIds.push(item.entity_id); successfulReimbursementIds.push(item.entity_id);
} }
} else if (batch.entity_type === "loan_repayment") { } else if (batch.entity_type === "loan_repayment") {
await settleLoanRepayment(tx, batch, request); await settleLoanRepayment(tx, batch, request, fastify.config);
} else if (batch.entity_type === "travel_order") { } else if (batch.entity_type === "travel_order") {
await executeTravelOrderPayment(tx, { await executeTravelOrderPayment(tx, {
batch, batch,
+181 -2
查看文件
@@ -169,10 +169,63 @@ function repaymentMatchesInput(repayment, input) {
repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount))); repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount)));
} }
async function settleLoanRepayment(tx, batch, request) { const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"];
// A matched receipt proves amount/accounts/currency/status, not which loan batch
// the bank paid. Any other batch for the same lender and amount that ever reached
// the bank (exported or receipted), including ones cancelled after a confirmed
// failure, could own the same transaction; settlement then needs attribution.
async function competingLoanBatches(tx, batch, repayment) {
return tx.paymentBatch.findMany({ where: {
id: { not: batch.id }, entity_type: "loan_repayment",
loan_repayment: { lender_id: repayment.lender_id, amount: repayment.amount },
OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }],
}, select: { id: true, batch_no: true, status: true }, orderBy: { id: "asc" } });
}
const sameIdSet = (stored, current) => {
const left = Array.isArray(stored) ? [...stored].sort() : null;
const right = [...(current ?? [])].sort();
return Boolean(left) && left.length === right.length && left.every((value, index) => value === right[index]);
};
// Valid only for the exact transaction, the exact receipt evidence that was
// reviewed (same verifications and manual proposals; a re-scan or re-keyed
// receipt of the same transaction invalidates it) and every current competitor.
function attributionMatches(attribution, claim, competitors) {
return Boolean(claim) && attribution.fingerprint === claim.fingerprint && attribution.key_id === claim.keyId &&
sameIdSet(attribution.verification_ids, claim.verificationIds) && sameIdSet(attribution.manual_proposal_ids, claim.manualProposalIds) &&
attributionCovers(attribution, competitors);
}
function attributionCovers(attribution, competitors) {
const covered = Array.isArray(attribution?.competitor_batch_ids) ? attribution.competitor_batch_ids : [];
return competitors.every((item) => covered.includes(item.id));
}
// Final settlement gate: runs after claimVerifiedReceipts created this item's
// claim in the same transaction, so OCR matches and manual receipt reviews both
// pass through it. Only an approved attribution for the exact transaction
// fingerprint, covering every current competitor, unlocks settlement.
async function assertReceiptAttributed(tx, config, batch, repayment) {
const competitors = await competingLoanBatches(tx, batch, repayment);
if (!competitors.length) return;
const claim = await tx.paymentReceiptClaim.findUnique({ where: { item_id: batch.items[0].id } });
// Re-derive the evidence behind this claim (read-only; the claim row itself
// stores only one verification) so the approval's evidence binding is checked.
const planned = claim && await plannedClaimOrNull(tx, config, batch);
const approved = planned && planned.fingerprint === claim.fingerprint && await tx.loanReceiptAttribution.findFirst({ where: {
payment_batch_id: batch.id, status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } });
if (!approved || !attributionMatches(approved, planned, competitors)) {
throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
}
}
async function settleLoanRepayment(tx, batch, request, config) {
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true }); const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; 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); }; if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
await assertReceiptAttributed(tx, config, batch, repayment);
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" }, await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
data: { reservation_state: "settled", settled_at: new Date() } }); data: { reservation_state: "settled", settled_at: new Date() } });
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
@@ -210,6 +263,10 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`; await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`;
const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id }); const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id });
// A failed receipt that may belong to a same-lender, same-amount batch cannot
// release this reservation; without an attribution path for failures the
// repayment stays reserved for bank reconciliation.
if ((await competingLoanBatches(tx, batch, repayment)).length) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } }); const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
// More than one bank attempt/evidence is ambiguous: one failed attempt cannot // More than one bank attempt/evidence is ambiguous: one failed attempt cannot
// establish that another attempt did not pay. Preserve all reservations. // establish that another attempt did not pay. Preserve all reservations.
@@ -237,6 +294,127 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id }); await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id });
} }
async function plannedLoanClaim(tx, config, batch) {
const { claimVerifiedReceipts, ReceiptVerificationError } = require("./paymentReceiptVerification");
let planned;
try { planned = await claimVerifiedReceipts(tx, config, batch, { readOnly: true }); }
catch (error) {
if (error instanceof ReceiptVerificationError && error.code === "receipt_verification_required") { throw buildBusinessValidationError("loan.receipt_attribution_unavailable", {}, 409); };
throw error;
}
const claim = planned?.find((entry) => entry.itemId === batch.items[0].id);
if (!claim) { throw buildBusinessValidationError("loan.receipt_attribution_unavailable", {}, 409); };
return claim;
}
async function assertAttributableBatch(tx, batch, { requireCompetitors = true } = {}) {
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (repayment.bank_result_state === "confirmed_failed" || repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const competitors = await competingLoanBatches(tx, batch, repayment);
if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); };
return { repayment, competitors };
}
// Finance documents why the verified bank transaction belongs to this batch and
// why each competitor is excluded. Time alignment alone is not an accepted basis.
async function proposeReceiptAttribution(tx, config, batch, request, input) {
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
const { competitors } = await assertAttributableBatch(tx, batch);
const claim = await plannedLoanClaim(tx, config, batch);
// A still-valid open/approved record blocks duplicates. One whose receipt
// transaction or competitor coverage no longer matches is closed as
// superseded in the same transaction so the batch never deadlocks.
const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } } });
if (active) {
if (attributionMatches(active, claim, competitors)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); };
await tx.loanReceiptAttribution.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } });
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: active.id,
reason: active.fingerprint !== claim.fingerprint || active.key_id !== claim.keyId ? "receipt_transaction_changed"
: attributionCovers(active, competitors) ? "receipt_evidence_changed" : "competitors_changed" });
}
const attribution = await tx.loanReceiptAttribution.create({ data: {
payment_batch_id: batch.id, fingerprint: claim.fingerprint, key_id: claim.keyId,
verification_ids: claim.verificationIds, manual_proposal_ids: claim.manualProposalIds,
competitor_batch_ids: competitors.map((item) => item.id), basis_kind: input.basisKind,
basis_note: input.basisNote, exclusion_note: input.exclusionNote, proposed_by: request.employee.id,
} });
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_PROPOSED", batch.loan_repayment_id, {
paymentBatchId: batch.id, attributionId: attribution.id, basisKind: input.basisKind, competitorCount: competitors.length });
return attribution;
}
// Independent review: the current reviewer must be eligible now and differ from
// the proposer, batch creator, submitter and lender employee. Approval is
// refused if the receipt evidence or competitor set changed since proposal.
async function reviewReceiptAttribution(tx, config, batch, request, attributionId, input) {
const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } });
if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false });
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by];
if (input.decision === "approve") {
const { competitors } = await assertAttributableBatch(tx, batch);
const claim = await plannedLoanClaim(tx, config, batch);
if (!attributionMatches(attribution, claim, competitors)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
// Also independent of everyone who produced the claimed evidence: uploaders
// of every receipt carrying this transaction and any manual-receipt proposer.
const verifications = await tx.paymentReceiptVerification.findMany({ where: { id: { in: claim.verificationIds } }, include: { attachment: true } });
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { id: { in: claim.manualProposalIds } }, select: { proposed_by: true } });
excluded.push(...verifications.map((v) => v.attachment?.uploaded_by), ...manual.map((m) => m.proposed_by));
}
// Rejection needs no receipt, so an open proposal can always be closed.
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
const reviewed = await tx.loanReceiptAttribution.update({ where: { id: attribution.id }, data: {
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id,
reviewed_at: new Date(), review_note: input.reason,
} });
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_RECEIPT_ATTRIBUTION_APPROVED" : "LOAN_RECEIPT_ATTRIBUTION_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id });
return reviewed;
}
async function plannedClaimOrNull(tx, config, batch) {
const { claimVerifiedReceipts, ReceiptVerificationError } = require("./paymentReceiptVerification");
try { return (await claimVerifiedReceipts(tx, config, batch, { readOnly: true }))?.find((entry) => entry.itemId === batch.items[0].id) ?? null; }
catch (error) { if (error instanceof ReceiptVerificationError) return null; throw error; }
}
// Display only: batch detail is also the response of mutating routes, so any
// failure to rebuild the planned claim (e.g. key mismatch) degrades to unknown.
async function displayPlannedClaim(tx, config, batch) {
try { return { claim: await plannedClaimOrNull(tx, config, batch), known: true }; }
catch { return { claim: null, known: false }; }
}
// Read-only readiness for receipt-readiness: false when competitors exist and no
// approved attribution matches the receipt that execute would claim.
async function loanAttributionSatisfied(tx, config, batch) {
const repayment = await readLoanRepayment(tx, batch);
const competitors = await competingLoanBatches(tx, batch, repayment);
if (!competitors.length) return true;
const claim = await plannedClaimOrNull(tx, config, batch);
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
return Boolean(approved && attributionMatches(approved, claim, competitors));
}
async function loanReceiptAttributionState(tx, batch, repayment, config) {
const competitors = await competingLoanBatches(tx, batch, repayment);
const planned = competitors.length && config ? await displayPlannedClaim(tx, config, batch) : { claim: null, known: false };
const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id },
orderBy: [{ proposed_at: "desc" }, { id: "desc" }], include: { proposer: { select: { name: true } }, reviewer: { select: { name: true } } } });
return {
required: competitors.length > 0,
competitors: competitors.map((item) => ({ id: item.id, batchNo: item.batch_no, status: item.status })),
current: current ? { id: current.id, status: current.status, basisKind: current.basis_kind, basisNote: current.basis_note,
exclusionNote: current.exclusion_note, proposedBy: current.proposed_by, proposedByName: current.proposer?.name ?? null,
proposedAt: current.proposed_at, reviewedByName: current.reviewer?.name ?? null, reviewedAt: current.reviewed_at,
reviewNote: current.review_note, coversCurrentCompetitors: attributionCovers(current, competitors),
stale: ["proposed", "approved"].includes(current.status) && competitors.length > 0 && planned.known && planned.claim !== null &&
!attributionMatches(current, planned.claim, competitors) } : null,
receiptAvailable: planned.known ? planned.claim !== null : null,
};
}
function mapRepayment(repayment, batch) { function mapRepayment(repayment, batch) {
return { return {
id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id, id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id,
@@ -251,4 +429,5 @@ function mapRepayment(repayment, batch) {
} }
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure }; eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied };
@@ -149,7 +149,7 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
}); });
requireFact(existingAliases.every((alias) => alias.claim_fingerprint === match.fingerprint && alias.key_id === keyId), requireFact(existingAliases.every((alias) => alias.claim_fingerprint === match.fingerprint && alias.key_id === keyId),
"receipt_transaction_reused"); "receipt_transaction_reused");
claims.push({ match, exists: existing.length > 0, existingAliases }); claims.push({ match, options, exists: existing.length > 0, existingAliases });
} }
requireFact(new Set(claims.map(({ match }) => match.fingerprint)).size === claims.length, requireFact(new Set(claims.map(({ match }) => match.fingerprint)).size === claims.length,
"receipt_transaction_reused"); "receipt_transaction_reused");
@@ -160,7 +160,9 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
aliasOwners.set(alias, match.item_id); aliasOwners.set(alias, match.item_id);
} }
} }
if (readOnly) return; if (readOnly) return claims.map(({ match, options }) => ({ itemId: match.item_id, fingerprint: match.fingerprint, keyId: match.key_id,
verificationIds: [...new Set(options.map((option) => option.verification_id))],
manualProposalIds: [...new Set(options.map((option) => option.manual_proposal_id).filter(Boolean))] }));
for (const { match, exists, existingAliases } of claims) { for (const { match, exists, existingAliases } of claims) {
if (!exists) { if (!exists) {
await tx.paymentReceiptClaim.create({ data: { await tx.paymentReceiptClaim.create({ data: {
@@ -16,6 +16,10 @@
"loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。", "loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。",
"loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。", "loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。",
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
"loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。",
"loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。",
"loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。",
"loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。",
"loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。",
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",