借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟
- 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额
- 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体
- 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一
- 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发
- 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过
- 无新增迁移

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 10:34:40 +08:00
共同撰写人 Claude Opus 5.5
父节点 45737ccb9a
当前提交 2063ff1e93
共修改 22 个文件,包含 2056 行新增和 47 行删除
+24
查看文件
@@ -73,6 +73,30 @@ export function createLoanRepayment(body: { requestKey: string; lenderId: string
allocations: Array<{ loanId: string; amount: string }> }) {
return saveLoanAction<{ item: PaymentBatchDetail }>("/loan-repayments", body);
}
export type IncomingReceiptPrefill = {
context: string; expiresAt: string; ocr: "not_needed" | "used" | "unavailable" | "failed"; pageCount: number;
documentKind: "bank_receipt" | "unknown"; direction: "credit" | "debit" | null;
blocks: string[]; warnings: string[];
fieldIssues: Partial<Record<"amount" | "receivedDate" | "reference" | string, "missing" | "ambiguous" | "invalid">>;
sources: Partial<Record<string, "pdf_text" | "ocr">>;
fields: { originalPrincipal: string | null; receivedDate: string | null; originReference: string | null;
referenceKind: "serial" | "voucher" | null; currency: string | null; payerName: string | null; payerAccountMasked: string | null;
payeeName: string | null; payeeAccountMasked: string | null };
suggestions: { legalEntityId: string | null; lenderId: string | null };
};
export function recognizeIncomingReceipt(file: File, signal?: AbortSignal) {
const body = new FormData(); body.append("file", file);
return apiRequestData<IncomingReceiptPrefill>("/api/company-loans/incoming-receipt/recognize", { method: "POST", body, signal });
}
export function saveLoanFromIncomingReceipt(file: File, payload: {
context: string; requestKey: string; lenderId: string; expectedLenderIdentityVersion: number; expectedLenderAccountVersion: number;
originReference: string; originalPrincipal: string;
openingOutstandingPrincipal: string; receivedDate: string; balanceAsOf: string; historicalEntry: boolean;
originalChecked: true; natureConfirmed: true;
}, signal?: AbortSignal) {
const body = new FormData(); body.append("payload", JSON.stringify(payload)); body.append("file", file);
return apiRequestData<{ item: CompanyLoan; created: boolean }>("/api/company-loans/from-incoming-receipt", { method: "POST", body, signal });
}
export function uploadLoanEvidence(id: string, kind: string, file: File) {
const body = new FormData(); body.append("file", file);
return apiRequestData<LoanEvidence>(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body });
+4
查看文件
@@ -1,4 +1,8 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.incoming_receipt_invalid": "请上传 5MB 以内、不超过 20 页的单份 PDF 原借入回单;文件损坏或无法安全读取时请改用手工登记。",
"loan.incoming_receipt_blocked": "该回单不能作为本笔借入原件:可能是非最终回执、失败或处理中、公司支出、多笔交易、非人民币或收款主体不符。请更换正式入账回单或改用手工登记。",
"loan.incoming_receipt_context_invalid": "识别结果已过期,或与当前账号、所选文件不一致,请重新上传并识别。",
"loan.incoming_receipt_duplicate": "该原件已用于本出借人的其他借款登记,不能重复登记;请核对已有借款。",
"loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。",
"loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。",
"loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。",
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../../auth/AuthContext";
import { recognizeIncomingReceipt, saveLoanFromIncomingReceipt, type IncomingReceiptPrefill, type LoanLender } from "../../api/companyLoans";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
const selectClass = "min-h-11 min-w-0 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
const moneyPattern = "[0-9]{1,12}(\\.[0-9]{1,2})?";
const blockLabels: Record<string, string> = {
non_final: "回单载明不作为最终依据", transaction_failed: "回单显示交易失败或已撤销", transaction_pending: "回单显示交易仍在处理中",
multiple_transactions: "文件包含多笔交易或多张回单(金额、流水号或凭证号不一致)", non_cny: "币种不是人民币",
currency_conflict: "回单中的币种互相矛盾", pages_unverified: "多页文件中有页面无法确认属于同一笔交易,请上传单笔回单",
};
const warningLabels: Record<string, string> = {
text_unavailable: "未能读取 PDF 文字,可能是扫描件", direction_ambiguous: "收付方向无法判断",
company_not_unique: "收款方名称对应多个公司", lender_not_unique: "付款方名称对应多个出借人",
payer_account_differs: "付款账号与出借人当前收款账号不同,请核对是否为原借入账户",
};
const ocrLabels: Record<IncomingReceiptPrefill["ocr"], string> = {
not_needed: "已从 PDF 文字读取", used: "已结合银行回单识别补充", unavailable: "银行回单识别未启用,请对照原件填写", failed: "银行回单识别未完成,请对照原件填写",
};
type Field = "principal" | "received" | "origin";
type Values = Record<Field | "opening" | "asOf" | "lender", string>;
const empty: Values = { principal: "", received: "", origin: "", opening: "", asOf: "", lender: "" };
// One original file per registration: recognition proposes values, finance checks
// them against the original, and the same file is saved with the loan in one step.
export function LoanIncomingReceiptDialog({ lenders, onClose, onManual, onSaved }: {
lenders: LoanLender[]; onClose: () => void; onManual: () => void; onSaved: () => void;
}) {
const { me } = useAuth();
const cache = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [prefill, setPrefill] = useState<IncomingReceiptPrefill | null>(null);
const [values, setValues] = useState<Values>(empty);
const [edited, setEdited] = useState<Set<string>>(new Set());
const editedRef = useRef(edited); editedRef.current = edited;
const [historical, setHistorical] = useState(true);
const [originalChecked, setOriginalChecked] = useState(false);
const [natureConfirmed, setNatureConfirmed] = useState(false);
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
// The lender version finance checked is frozen; a refreshed list never
// silently replaces it. A change clears the checks and asks for a new review.
const [checkedLender, setCheckedLender] = useState<{ id: string; identityVersion: number; accountVersion: number } | null>(null);
const [lenderChanged, setLenderChanged] = useState(false);
const sequence = useRef(0);
const alive = useRef(true);
const pending = useRef<AbortController | null>(null);
const actor = useRef(me?.id); actor.current = me?.id;
useEffect(() => {
alive.current = true;
return () => { alive.current = false; pending.current?.abort(); };
}, [me?.id]);
const listed = lenders.find((l) => l.id === values.lender);
useEffect(() => {
if (!listed) { setCheckedLender(null); return; }
if (!checkedLender || checkedLender.id !== listed.id) {
setCheckedLender({ id: listed.id, identityVersion: listed.identityVersion, accountVersion: listed.accountVersion }); return;
}
if (checkedLender.identityVersion !== listed.identityVersion || checkedLender.accountVersion !== listed.accountVersion) {
setCheckedLender({ id: listed.id, identityVersion: listed.identityVersion, accountVersion: listed.accountVersion });
setOriginalChecked(false); setNatureConfirmed(false); setLenderChanged(true);
}
}, [listed, checkedLender]);
const current = (seq: number, actorId: string | undefined) => alive.current && sequence.current === seq && actor.current === actorId;
const recognition = useMutation({ mutationFn: async ({ selected, seq }: { selected: File; seq: number }) => {
const actorId = me?.id;
const controller = new AbortController(); pending.current?.abort(); pending.current = controller;
try {
const result = await recognizeIncomingReceipt(selected, controller.signal);
if (!current(seq, actorId)) return;
setPrefill(result);
// A late result never overwrites what finance already typed for this file.
setValues((previous) => {
const next = { ...previous };
const fill = (key: keyof Values, value: string | null | undefined) => { if (value && !editedRef.current.has(key)) next[key] = value; };
fill("principal", result.fields.originalPrincipal); fill("received", result.fields.receivedDate);
fill("origin", result.fields.originReference);
if (result.suggestions.lenderId && lenders.some((l) => l.id === result.suggestions.lenderId)) fill("lender", result.suggestions.lenderId);
return next;
});
} finally { if (pending.current === controller) pending.current = null; }
} });
const save = useMutation({ mutationFn: async () => {
const lender = checkedLender;
if (!file || !prefill || prefill.blocks.length || !lender || lender.id !== values.lender) throw new Error("请先上传可用的原借入回单并完成识别。");
const actorId = me?.id; const seq = sequence.current;
const controller = new AbortController(); pending.current = controller;
try {
await saveLoanFromIncomingReceipt(file, {
context: prefill.context, requestKey, lenderId: lender.id, expectedLenderIdentityVersion: lender.identityVersion,
expectedLenderAccountVersion: lender.accountVersion, originReference: values.origin.trim(),
originalPrincipal: values.principal.trim(), openingOutstandingPrincipal: values.opening.trim(),
receivedDate: values.received, balanceAsOf: values.asOf, historicalEntry: historical, originalChecked: true, natureConfirmed: true,
}, controller.signal);
if (!current(seq, actorId)) return;
await Promise.all(["company-loans", "loan-lenders"].map((key) => cache.invalidateQueries({ queryKey: [key, actorId] })));
if (current(seq, actorId)) onSaved();
} finally { if (pending.current === controller) pending.current = null; }
} });
function choose(selected: File | null) {
sequence.current += 1;
pending.current?.abort();
// A new file starts a new registration: its values, checks and request key reset.
setFile(selected); setPrefill(null); setValues((previous) => ({ ...empty, opening: previous.opening, asOf: previous.asOf }));
setEdited(new Set()); setOriginalChecked(false); setNatureConfirmed(false); setRequestKey(crypto.randomUUID());
recognition.reset(); save.reset();
if (selected) recognition.mutate({ selected, seq: sequence.current });
}
function change(key: keyof Values, value: string) {
if (key === "lender") { setLenderChanged(false); setOriginalChecked(false); setNatureConfirmed(false); }
setValues((previous) => ({ ...previous, [key]: value }));
setEdited((previous) => new Set(previous).add(key));
}
function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); save.mutate(); }
const busy = recognition.isPending || save.isPending;
const blocked = Boolean(prefill?.blocks.length);
const lenderChoice = lenders.find((l) => l.id === values.lender);
const issue = (key: string) => prefill?.fieldIssues[key];
const hint = (key: string, field: Field) => {
if (!prefill) return null;
if (issue(key) === "ambiguous") return "原件中存在多个不同值,未预填,请对照原件填写";
if (issue(key) === "missing") return "未识别,请对照原件填写";
if (issue(key) === "invalid") return "原件中的值格式无法确认,未预填,请对照原件填写";
if (edited.has(field)) return "已人工修改";
return prefill.sources[key] === "ocr" ? "来自银行回单识别,请核对" : "来自 PDF 文字,请核对";
};
const error = save.error ?? recognition.error;
return <Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader>
<DialogTitle>上传回单登记借入</DialogTitle>
<DialogDescription>上传一份原借入银行回单 PDF,系统预填金额、到账日期和凭据号,请对照原件核对。保存后借款为“余额待核实”,原件自动归入该笔借款材料。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-4">
<fieldset disabled={save.isPending} className="grid min-w-0 gap-4">
<label className="grid gap-1.5 text-sm">原借入回单(PDF,单笔交易)
<Input type="file" accept=".pdf,application/pdf" aria-label="原借入回单" onChange={(event) => choose(event.currentTarget.files?.[0] ?? null)} />
</label>
{recognition.isPending ? <p role="status" className="text-sm">正在识别回单…</p> : null}
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{prefill ? <div className="grid gap-2 rounded border p-3 text-sm" aria-label="回单识别结果">
<p>{ocrLabels[prefill.ocr]}。识别结果不是银行验真,须以原件为准。</p>
{blocked ? <p role="alert" className="text-red-700">该文件不能作为原借入回单:{prefill.blocks.map((b) => blockLabels[b] ?? "回单存在限制").join(";")}。请更换正式入账回单,或改用手工登记。</p> : null}
{prefill.warnings.length ? <p className="text-amber-800">{prefill.warnings.map((w) => warningLabels[w] ?? "请人工核对").join(";")}。</p> : null}
<dl className="grid gap-1">
<div><dt className="inline text-zinc-600">付款方:</dt><dd className="inline break-words">{prefill.fields.payerName ?? "未识别"} {prefill.fields.payerAccountMasked ?? ""}</dd></div>
<div><dt className="inline text-zinc-600">收款方:</dt><dd className="inline break-words">{prefill.fields.payeeName ?? "未识别"} {prefill.fields.payeeAccountMasked ?? ""}</dd></div>
<div><dt className="inline text-zinc-600">收付方向:</dt><dd className="inline">{prefill.direction === "credit" ? "入账(贷方)" : prefill.direction === "debit" ? "付款(借方),请核对付款方是否为出借人" : "未识别"}</dd></div>
</dl>
</div> : null}
{prefill && !blocked ? <>
<label className="grid gap-1.5 text-sm">出借人<select aria-label="出借人" required className={selectClass} value={values.lender} onChange={(e) => change("lender", e.target.value)}>
<option value="">请选择已登记出借人</option>{lenders.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}
</select></label>
{lenderChanged ? <p role="alert" className="text-sm text-amber-800">所选出借人的资料已更新,请重新核对后再勾选确认。</p> : null}
{!values.lender ? <p className="text-sm text-zinc-600">没有对应出借人时,请先用“登记出借人”从员工选择或登记,再回来上传。</p> : null}
{lenderChoice && prefill.fields.payeeName && prefill.fields.payeeName !== lenderChoice.legalEntityName
? <p role="alert" className="text-sm text-amber-800">回单收款方与所选出借人的借款公司不一致,保存时将被拒绝。</p> : null}
{([["principal", "amount", "原始本金", "decimal"], ["received", "receivedDate", "实际到账日期", "date"], ["origin", "reference", "原借入凭据唯一引用", "text"]] as const)
.map(([field, key, label, kind]) => <label key={field} className="grid gap-1.5 text-sm">{label}
<Input required aria-label={label} value={values[field]} onChange={(e) => change(field, e.target.value)}
{...(kind === "date" ? { type: "date" } : kind === "decimal" ? { inputMode: "decimal" as const, pattern: moneyPattern } : { maxLength: 200 })} />
<span className="text-xs text-zinc-600">{hint(key, field)}{field === "origin" && prefill.fields.referenceKind ? `(${prefill.fields.referenceKind === "serial" ? "流水号" : "凭证号"})` : ""}</span>
</label>)}
<label className="grid gap-1.5 text-sm">核对日未还本金<Input required aria-label="核对日未还本金" inputMode="decimal" pattern={moneyPattern} value={values.opening} onChange={(e) => change("opening", e.target.value)} />
<span className="text-xs text-zinc-600">回单不能证明未还本金,请按代账核对结果填写。</span></label>
<label className="grid gap-1.5 text-sm">余额核对截止日<Input required aria-label="余额核对截止日" type="date" value={values.asOf} onChange={(e) => change("asOf", e.target.value)} /></label>
<label className="grid gap-1.5 text-sm">登记性质<select aria-label="登记性质" className={selectClass} value={historical ? "historical" : "current"} onChange={(e) => setHistorical(e.target.value === "historical")}>
<option value="historical">历史借入登记</option><option value="current">当期实际借入</option></select></label>
<label className="flex min-h-11 items-start gap-2 text-sm"><input type="checkbox" className="mt-1" required checked={originalChecked} onChange={(e) => { setOriginalChecked(e.target.checked); setLenderChanged(false); }} />
<span>已查看完整原件,金额、日期和凭据号与原件一致</span></label>
<label className="flex min-h-11 items-start gap-2 text-sm"><input type="checkbox" className="mt-1" required checked={natureConfirmed} onChange={(e) => setNatureConfirmed(e.target.checked)} />
<span>已核对这笔入账是向该出借人的借款,且收款主体为所选公司</span></label>
</> : null}
<Button type="submit" disabled={!prefill || blocked || busy || !originalChecked || !natureConfirmed}>{save.isPending ? "保存中…" : "保存借款及原件"}</Button>
<Button type="button" variant="outline" disabled={save.isPending} onClick={onManual}>不上传回单,手工登记</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>;
}
+6 -2
查看文件
@@ -9,6 +9,7 @@ import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoan
fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans";
import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations";
import { LoanEmployeeLenderDialog } from "../components/common/LoanEmployeeLenderDialog";
import { LoanIncomingReceiptDialog } from "../components/common/LoanIncomingReceiptDialog";
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -21,7 +22,7 @@ import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "loanReceipt" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
function Field({ label, children }: { label: string; children: ReactNode }) {
const id = useId();
@@ -105,6 +106,7 @@ export function CompanyLoansPage() {
return <ListWorkspace>
<WorkspaceHeader title="公司借款" subtitle="记录公司向个人借入及归还的人民币本金。余额以核对截止日及 OA 登记为依据,未核实不代表已结清。"
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("employeeLender")}>登记出借人</Button>
<Button variant="outline" onClick={() => open("loanReceipt")}>上传回单登记借入</Button>
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) =>
@@ -156,7 +158,9 @@ export function CompanyLoansPage() {
{mode === "employeeLender" || mode === "employeeRefresh" ? <LoanEmployeeLenderDialog key={`${me?.id}-${requestKey}`}
lender={mode === "employeeRefresh" ? selectedLender : null} onClose={() => setMode(null)} onExternal={() => open("lender")}
onRefresh={(lender) => open("employeeRefresh", lender)} onSaved={() => { setMode(null); setTab("lenders"); setLenderPage(1); }} /> : null}
<Dialog open={mode !== null && mode !== "employeeLender" && mode !== "employeeRefresh"} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
{mode === "loanReceipt" ? <LoanIncomingReceiptDialog key={`${me?.id}-${requestKey}`} lenders={lenders.data?.items ?? []}
onClose={() => setMode(null)} onManual={() => open("loan")} onSaved={() => { setMode(null); setTab("loans"); setPage(1); }} /> : null}
<Dialog open={mode !== null && mode !== "employeeLender" && mode !== "employeeRefresh" && mode !== "loanReceipt"} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
{({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" :