import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { fetchReceiptVerification, saveReceiptVerification, reviewReceiptVerification, checkReceiptDocument, type ReceiptVerificationPage, } from "../../api/paymentBatches"; import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Textarea } from "../ui/textarea"; const FIELD_NAMES = ["小写金额", "币别", "付款人账号", "收款人账号", "凭证号", "单据状态"] as const; const CHANGE_LABELS: Record = { amount: "金额", currency: "币种", payerAccount: "付款账号", payeeAccount: "收款账号", reference: "交易参考", transactionStatus: "交易状态", paymentProof: "付款凭证依据", }; function PageFacts({ page }: { page?: ReceiptVerificationPage }) { return
金额:{page?.amount ?? "未识别"} · 币种:{page?.currency ?? "未识别"}
付款账号:{page?.payerAccountMasked ?? "未识别"}
收款账号:{page?.payeeAccountMasked ?? "未识别"}
交易参考:{page?.reference ?? "未识别"}
状态:{page?.document?.proof?.basis === "booked_debit" ? "已记账支出(人工核对)" : ({ success: "成功", failed: "失败", pending: "受理或处理中" }[page?.transactionStatus ?? ""] ?? "未确认")}
凭证性质:{{ bank_receipt: "银行正式回单", non_final: "非最终回执", unknown: "需核对原件" }[page?.document?.kind ?? "unknown"]}
{page?.document?.proof ?
付款依据:{page.document.proof.basis === "booked_debit" ? `正式回单记载已记账支出,记账日期 ${page.document.proof.bookedOn}` : "正式回单原件明确显示付款成功"}
: null}
; } type Props = { actorId: string; batchId: string; attachmentId: string; makerId: string | null; canManage: boolean; canReview: boolean; relatedParty?: boolean; items: { id: string; label: string }[]; onClose: () => void; onChanged: () => void; }; export function PaymentReceiptVerificationDialog(props: Props) { const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const [selections, setSelections] = useState>({}); const [editing, setEditing] = useState>({}); const [changes, setChanges] = useState>>({}); const [reason, setReason] = useState(""); const [proofs, setProofs] = useState>({}); const query = useQuery({ queryKey: ["receipt-verification", props.actorId, props.batchId, props.attachmentId], queryFn: ({ signal }) => fetchReceiptVerification(props.batchId, props.attachmentId, signal), enabled: Boolean(props.actorId), staleTime: 0, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }); const detail = query.data?.item; const changed = () => { if (!mounted.current) return; props.onChanged(); props.onClose(); }; const save = useMutation({ mutationFn: () => saveReceiptVerification(props.batchId, props.attachmentId, { expectedVersion: detail!.version, ...(reason.trim() ? { reason: reason.trim() } : {}), pages: Array.from({ length: detail!.pagesExpected }, (_unused, index) => { const pageNumber = index + 1; const fields = Object.entries(changes[pageNumber] ?? {}).filter(([, value]) => value.trim()) .map(([Name, Value]) => ({ Name, Value })); const proof = proofs[pageNumber]; if (proof?.basis === "booked_debit") { const status = fields.find((field) => field.Name === "单据状态"); if (status) status.Value = ""; else fields.push({ Name: "单据状态", Value: "" }); } return { pageNumber, itemId: selections[pageNumber] ?? "", ...(fields.length ? { fields } : {}), ...(proof?.basis ? { paymentProof: { basis: proof.basis, originalChecked: true as const, note: reason.trim(), ...(proof.basis === "booked_debit" ? { bookedOn: proof.bookedOn } : {}) } } : {}) }; }), }), onSuccess: changed, }); const review = useMutation({ mutationFn: (decision: "approve" | "reject") => reviewReceiptVerification( props.batchId, props.attachmentId, detail!.proposal!.id, { expectedVersion: detail!.version, decision, reason: reason.trim() }, ), onSuccess: changed, }); const inspect = useMutation({ mutationFn: () => checkReceiptDocument(props.batchId, props.attachmentId, detail!.version), onSuccess: changed, }); const busy = save.isPending || review.isPending || inspect.isPending; const pageCount = detail && Number.isInteger(detail.pagesExpected) && detail.pagesExpected > 0 && detail.pagesExpected <= 20 ? detail.pagesExpected : 0; const editable = props.canManage && detail && !detail.documentInspectionRequired && ["needs_review", "ocr_failed"].includes(detail.state) && !query.isError; const reviewable = props.canReview && detail?.canReview === true && detail?.proposal?.status === "pending_review" && props.actorId !== detail.proposal.proposedBy && props.actorId !== props.makerId && !query.isError; const blocked = (page?: ReceiptVerificationPage) => page?.document?.kind === "non_final" || page?.document?.direction === "credit" || Boolean(page?.document?.issues.length); const needsProof = (page?: ReceiptVerificationPage) => page?.document?.kind !== "bank_receipt" || !page.document.explicitSuccess; const complete = pageCount > 0 && Array.from({ length: pageCount }, (_unused, index) => { const n = index + 1; const page = detail?.pages.find((entry) => entry.pageNumber === n); const proof = proofs[n]; return selections[n] && !blocked(page) && (!needsProof(page) || (proof?.basis && proof.checked && reason.trim().length >= 5 && (proof.basis !== "booked_debit" || /^\d{4}-\d{2}-\d{2}$/.test(proof.bookedOn)))); }).every(Boolean); const hasCorrections = Object.values(changes).some((fields) => Object.values(fields).some((value) => value.trim())); const error = query.error ?? save.error ?? review.error ?? inspect.error; return { if (!open && !busy) props.onClose(); }}> 回单核验 核对原回单与付款明细。账号仅显示掩码;需要核对完整内容时请使用回单“在线查看”。核验不代表银行验真,也不会自动登记付款。 {query.isPending ?

正在加载核验信息…

: null} {error ?

{resolveErrorMessage(error)}

: null} {detail?.documentInspectionRequired ?

旧核验尚未记录凭证性质。更新后会保留原记录,并要求重新核对原件;不会重新发送文件识别。

{props.canManage && !query.isError ? : null}
: null} {detail && !pageCount ?

尚无完整页数,请先重新识别或上传可读取的原回单。

: null} {Array.from({ length: pageCount }, (_unused, index) => index + 1).map((pageNumber) => { const original = detail?.pages.find((page) => page.pageNumber === pageNumber); const proposed = detail?.proposal?.pages.find((page) => page.pageNumber === pageNumber); return

第{pageNumber}页 · 原识别结果

{blocked(original) ?

这份材料不能证明本笔付款完成。请提供对应的正式付款回单;不能通过更正字段覆盖非最终说明、收付方向或交易矛盾。

: null} {proposed ?

人工核验结果

对应明细:{props.items.find((item) => item.id === proposed.itemId)?.label ?? "当前明细不可用"}

更正字段:{detail?.proposal?.changedFields.find((entry) => entry.pageNumber === pageNumber)?.fields.map((field) => CHANGE_LABELS[field] ?? "字段").join("、") || "未更正,仅关联"}

: null} {editable && !blocked(original) ? <> {needsProof(original) ?
{proofs[pageNumber]?.basis === "booked_debit" ? : null}

记账支出不要求补填“交易成功”。凭证性质或资金方向不能确认时,请先补证。

: null} {editing[pageNumber] ?

只填写要更正的字段,其他字段保留原识别值。更正后需要{props.relatedParty ? "公司代表确认" : "独立复核"}。

{FIELD_NAMES.filter((name) => name !== "单据状态" || proofs[pageNumber]?.basis !== "booked_debit").map((name) => )}
: null} : null}
; })} {detail?.proposal ?

核验说明:{detail.proposal.reason}

: null} {detail?.proposal?.status === "pending_review" && !reviewable ?

{props.relatedParty ? "等待有当前授权的公司代表确认;上传人、录入人和制单人不能自审。" : "等待有资格的独立审批人复核;经办人和报销参与人不能自审。"}

: null} {editable || reviewable ?