借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)

新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。

验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。

真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
2026-10-07 21:58:03 +08:00
父节点 2e00d30fd0
当前提交 431c36eae3
共修改 32 个文件,包含 1902 行新增和 25 行删除
+41 -1
查看文件
@@ -17,7 +17,7 @@ export type CompanyLoan = {
outstanding: string; available: string; reserved: string; settled: string;
evidences: LoanEvidence[]; accountingConfirmationRef: string | null;
};
export type LoanRepayment = {
export type LoanRepayment = LoanReconciliation & {
id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string;
bankResultState: string; paymentBatchId: string; status: string;
accountingHandoffRef: string | null; accountingHandoffAt: string | null;
@@ -61,3 +61,43 @@ export function uploadLoanEvidence(id: string, kind: string, file: File) {
export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) {
return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) });
}
export type LoanReconciliation = {
failureAttributions?: Array<{ id: string; status: string; proposedBy: string; basisNote: string; exclusionNote: string; coveredSubmissionIds: string[]; failureScope: string; receiptAttachmentIds: string[] }>;
bankSubmissions?: {
exports: Array<{ id: string; sequence: number; exportedAt: string; exportedBy: string; customerSerials: string[] }>;
legacy: { knownExportCount: number; unknownFacts: string[] } | null;
submissions: Array<{ id: string; exportId: string | null; bankInstructionRef: string; status: string; submittedAt: string; registeredBy: string; note: string }>;
attestation: { id: string; status: string; proposedBy: string; stale: boolean } | null;
};
terminal?: {
evidences: Array<{ id: string; originalName: string; evidenceKind: string; voided: boolean }>;
facts: Array<{ id: string; submissionId: string; status: string; proposedBy: string; kind: string; note: string; terminalStatus: string; evidenceId: string | null; receiptAttachmentId: string | null; resolvesAttachmentIds: string[] }>;
};
releaseRecovery?: {
current: { id: string; sequence: number; status: string; stale: boolean } | null;
history: Array<{ id: string; sequence: number; status: string; startedBy: string; reason: string }>;
};
notSubmitted?: { history: Array<{ id: string; sequence: number; status: string; proposedBy: string; stale: boolean;
payload: { exportIds: string[]; queryEvidenceId: string; statementEvidenceId: string; exporterStatements: Array<{ exporterId: string; evidenceId: string }>; coverageFrom: string; coverageThrough: string; note: string }; reviews: Array<{ reviewedBy: string; decision: string; reason: string }> }> };
freeze?: { frozen: boolean; reasons: Array<{ id: string; kind: string; status: string; paymentBatchId: string }> };
bankResults?: { events: Array<{ id: string; kind: string; parentId: string | null; evidenceId: string; status: string; amount: string;
proposedBy: string; reviewedBy: string | null; reviewNote: string | null; outstandingReturn: string | null;
settlements: Array<{ loanId: string; bankAmount: string; settledAmount: string; receivableAmount: string }> }> };
};
export function uploadLoanBankEvidence(batchId: string, kind: string, file: File) {
const body = new FormData(); body.append("file", file);
return apiRequestData<{ id: string }>(`/api/payment-batches/${batchId}/loan-terminal-evidences?kind=${encodeURIComponent(kind)}`, { method: "POST", body });
}
export function downloadLoanBankEvidence(batchId: string, id: string, reason: string) {
return apiRequestBlob(`/api/payment-batches/${batchId}/loan-terminal-evidences/${id}/file`, { headers: { "x-access-reason": encodeURIComponent(reason) } });
}
export function readLoanBankResultDetails(batchId: string, id: string, reason: string) {
return apiRequestData<{ fields: Array<{ Name: string; Value: string }>; attributionNote: string; exclusionNote: string }>(`/api/payment-batches/${batchId}/loan-bank-results/${id}/details`, { headers: { "x-access-reason": encodeURIComponent(reason) } });
}
export function downloadLoanReconciliationReceipt(batchId: string, attachmentId: string, reason: string) {
const query = new URLSearchParams({ purpose: "loan_reconciliation", reason });
return apiRequestBlob(`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/file?${query}`);
}
+3
查看文件
@@ -1,4 +1,7 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。",
"loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。",
"loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。",
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
@@ -0,0 +1,108 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../../auth/AuthContext";
import { saveLoanAction, uploadLoanBankEvidence, downloadLoanBankEvidence, readLoanBankResultDetails, downloadLoanReconciliationReceipt, type LoanRepayment } from "../../api/companyLoans";
import { type PaymentBatchReceiptAttachment } from "../../api/paymentBatches";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "../ui/dialog";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { downloadBlob } from "../../lib/download";
import { formatDisplayDate } from "../../lib/displayFormat";
const states: Record<string, string> = { proposed: "待复核", approved: "复核通过", rejected: "已驳回", superseded: "已取代", collecting: "补证中", withdrawn: "已撤回", consumed: "已释放", booked: "已登记入账", dismissed: "已确认误报" };
const categories: Record<string, string> = { bank_terminal: "银行终态或更正书面证据", query_no_result: "指令查询无结果官方 PDF", account_statement: "同期付款账户对账单", exporter_statement: "导出人文件去向说明", late_success: "晚到成功回单", incoming_return: "出借人退款到账回单" };
const actions: Record<string, string> = { downloadReceipt: "查看对应付款回单", details: "查看录入的银行事实", upload: "上传银行证据", submission: "登记银行提交", withdrawSubmission: "撤回提交登记", attest: "确认提交清单完整", reviewList: "独立复核提交清单", terminal: "登记银行终态事实", reviewFact: "独立复核终态事实", confirm: "确认银行未付款", failureAttribution: "登记失败回单归属", reviewFailure: "复核失败归属", q6: "登记人工未提交", reviewQ6: "特别复核人工未提交", recovery: "发起补证恢复", submitRecovery: "送审补证恢复", reviewRecovery: "复核补证恢复", withdrawRecovery: "撤回补证恢复", cancel: "重验依据并取消释放", event: "登记晚到成功", refund: "登记追回到账", reviewEvent: "独立复核银行事实", apply: "登记补记或追回", download: "查看银行证据", void: "作废银行证据" };
export function LoanBankReconciliation({ repayment: r, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) {
const { me, permissions } = useAuth(); const cache = useQueryClient();
const finance = !!me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) && permissions.includes("loan.manage");
const reviewer = !!me?.roleCodes.some((role) => ["manager", "ceo"].includes(role)) && permissions.includes("payment.batch.review");
const [details, setDetails] = useState<Awaited<ReturnType<typeof readLoanBankResultDetails>> | null>(null);
const [action, setAction] = useState<string | null>(null); const [target, setTarget] = useState("");
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [values, setValues] = useState<Record<string, string>>({}); const [file, setFile] = useState<File | null>(null);
const value = (key: string) => values[key] ?? "";
const set = (key: string, next: string) => setValues((old) => ({ ...old, [key]: next }));
const base = `/payment-batches/${r.paymentBatchId}`;
const exports = r.bankSubmissions?.exports ?? []; const submissions = r.bankSubmissions?.submissions ?? [];
const evidences = r.terminal?.evidences ?? []; const current = r.releaseRecovery?.current;
const q6 = r.notSubmitted?.history[0]; const list = r.bankSubmissions?.attestation;
const unlinked = exports.filter((e) => !submissions.some((s) => s.status === "active" && s.exportId === e.id));
const mutation = useMutation({ mutationFn: async () => {
const note = value("note"); const decision = value("decision") || "approve";
const reviewBody = { decision, reason: note };
if (action === "details") setDetails(await readLoanBankResultDetails(r.paymentBatchId, target, note));
if (action === "upload") { if (!file) throw new Error("请选择证据文件"); await uploadLoanBankEvidence(r.paymentBatchId, value("category") || "bank_terminal", file); }
if (action === "download") { const result = await downloadLoanBankEvidence(r.paymentBatchId, target, note); downloadBlob(result.blob, result.filename ?? "银行证据"); }
if (action === "downloadReceipt") { const result = await downloadLoanReconciliationReceipt(r.paymentBatchId, target, note); downloadBlob(result.blob, result.filename ?? "付款回单"); }
if (action === "void") await saveLoanAction(`${base}/loan-terminal-evidences/${target}/void`, { reason: note });
if (action === "submission") await saveLoanAction(`${base}/loan-bank-submissions`, { channel: value("export") ? "online_banking_import" : "manual_entry", ...(value("export") ? { exportId: value("export") } : {}), bankInstructionRef: value("reference"), submittedAt: new Date(value("date")).toISOString(), note });
if (action === "withdrawSubmission") await saveLoanAction(`${base}/loan-bank-submissions/${target}/withdraw`, { reason: note });
if (action === "attest") await saveLoanAction(`${base}/loan-submission-attestation`, { note });
if (action === "reviewList") await saveLoanAction(`${base}/loan-submission-attestation/${target}/review`, reviewBody);
if (action === "terminal") await saveLoanAction(`${base}/loan-terminal-facts`, { submissionId: value("submission"), kind: value("receipt") ? "failure_receipt" : "termination_evidence", ...(value("receipt") ? { receiptAttachmentId: value("receipt") } : { evidenceId: value("evidence"), terminalStatus: value("terminalStatus") || "failed" }), resolvesAttachmentIds: pendingIds, note });
if (action === "reviewFact") await saveLoanAction(`${base}/loan-terminal-facts/${target}/review`, reviewBody);
if (action === "confirm") await saveLoanAction(`${base}/loan-not-paid/review`, {});
if (action === "failureAttribution") await saveLoanAction(`${base}/loan-receipt-attribution`, { kind: "failure", attachmentId: value("receipt"), ...(value("submission") ? { submissionId: value("submission") } : {}), basisKind: "bank_instruction_ref", basisNote: note, exclusionNote: value("exclusion") });
if (action === "reviewFailure") await saveLoanAction(`${base}/loan-receipt-attribution/${target}/review`, reviewBody);
if (action === "q6") await saveLoanAction(`${base}/loan-not-submitted`, { exportIds: unlinked.map((e) => e.id), queryEvidenceId: value("query"), statementEvidenceId: value("statement"), exporterStatements: [...new Set(unlinked.map((e) => e.exportedBy))].map((id) => ({ exporterId: id, evidenceId: value(`exporter:${id}`) })), coverageFrom: new Date(value("from")).toISOString(), coverageThrough: new Date(value("through")).toISOString(), note });
if (action === "reviewQ6") await saveLoanAction(`${base}/loan-not-submitted/${target}/review`, reviewBody);
if (action === "recovery") await saveLoanAction(`${base}/loan-release-recoveries`, { requestKey: crypto.randomUUID(), reason: note });
if (action === "submitRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/submit`, {});
if (action === "reviewRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/review`, reviewBody);
if (action === "withdrawRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/withdraw`, { reason: note });
if (action === "cancel") await saveLoanAction(`${base}/cancel`, { reason: note, ...(current ? { recoveryId: current.id, requestKey: crypto.randomUUID() } : {}) });
if (action === "event" || action === "refund") await saveLoanAction(`${base}/loan-bank-results`, { kind: action === "refund" ? "return" : "late_success", ...(action === "refund" ? { parentId: target } : {}), evidenceId: value("evidence"), requestKey: crypto.randomUUID(), attributionNote: note, exclusionNote: value("exclusion"), fields: [{ Name: "付款账号", Value: value("payer") }, { Name: "收款账号", Value: value("payee") }, { Name: "金额", Value: value("amount") }, { Name: "币种", Value: "CNY" }, { Name: "交易状态", Value: "交易成功" }, { Name: "流水号", Value: value("reference") }] });
if (action === "reviewEvent") await saveLoanAction(`${base}/loan-bank-results/${target}/review`, { ...reviewBody, ...(decision === "reject" && value("evidence") ? { correctionEvidenceId: value("evidence"), correctionKind: value("correction") || "misassociated" } : {}) });
if (action === "apply") await saveLoanAction(`${base}/loan-bank-results/${target}/apply`, {});
}, onSuccess: async () => { if (action === "details") return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } });
function open(name: string, id = "") { setAction(name); setTarget(id); setValues({}); setPendingIds([]); setDetails(null); setFile(null); mutation.reset(); }
const button = (name: string, id = "") => <Button key={`${name}:${id}`} type="button" variant="outline" onClick={() => open(name, id)}>{actions[name]}</Button>;
const input = (key: string, label: string, type = "text") => <label className="grid gap-2 text-sm" key={key}>{label}<Input required type={type} value={value(key)} onChange={(e) => set(key, e.target.value)} /></label>;
const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} className="min-h-11 min-w-0 w-full rounded border px-3" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">请选择{required ? "" : "(可不选)"}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select></label>;
const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required);
const exportLabel = (id: string | null) => id ? `第 ${exports.find((e) => e.id === id)?.sequence ?? "未知"} 次导出` : "系统外手工提交";
const submissionLabel = (id: string) => submissions.find((s) => s.id === id)?.bankInstructionRef ?? "提交记录已不可用";
const evidenceLink = (id: string) => <Button type="button" variant="outline" onClick={() => open("download", id)}>证据 {evidences.findIndex((e) => e.id === id) + 1}:{evidences.find((e) => e.id === id)?.originalName ?? "不可用"}</Button>;
const receiptLink = (id: string) => <Button type="button" variant="outline" onClick={() => open("downloadReceipt", id)}>付款回单 {attachments.findIndex((a) => a.id === id) + 1}{attachments.find((a) => a.id === id)?.archivedAt ? "(已作废,仅核对历史事实)" : ""}</Button>;
const receiptOptions: Array<[string, string]> = attachments.map((a, i) => [a.id, `回单 ${i + 1}${a.archivedAt ? "(已作废的历史事实)" : ""}`]);
const terminal = ["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(r.bankResultState);
return <section className="space-y-3 rounded-md border p-3" aria-label="银行结果对账">
<h3 className="font-semibold">银行结果对账</h3>
{r.freeze?.frozen ? <p role="status" className="text-sm text-amber-800">该出借人暂停新还款、复核和导出。仍可登记银行事实、对账和追回;所有冻结原因处理完成后才恢复付款。</p> : null}
{r.freeze?.reasons.filter((x) => x.status === "active").map((x) => <p key={x.id} className="text-sm">待处理:{x.kind === "late_success" ? "晚到成功待确认或补记" : x.kind === "receivable" ? "超额付款待追回" : "其他申请占用冲突"}</p>)}
{r.bankSubmissions?.legacy ? <p className="text-sm text-amber-800">历史导出明细待核实,不能凭汇总释放额度。</p> : null}
<p className="text-sm">等待期只是内部谨慎期,不能证明银行未付款。线下核对不能直接解除系统占用。</p>
{exports.map((e) => <p key={e.id} className="break-words text-sm">导出 {e.sequence} · {formatDisplayDate(e.exportedAt)} · 客户方流水号 {e.customerSerials.join("、")}</p>)}
{submissions.map((s) => <div key={s.id} className="flex flex-wrap items-center gap-2 text-sm"><span>指令 {s.bankInstructionRef} · {exportLabel(s.exportId)} · {formatDisplayDate(s.submittedAt)} · {s.note} · {s.status === "active" ? "已登记" : "已撤回登记(不代表银行撤销)"}</span>{finance && s.status === "active" && r.status !== "cancelled" ? button("withdrawSubmission", s.id) : null}</div>)}
{list ? <div className="space-y-2 text-sm"><p>提交清单:{states[list.status]}{list.stale ? ",已变化,须重新确认复核" : ""}</p>{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}</div> : null}
<div className="flex flex-wrap gap-2">{finance && ["reviewed", "failed", "cancelled"].includes(r.status) ? button("upload") : null}{finance && r.status !== "cancelled" ? <>{button("submission")}{button("attest")}{button("terminal")}{button("failureAttribution")}</> : null}{reviewer && r.bankResultState === "unknown" ? button("confirm") : null}</div>
{evidences.map((e, evidenceIndex) => <div key={e.id} className="flex flex-wrap items-center gap-2 text-sm"><span className="break-all">证据 {evidenceIndex + 1} · {categories[e.evidenceKind] ?? "银行证据"}:{e.originalName}{e.voided ? "(已作废)" : ""}</span>{button("download", e.id)}{finance && !e.voided && r.status !== "cancelled" ? button("void", e.id) : null}</div>)}
{r.terminal?.facts.map((f, i) => <div key={f.id} className="flex flex-wrap items-center gap-2 text-sm"><span>终态事实 {i + 1}:{states[f.status]} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}</span>{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? <span>了结处理中回单:</span> : null}{f.resolvesAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}</div>)}
{r.failureAttributions?.map((a) => <div key={a.id} className="space-y-2 text-sm"><p>失败归属:{states[a.status]} · {a.basisNote}</p><p>{a.exclusionNote}</p><p>归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}</p>{a.receiptAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}</div>)}
{q6 ? <div className="space-y-2 text-sm"><p>人工确认未提交:{states[q6.status]} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}</p><p>覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}</p><div className="flex flex-wrap gap-2"><span>查询:</span>{evidenceLink(q6.payload.queryEvidenceId)}<span>对账单:</span>{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => <span key={x.exporterId}>导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)}</span>)}</div>{reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}</div> : null}
{finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null}
{current ? <div className="space-y-2 text-sm"><p>补证恢复第 {current.sequence} 轮:{states[current.status]}{current.stale ? ";依据已变化,须重新发起" : ""}</p><div className="flex flex-wrap gap-2">{finance && current.status === "collecting" ? button("submitRecovery", current.id) : null}{finance && ["collecting", "proposed"].includes(current.status) ? button("withdrawRecovery", current.id) : null}{reviewer && current.status === "proposed" && !current.stale && r.releaseRecovery?.history[0]?.startedBy !== me?.id ? button("reviewRecovery", current.id) : null}</div></div> : null}
{finance && ["confirmed_failed", "confirmed_not_paid"].includes(r.bankResultState) && r.status !== "cancelled" && (!current || current.stale || ["withdrawn", "rejected"].includes(current.status)) ? button("recovery") : null}
{finance && terminal && r.status !== "cancelled" && !r.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved"].includes(e.status)) ? button("cancel") : null}
{terminal ? <p className="text-sm text-amber-800">原制单文件不得再导入网银。系统不能使已下载文件失效;出现晚到成功应立即登记对账。</p> : null}
{finance && terminal ? button("event") : null}
{r.bankResults?.events.map((e, i) => <div key={e.id} className="space-y-2 rounded border p-3 text-sm"><p>{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {states[e.status]}</p>{e.outstandingReturn ? <p>待追回 ¥{e.outstandingReturn}</p> : null}{e.settlements.map((s) => <p key={s.loanId}>银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}</p>)}<div className="flex flex-wrap gap-2">{button("download", e.evidenceId)}{button("details", e.id)}{reviewer && e.status === "proposed" && e.proposedBy !== me?.id ? button("reviewEvent", e.id) : null}{finance && e.status === "approved" ? button("apply", e.id) : null}{finance && e.status === "booked" && Number(e.outstandingReturn) > 0 ? button("refund", e.id) : null}</div></div>)}
<Dialog open={action !== null} onOpenChange={(next) => { if (!next && !mutation.isPending) setAction(null); }}><DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>{action ? actions[action] : "银行对账"}</DialogTitle><DialogDescription>核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。</DialogDescription></DialogHeader>
<form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }}>
{details ? <div className="space-y-2 text-sm" aria-label="已授权查看的银行事实">{details.fields.map((f) => <p key={f.Name} className="break-all">{f.Name}:{f.Value}</p>)}<p>{details.attributionNote}</p><p>{details.exclusionNote}</p></div> : null}
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
{action === "upload" ? <>{select("category", "证据类别", Object.entries(categories))}<label className="grid gap-2 text-sm">证据文件<Input required type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} /></label></> : null}
{action === "submission" ? <>{select("export", "使用的导出文件(手工录入可不选)", exports.map((e) => [e.id, `第 ${e.sequence} 次导出`]), false)}{input("reference", "银行指令号")}{input("date", "实际提交时间", "datetime-local")}</> : null}
{action === "terminal" || action === "failureAttribution" ? <>{select("submission", "对应银行提交", submissions.filter((s) => s.status === "active").map((s) => [s.id, s.bankInstructionRef]), action === "terminal")}{select("receipt", "失败回单(终态书面证据可不选)", receiptOptions, action === "failureAttribution")}{action === "terminal" ? <>{evidenceSelect("evidence", "银行终态书面证据", "bank_terminal", !value("receipt"))}{select("terminalStatus", "银行终态", [["failed", "已失败,不会再执行"], ["revoked", "已撤销,不会再执行"], ["rejected", "已拒绝,不会再执行"]], !value("receipt"))}<fieldset className="grid gap-2"><legend className="text-sm">了结的同指令处理中回单(可多选)</legend>{receiptOptions.map(([id, label]) => <label key={id} className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" checked={pendingIds.includes(id)} onChange={(e) => setPendingIds((old) => e.target.checked ? [...old, id] : old.filter((x) => x !== id))} />{label}</label>)}</fieldset></> : input("exclusion", "排除其他候选批次的依据")}</> : null}
{action === "q6" ? <>{evidenceSelect("query", "查询无结果官方 PDF", "query_no_result")}{evidenceSelect("statement", "同期对账单", "account_statement")}{[...new Set(unlinked.map((e) => e.exportedBy))].map((id, i) => evidenceSelect(`exporter:${id}`, `导出人 ${i + 1} 的文件去向说明`, "exporter_statement"))}{input("from", "查询与对账单覆盖开始", "datetime-local")}{input("through", "查询与对账单覆盖截至(须含登记日)", "datetime-local")}</> : null}
{action?.startsWith("review") ? select("decision", "复核结论", [["approve", "依据充分,同意"], ["reject", "不同意/有依据确认误报"]]) : null}
{action === "reviewEvent" && value("decision") === "reject" ? <>{evidenceSelect("evidence", "误关联或重复登记的更正证据(晚到成功必填)", "bank_terminal", false)}{select("correction", "关闭原因", [["misassociated", "误关联"], ["duplicate", "重复登记"]], false)}</> : null}
{action === "event" || action === "refund" ? <>{evidenceSelect("evidence", "对应的成功银行回单", action === "refund" ? "incoming_return" : "late_success")}{input("payer", "回单付款账号")}{input("payee", "回单收款账号")}{input("amount", "回单人民币金额")}{input("reference", "回单流水号")}{input("exclusion", "为什么不属于其他候选批次或交易")}</> : null}
{!['upload', 'submitRecovery', 'confirm', 'apply'].includes(action ?? '') ? <label className="grid gap-2 text-sm">依据与说明<textarea required minLength={10} maxLength={500} className="min-h-24 rounded border px-3 py-2" value={value("note")} onChange={(e) => set("note", e.target.value)} /></label> : null}
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
</form>
</DialogContent></Dialog>
</section>;
}
@@ -1,3 +1,4 @@
import { LoanBankReconciliation } from "./LoanBankReconciliation";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../../auth/AuthContext";
@@ -50,13 +51,17 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
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);
const lateBankFact = repayment.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved", "booked"].includes(e.status));
const overturned = repayment.bankResults?.events.some((e) => e.kind === "late_success" && e.status === "booked");
return <section className="card-std space-y-3 p-4" aria-label="本金还款分配">
<h2 className="font-semibold">原借款分配与会计交接</h2>
<LoanBankReconciliation repayment={repayment} attachments={attachments} />
<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}
{overturned ? <p className="text-sm text-amber-800">原未付款结论已被晚到成功推翻;实际付款、结清本金与待追回款见银行结果对账。</p> : null}
{repayment.bankResultState === "confirmed_failed" && !lateBankFact ? <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>
<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>