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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 10:34:40 +08:00

189 行
15 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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>;
}