借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)
- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时, 最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过, 证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用 - 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、 回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须 当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、 人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改 - 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选 判断,核销之后才出现的新证据留待晚到结果对账流程处理 - 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认 - 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、 PC 浏览器完整登记复核重登流程;变异测试均变红 注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认 失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -22,6 +22,18 @@ export type LoanRepayment = {
|
||||
bankResultState: string; paymentBatchId: string; status: string;
|
||||
accountingHandoffRef: string | null; accountingHandoffAt: string | null;
|
||||
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) {
|
||||
return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal });
|
||||
|
||||
@@ -18,6 +18,10 @@ export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
||||
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
|
||||
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
|
||||
"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.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。",
|
||||
"document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
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 { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
@@ -10,18 +10,33 @@ import { downloadBlob } from "../../lib/download";
|
||||
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
|
||||
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[] }) {
|
||||
const { me } = useAuth(); const cache = useQueryClient();
|
||||
const { me, permissions } = useAuth(); const cache = useQueryClient();
|
||||
const active = useRef(true);
|
||||
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 [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
|
||||
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 mutation = useMutation({ mutationFn: async () => {
|
||||
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 === "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) {
|
||||
const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId);
|
||||
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;
|
||||
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="本金还款分配">
|
||||
<h2 className="font-semibold">原借款分配与会计交接</h2>
|
||||
<p className="break-words text-sm">{repayment.reason}</p>
|
||||
{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}
|
||||
{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">
|
||||
<p className="break-all font-medium">{allocation.loanNo} · 本次归还 ¥{allocation.amount}</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}
|
||||
</div>
|
||||
<Dialog open={action !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) setAction(null); }}>
|
||||
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" : "下载原借款材料"}</DialogTitle>
|
||||
<DialogDescription>{action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader>
|
||||
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" :
|
||||
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(); }}>
|
||||
{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>)}
|
||||
</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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户