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 = { non_final: "回单载明不作为最终依据", transaction_failed: "回单显示交易失败或已撤销", transaction_pending: "回单显示交易仍在处理中", multiple_transactions: "文件包含多笔交易或多张回单(金额、流水号或凭证号不一致)", non_cny: "币种不是人民币", currency_conflict: "回单中的币种互相矛盾", pages_unverified: "多页文件中有页面无法确认属于同一笔交易,请上传单笔回单", }; const warningLabels: Record = { text_unavailable: "未能读取 PDF 文字,可能是扫描件", direction_ambiguous: "收付方向无法判断", company_not_unique: "收款方名称对应多个公司", lender_not_unique: "付款方名称对应多个出借人", payer_account_differs: "付款账号与出借人当前收款账号不同,请核对是否为原借入账户", }; const ocrLabels: Record = { not_needed: "已从 PDF 文字读取", used: "已结合银行回单识别补充", unavailable: "银行回单识别未启用,请对照原件填写", failed: "银行回单识别未完成,请对照原件填写", }; type Field = "principal" | "received" | "origin"; type Values = Record; 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(null); const [prefill, setPrefill] = useState(null); const [values, setValues] = useState(empty); const [edited, setEdited] = useState>(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(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) { 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 { if (!open && !busy) onClose(); }}> 上传回单登记借入 上传一份原借入银行回单 PDF,系统预填金额、到账日期和凭据号,请对照原件核对。保存后借款为“余额待核实”,原件自动归入该笔借款材料。
{recognition.isPending ?

正在识别回单…

: null} {error ?

{resolveErrorMessage(error)}

: null} {prefill ?

{ocrLabels[prefill.ocr]}。识别结果不是银行验真,须以原件为准。

{blocked ?

该文件不能作为原借入回单:{prefill.blocks.map((b) => blockLabels[b] ?? "回单存在限制").join(";")}。请更换正式入账回单,或改用手工登记。

: null} {prefill.warnings.length ?

{prefill.warnings.map((w) => warningLabels[w] ?? "请人工核对").join(";")}。

: null}
付款方:
{prefill.fields.payerName ?? "未识别"} {prefill.fields.payerAccountMasked ?? ""}
收款方:
{prefill.fields.payeeName ?? "未识别"} {prefill.fields.payeeAccountMasked ?? ""}
收付方向:
{prefill.direction === "credit" ? "入账(贷方)" : prefill.direction === "debit" ? "付款(借方),请核对付款方是否为出借人" : "未识别"}
: null} {prefill && !blocked ? <> {lenderChanged ?

所选出借人的资料已更新,请重新核对后再勾选确认。

: null} {!values.lender ?

没有对应出借人时,请先用“登记出借人”从员工选择或登记,再回来上传。

: null} {lenderChoice && prefill.fields.payeeName && prefill.fields.payeeName !== lenderChoice.legalEntityName ?

回单收款方与所选出借人的借款公司不一致,保存时将被拒绝。

: null} {([["principal", "amount", "原始本金", "decimal"], ["received", "receivedDate", "实际到账日期", "date"], ["origin", "reference", "原借入凭据唯一引用", "text"]] as const) .map(([field, key, label, kind]) => )} : null}
; }