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