H1-H10 优化OCR实时体验闭环
这个提交包含在:
@@ -105,11 +105,11 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (code === "VALID_006") {
|
||||
return "OCR 重试次数已达上限,请稍后人工处理。";
|
||||
return "票据识别重试次数已达上限,请稍后人工处理。";
|
||||
}
|
||||
|
||||
if (code === "VALID_007") {
|
||||
return "OCR 重试过于频繁,请稍后再试。";
|
||||
return "票据识别重试过于频繁,请稍后再试。";
|
||||
}
|
||||
|
||||
if (code === "VALID_001") {
|
||||
@@ -188,7 +188,7 @@ export function mapApiErrorMessage(
|
||||
}
|
||||
|
||||
if (details?.ocr === "disabled") {
|
||||
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
||||
return "当前环境未开启票据识别,请联系管理员处理。";
|
||||
}
|
||||
|
||||
if (details?.ocr === "missing_required_fields") {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export type AppEvent = {
|
||||
id?: string;
|
||||
type: string;
|
||||
invoiceId?: string;
|
||||
status?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
type SubscribeAppEventsOptions = {
|
||||
accessToken: string;
|
||||
onEvent: (event: AppEvent) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function parseSseMessage(message: string): AppEvent | null {
|
||||
const dataLines = message
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart());
|
||||
|
||||
if (!dataLines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(dataLines.join("\n")) as AppEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAppEvents(options: SubscribeAppEventsOptions) {
|
||||
const controller = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/events", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
authorization: `Bearer ${options.accessToken}`,
|
||||
accept: "text/event-stream",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`events_stream_unavailable:${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = chunks.pop() ?? "";
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const event = parseSseMessage(chunk);
|
||||
if (event) {
|
||||
options.onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
options.onError?.(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export type InvoiceVerificationRecord = {
|
||||
export type InvoicePoolListItem = {
|
||||
id: string;
|
||||
status: InvoicePoolStatus;
|
||||
availabilityReason: string | null;
|
||||
availabilityMessage: string | null;
|
||||
ocrType: string;
|
||||
ocrProvider: string;
|
||||
ocrAttempts: number;
|
||||
@@ -68,11 +70,15 @@ export type UploadInvoiceResponse = {
|
||||
uploadAction?: "create" | "reuse_existing" | "blocked";
|
||||
unavailableReason?: string | null;
|
||||
unavailableMessage?: string | null;
|
||||
availabilityReason?: string | null;
|
||||
availabilityMessage?: string | null;
|
||||
};
|
||||
|
||||
export type InvoicePoolDetail = {
|
||||
id: string;
|
||||
status: InvoicePoolStatus;
|
||||
availabilityReason: string | null;
|
||||
availabilityMessage: string | null;
|
||||
archivedReason: string | null;
|
||||
archivedAt: string | null;
|
||||
ownerEmployeeId: string;
|
||||
@@ -144,6 +150,8 @@ export type InvoiceStatusItem = {
|
||||
status: InvoicePoolStatus;
|
||||
reason: string;
|
||||
message: string;
|
||||
availabilityReason: string | null;
|
||||
availabilityMessage: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { subscribeAppEvents } from "../api/events";
|
||||
import {
|
||||
fetchInvoiceStatuses,
|
||||
type InvoiceStatusItem,
|
||||
} from "../api/invoicePool";
|
||||
|
||||
type UseInvoiceOcrTrackerOptions = {
|
||||
accessToken: string | null;
|
||||
enabled: boolean;
|
||||
pendingInvoiceIds: string[];
|
||||
setPendingInvoiceIds: (updater: string[] | ((previous: string[]) => string[])) => void;
|
||||
onAvailable: (invoiceIds: string[]) => void;
|
||||
onUnavailable: (items: InvoiceStatusItem[]) => void;
|
||||
};
|
||||
|
||||
export function useInvoiceOcrTracker({
|
||||
accessToken,
|
||||
enabled,
|
||||
pendingInvoiceIds,
|
||||
setPendingInvoiceIds,
|
||||
onAvailable,
|
||||
onUnavailable,
|
||||
}: UseInvoiceOcrTrackerOptions) {
|
||||
const [streamFailed, setStreamFailed] = useState(false);
|
||||
const firstPendingAtRef = useRef<number | null>(null);
|
||||
const handledIdsRef = useRef(new Set<string>());
|
||||
const pendingKey = pendingInvoiceIds.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingInvoiceIds.length && firstPendingAtRef.current == null) {
|
||||
firstPendingAtRef.current = Date.now();
|
||||
}
|
||||
if (!pendingInvoiceIds.length) {
|
||||
firstPendingAtRef.current = null;
|
||||
handledIdsRef.current.clear();
|
||||
}
|
||||
}, [pendingInvoiceIds.length]);
|
||||
|
||||
const statusQuery = useQuery({
|
||||
enabled: enabled && pendingInvoiceIds.length > 0,
|
||||
queryKey: ["invoice-pool", "status", pendingInvoiceIds],
|
||||
queryFn: () => fetchInvoiceStatuses(pendingInvoiceIds),
|
||||
refetchInterval: pendingInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !accessToken || !pendingInvoiceIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStreamFailed(false);
|
||||
return subscribeAppEvents({
|
||||
accessToken,
|
||||
onEvent: (event) => {
|
||||
if (event.type !== "invoice.updated" || !event.invoiceId) {
|
||||
return;
|
||||
}
|
||||
if (pendingInvoiceIds.includes(event.invoiceId)) {
|
||||
void statusQuery.refetch();
|
||||
}
|
||||
},
|
||||
onError: () => setStreamFailed(true),
|
||||
});
|
||||
}, [accessToken, enabled, pendingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const statuses = statusQuery.data?.items ?? [];
|
||||
if (!statuses.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = statuses
|
||||
.filter((item) => item.status === "available")
|
||||
.map((item) => item.id)
|
||||
.filter((invoiceId) => !handledIdsRef.current.has(invoiceId));
|
||||
const unavailableItems = statuses
|
||||
.filter((item) => ["ocr_failed", "archived", "bound"].includes(item.status))
|
||||
.filter((item) => !handledIdsRef.current.has(item.id));
|
||||
const finishedIds = new Set([...availableIds, ...unavailableItems.map((item) => item.id)]);
|
||||
|
||||
if (!finishedIds.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const invoiceId of finishedIds) {
|
||||
handledIdsRef.current.add(invoiceId);
|
||||
}
|
||||
|
||||
if (availableIds.length) {
|
||||
onAvailable(availableIds);
|
||||
}
|
||||
if (unavailableItems.length) {
|
||||
onUnavailable(unavailableItems);
|
||||
}
|
||||
|
||||
setPendingInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
|
||||
}, [statusQuery.data?.items, onAvailable, onUnavailable, setPendingInvoiceIds]);
|
||||
|
||||
const hasLongWait = Boolean(
|
||||
pendingInvoiceIds.length &&
|
||||
firstPendingAtRef.current &&
|
||||
Date.now() - firstPendingAtRef.current > 60_000,
|
||||
);
|
||||
|
||||
const statusMessage = useMemo(() => {
|
||||
if (!pendingInvoiceIds.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (hasLongWait) {
|
||||
return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后会自动添加。`;
|
||||
}
|
||||
|
||||
return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`;
|
||||
}, [hasLongWait, pendingInvoiceIds.length]);
|
||||
|
||||
return {
|
||||
statusMessage,
|
||||
isTracking: pendingInvoiceIds.length > 0,
|
||||
streamFailed,
|
||||
refetch: statusQuery.refetch,
|
||||
};
|
||||
}
|
||||
@@ -29,19 +29,19 @@ export function formatInvoiceOcrFieldLabels(fields: string[]) {
|
||||
export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) {
|
||||
const missingFields = parseMissingInvoiceOcrFields(rawError);
|
||||
if (missingFields.length) {
|
||||
return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
|
||||
return `票据识别缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
|
||||
}
|
||||
|
||||
if (rawError === "duplicate_invoice_code_number") {
|
||||
return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。";
|
||||
return "已识别出相同发票,不能重复添加。";
|
||||
}
|
||||
|
||||
if (rawError === "mock_ocr_failed") {
|
||||
return "OCR 识别失败,请重试或人工补齐关键信息。";
|
||||
return "票据识别失败,请重试或人工补齐关键信息。";
|
||||
}
|
||||
|
||||
if (!rawError) {
|
||||
return "OCR 识别失败,请稍后重试。";
|
||||
return "票据识别失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return rawError;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoicePoolList,
|
||||
fetchInvoiceStatuses,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type BindableInvoiceSummary,
|
||||
@@ -48,6 +47,7 @@ import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type ItemFormState = {
|
||||
@@ -162,8 +162,9 @@ export function MobileReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const { me, permissions, accessToken } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileDocumentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||
@@ -234,16 +235,6 @@ export function MobileReimbursementDetailPage() {
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const pendingInvoiceStatusesQuery = useQuery({
|
||||
enabled: pendingOcrInvoiceIds.length > 0,
|
||||
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
|
||||
queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds),
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const invoiceStatusMessage = pendingOcrInvoiceIds.length
|
||||
? `${pendingOcrInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`
|
||||
: invoiceActionMessage;
|
||||
const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success";
|
||||
const invoiceSearchLower = invoiceSearch.toLowerCase();
|
||||
const bindableLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
|
||||
@@ -446,6 +437,35 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleTrackedInvoicesAvailable = useCallback(
|
||||
(invoiceIds: string[]) => {
|
||||
if (!invoiceIds.length) {
|
||||
return;
|
||||
}
|
||||
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
|
||||
},
|
||||
[bindInvoicesMutation],
|
||||
);
|
||||
|
||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
|
||||
const message = items
|
||||
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.availabilityMessage ?? item.message}`)
|
||||
.join(";");
|
||||
setInvoiceActionError(message);
|
||||
setInvoiceActionMessage("");
|
||||
toast.error("部分发票需要处理");
|
||||
}, []);
|
||||
|
||||
const invoiceOcrTracker = useInvoiceOcrTracker({
|
||||
accessToken,
|
||||
enabled: Boolean(reimbursementId),
|
||||
pendingInvoiceIds: pendingOcrInvoiceIds,
|
||||
setPendingInvoiceIds: setPendingOcrInvoiceIds,
|
||||
onAvailable: handleTrackedInvoicesAvailable,
|
||||
onUnavailable: handleTrackedInvoicesUnavailable,
|
||||
});
|
||||
const invoiceStatusMessage = invoiceOcrTracker.statusMessage || invoiceActionMessage;
|
||||
|
||||
const unbindInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
@@ -537,10 +557,15 @@ export function MobileReimbursementDetailPage() {
|
||||
await invalidateDetail();
|
||||
await bindableInvoicesQuery.refetch();
|
||||
|
||||
setPendingOcrInvoiceIds(result.pendingIds);
|
||||
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
||||
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
|
||||
if (!result.failures.length && fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
if (!result.failures.length) {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileDocumentInputRef.current) {
|
||||
fileDocumentInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
@@ -617,38 +642,6 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const statuses = pendingInvoiceStatusesQuery.data?.items ?? [];
|
||||
if (!statuses.length || bindInvoicesMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = statuses
|
||||
.filter((item) => item.status === "available")
|
||||
.map((item) => item.id);
|
||||
const finishedIds = new Set(
|
||||
statuses
|
||||
.filter((item) => item.status !== "pending_ocr")
|
||||
.map((item) => item.id),
|
||||
);
|
||||
const failedMessages = statuses
|
||||
.filter((item) => item.status === "ocr_failed" || item.status === "archived")
|
||||
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`);
|
||||
|
||||
if (failedMessages.length) {
|
||||
setInvoiceActionError(failedMessages.join(";"));
|
||||
setInvoiceActionMessage("");
|
||||
}
|
||||
|
||||
if (availableIds.length) {
|
||||
bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId })));
|
||||
}
|
||||
|
||||
if (finishedIds.size) {
|
||||
setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
|
||||
}
|
||||
}, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!detail) {
|
||||
return;
|
||||
@@ -704,6 +697,9 @@ export function MobileReimbursementDetailPage() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileDocumentInputRef.current) {
|
||||
fileDocumentInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
|
||||
@@ -1145,23 +1141,42 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
{invoicePanel === "upload" ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<label
|
||||
htmlFor="mobile-reimbursement-invoice-upload"
|
||||
className="flex min-h-32 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-5 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[30px] text-zinc-700">upload_file</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">
|
||||
{selectedInvoiceFiles.length ? `已选择 ${selectedInvoiceFiles.length} 个文件` : "选择或拍摄发票"}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">支持图片和 PDF,可多选</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
htmlFor="mobile-reimbursement-invoice-upload-image"
|
||||
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[28px] text-zinc-700">photo_camera</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选图片</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">适合手机拍发票</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="mobile-reimbursement-invoice-upload-document"
|
||||
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[28px] text-zinc-700">picture_as_pdf</span>
|
||||
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF/文件</span>
|
||||
<span className="mt-1 text-xs text-zinc-500">适合电子发票</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="mobile-reimbursement-invoice-upload"
|
||||
id="mobile-reimbursement-invoice-upload-image"
|
||||
className="sr-only"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
<input
|
||||
ref={fileDocumentInputRef}
|
||||
id="mobile-reimbursement-invoice-upload-document"
|
||||
className="sr-only"
|
||||
data-testid="reimbursement-invoice-upload-document"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceStatuses,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
} from "../api/invoicePool";
|
||||
@@ -54,6 +53,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
@@ -89,10 +89,6 @@ function formatDateTime(value: string | null) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
@@ -142,7 +138,7 @@ function resolveErpSyncStatus(
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { permissions, me } = useAuth();
|
||||
const { permissions, me, accessToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [actionError, setActionError] = useState("");
|
||||
@@ -501,7 +497,9 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFiles([]);
|
||||
setPendingOcrInvoiceIds([]);
|
||||
setPendingOcrInvoiceIds((previous) =>
|
||||
previous.filter((invoiceId) => !succeededIds.includes(invoiceId)),
|
||||
);
|
||||
toast.success(`已添加 ${succeededIds.length} 张发票`);
|
||||
return;
|
||||
}
|
||||
@@ -523,6 +521,33 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleTrackedInvoicesAvailable = useCallback(
|
||||
(invoiceIds: string[]) => {
|
||||
if (!invoiceIds.length) {
|
||||
return;
|
||||
}
|
||||
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
|
||||
},
|
||||
[bindInvoicesMutation],
|
||||
);
|
||||
|
||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
|
||||
const message = items
|
||||
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
|
||||
.join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error("部分发票需要处理");
|
||||
}, []);
|
||||
|
||||
const invoiceOcrTracker = useInvoiceOcrTracker({
|
||||
accessToken,
|
||||
enabled: Boolean(reimbursementId),
|
||||
pendingInvoiceIds: pendingOcrInvoiceIds,
|
||||
setPendingInvoiceIds: setPendingOcrInvoiceIds,
|
||||
onAvailable: handleTrackedInvoicesAvailable,
|
||||
onUnavailable: handleTrackedInvoicesUnavailable,
|
||||
});
|
||||
|
||||
const unbindInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
|
||||
onSuccess: async () => {
|
||||
@@ -584,34 +609,9 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (pendingById.size && Date.now() < deadline) {
|
||||
await sleep(2_000);
|
||||
const invoiceResults = await fetchInvoiceStatuses(Array.from(pendingById.keys()));
|
||||
|
||||
for (const invoice of invoiceResults.items) {
|
||||
if (!invoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (invoice.status === "available") {
|
||||
availableIds.push(invoice.id);
|
||||
pendingById.delete(invoice.id);
|
||||
} else if (invoice.status === "ocr_failed") {
|
||||
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
||||
ocrFailures.push(`${fileName}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
|
||||
pendingById.delete(invoice.id);
|
||||
} else if (invoice.status === "archived" || invoice.status === "bound") {
|
||||
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
|
||||
ocrFailures.push(`${fileName}: ${invoice.message}`);
|
||||
pendingById.delete(invoice.id);
|
||||
}
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
||||
}
|
||||
setPendingOcrInvoiceIds((previous) =>
|
||||
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
|
||||
);
|
||||
|
||||
for (const invoiceId of availableIds) {
|
||||
try {
|
||||
@@ -635,30 +635,31 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
|
||||
setPendingOcrInvoiceIds(result.pendingIds);
|
||||
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||
await bindableInvoicesQuery.refetch();
|
||||
|
||||
const messages = [
|
||||
const blockingMessages = [
|
||||
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
|
||||
...result.ocrFailures,
|
||||
...result.bindFailures,
|
||||
];
|
||||
if (result.pendingIds.length) {
|
||||
messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`);
|
||||
}
|
||||
|
||||
if (messages.length) {
|
||||
const message = messages.join(";");
|
||||
if (blockingMessages.length) {
|
||||
const message = blockingMessages.join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`);
|
||||
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`);
|
||||
return;
|
||||
}
|
||||
|
||||
setInvoiceActionError("");
|
||||
if (result.pendingIds.length) {
|
||||
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`);
|
||||
return;
|
||||
}
|
||||
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
|
||||
},
|
||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||
@@ -1644,7 +1645,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
{pendingOcrInvoiceIds.length ? (
|
||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
{pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。
|
||||
{invoiceOcrTracker.statusMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 });
|
||||
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。");
|
||||
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("票据识别缺少关键字段:发票代码、销售方。");
|
||||
|
||||
await page.getByTestId("invoice-edit-open").click();
|
||||
await page.getByTestId("invoice-edit-set-available").check();
|
||||
|
||||
@@ -354,6 +354,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
uploadAction: "create",
|
||||
unavailableReason: null,
|
||||
unavailableMessage: null,
|
||||
availabilityReason: "pending_ocr",
|
||||
availabilityMessage: "发票正在识别,完成后会自动刷新",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -368,6 +370,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
status: stillPending ? "pending_ocr" : "available",
|
||||
reason: stillPending ? "pending_ocr" : "available",
|
||||
message: stillPending ? "发票正在识别" : "发票可添加",
|
||||
availabilityReason: stillPending ? "pending_ocr" : "available",
|
||||
availabilityMessage: stillPending ? "发票正在识别" : "发票可添加",
|
||||
invoiceCode: stillPending ? null : "044002600111",
|
||||
invoiceNumber: stillPending ? null : "12345678",
|
||||
invoiceDate: stillPending ? null : "2026-05-08",
|
||||
@@ -515,7 +519,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
buffer: Buffer.from("%PDF r111 invoice"),
|
||||
});
|
||||
await page.getByTestId("reimbursement-invoice-upload-submit").click();
|
||||
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
|
||||
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -588,8 +592,20 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
|
||||
|
||||
await invoiceDialog.getByRole("button", { name: "上传" }).click();
|
||||
await expect(invoiceDialog.getByText("拍照或选图片")).toBeVisible();
|
||||
await expect(invoiceDialog.getByText("选择 PDF/文件")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "image/*");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("capture", "environment");
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).toHaveAttribute(
|
||||
"accept",
|
||||
/application\/pdf/,
|
||||
);
|
||||
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).not.toHaveAttribute(
|
||||
"capture",
|
||||
/.+/,
|
||||
);
|
||||
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
|
||||
await mobile.page.getByTestId("reimbursement-invoice-upload-document").setInputFiles({
|
||||
name: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF r111 mobile invoice"),
|
||||
|
||||
在新工单中引用
屏蔽一个用户