feat: 完成P6-P8前端能力闭环(V3,D2,D3,B3)
这个提交包含在:
@@ -7,12 +7,15 @@ import { exportInvoicePool } from "../api/exports";
|
||||
import {
|
||||
archiveInvoice,
|
||||
bindInvoice,
|
||||
fetchInvoiceVerifications,
|
||||
fetchInvoicePoolDetail,
|
||||
fetchInvoicePoolList,
|
||||
fetchInvoiceSignedUrl,
|
||||
overrideInvoiceDuplicate,
|
||||
patchInvoice,
|
||||
retryInvoiceVerification,
|
||||
retryInvoiceOcr,
|
||||
triggerInvoiceVerification,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type InvoicePoolStatus,
|
||||
@@ -35,6 +38,7 @@ export function InvoicePoolPage() {
|
||||
const canManageInvoice = hasPermission(permissions, "invoice.manage");
|
||||
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||
@@ -90,6 +94,17 @@ export function InvoicePoolPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const verificationQuery = useQuery({
|
||||
enabled: Boolean(detailInvoiceId) && canVerifyInvoice,
|
||||
queryKey: ["invoice-pool", "verification", detailInvoiceId],
|
||||
queryFn: () => fetchInvoiceVerifications(detailInvoiceId, 20),
|
||||
refetchInterval: (query) => {
|
||||
const items = query.state.data?.items ?? [];
|
||||
const hasPending = items.some((item) => item.status === "pending");
|
||||
return hasPending ? 3_000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailQuery.data || editOpen) {
|
||||
return;
|
||||
@@ -251,6 +266,42 @@ export function InvoicePoolPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const triggerVerificationMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => triggerInvoiceVerification(invoiceId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
||||
]);
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else {
|
||||
setError("触发验真失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const retryVerificationMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => retryInvoiceVerification(invoiceId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
||||
]);
|
||||
setError("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (isApiError(err)) {
|
||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||
} else {
|
||||
setError("重试验真失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const overrideDuplicateMutation = useMutation({
|
||||
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
|
||||
overrideInvoiceDuplicate(params.invoiceId, {
|
||||
@@ -557,8 +608,48 @@ export function InvoicePoolPage() {
|
||||
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
|
||||
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
|
||||
</div>
|
||||
<div>
|
||||
验真状态:
|
||||
{detail.verification
|
||||
? `${detail.verification.status} (${detail.verification.provider})`
|
||||
: "未触发"}
|
||||
</div>
|
||||
{detail.verification?.resultMessage ? (
|
||||
<div>验真结果:{detail.verification.resultMessage}</div>
|
||||
) : null}
|
||||
{detail.verification?.errorMessage ? (
|
||||
<div className="text-red-600">验真错误:{detail.verification.errorMessage}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{canVerifyInvoice ? (
|
||||
<Button
|
||||
data-testid="invoice-trigger-verification"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
|
||||
onClick={() => triggerVerificationMutation.mutate(detail.id)}
|
||||
>
|
||||
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canVerifyInvoice &&
|
||||
detail.verification &&
|
||||
["failed", "error"].includes(detail.verification.status) ? (
|
||||
<Button
|
||||
data-testid="invoice-retry-verification"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={retryVerificationMutation.isPending}
|
||||
onClick={() => retryVerificationMutation.mutate(detail.id)}
|
||||
>
|
||||
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEditDetail ? (
|
||||
<Button
|
||||
data-testid="invoice-edit-open"
|
||||
@@ -647,6 +738,31 @@ export function InvoicePoolPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canVerifyInvoice ? (
|
||||
<div className="mt-3 rounded-lg border border-slate-200 p-3" data-testid="invoice-verification-records">
|
||||
<p className="mb-2 text-sm font-medium text-slate-800">验真记录</p>
|
||||
{(verificationQuery.data?.items ?? []).length ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{(verificationQuery.data?.items ?? []).map((record) => (
|
||||
<div key={record.id} className="rounded-md border border-slate-200 px-2 py-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-mono">{record.id}</span>
|
||||
<span>{record.status}</span>
|
||||
</div>
|
||||
<div className="text-slate-600">
|
||||
provider={record.provider} attempts={record.attempts} requestedAt={record.requestedAt || "-"}
|
||||
</div>
|
||||
{record.resultMessage ? <div className="text-slate-700">{record.resultMessage}</div> : null}
|
||||
{record.errorMessage ? <div className="text-red-600">{record.errorMessage}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">暂无验真记录</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editOpen ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
|
||||
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
|
||||
|
||||
在新工单中引用
屏蔽一个用户