R92 A3-A4 优化发票上传状态与批量上传
这个提交包含在:
@@ -124,9 +124,10 @@ export function ReimbursementDetailPage() {
|
||||
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
|
||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||
const [selectedInvoiceFile, setSelectedInvoiceFile] = useState<File | null>(null);
|
||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [pendingOcrInvoiceId, setPendingOcrInvoiceId] = useState<string | null>(null);
|
||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||
expenseTypeId: "",
|
||||
expenseDate: "",
|
||||
@@ -185,6 +186,7 @@ export function ReimbursementDetailPage() {
|
||||
search: invoiceSearch || undefined,
|
||||
limit: 20,
|
||||
}),
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -442,8 +444,8 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFile(null);
|
||||
setPendingOcrInvoiceId(null);
|
||||
setSelectedInvoiceFiles([]);
|
||||
setPendingOcrInvoiceIds([]);
|
||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
||||
return;
|
||||
}
|
||||
@@ -484,70 +486,113 @@ export function ReimbursementDetailPage() {
|
||||
});
|
||||
|
||||
const uploadInvoiceMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
mutationFn: async (files: File[]) => {
|
||||
const ownerEmployeeId = detailQuery.data?.ownerId;
|
||||
if (!ownerEmployeeId) {
|
||||
throw new Error("missing ownerEmployeeId");
|
||||
}
|
||||
return uploadInvoice(file, "vat", ownerEmployeeId);
|
||||
|
||||
if (!files.length) {
|
||||
throw new Error("请选择发票文件");
|
||||
}
|
||||
|
||||
const uploadFailures: Array<{ file: File; message: string }> = [];
|
||||
const pendingById = new Map<string, string>();
|
||||
const availableIds: string[] = [];
|
||||
const ocrFailures: string[] = [];
|
||||
setInvoiceUploadProgress({ completed: 0, total: files.length });
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
try {
|
||||
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
|
||||
if (result.status === "available") {
|
||||
availableIds.push(result.invoicePoolId);
|
||||
} else if (result.status === "pending_ocr") {
|
||||
pendingById.set(result.invoicePoolId, file.name);
|
||||
}
|
||||
} catch (error) {
|
||||
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
|
||||
}
|
||||
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 Promise.all(
|
||||
Array.from(pendingById.keys()).map(async (invoiceId) => {
|
||||
try {
|
||||
return await fetchInvoicePoolDetail(invoiceId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const invoice of invoiceResults) {
|
||||
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}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
|
||||
pendingById.delete(invoice.id);
|
||||
}
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedCount: files.length - uploadFailures.length,
|
||||
uploadFailures,
|
||||
availableIds,
|
||||
pendingIds: Array.from(pendingById.keys()),
|
||||
ocrFailures,
|
||||
};
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setInvoiceActionError("");
|
||||
setSelectedInvoiceFile(null);
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
|
||||
setPendingOcrInvoiceIds(result.pendingIds);
|
||||
|
||||
if (result.status === "available") {
|
||||
const refreshed = await bindableInvoicesQuery.refetch();
|
||||
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
|
||||
}));
|
||||
}
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.success("发票上传成功,可直接绑定");
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
const refreshed = await bindableInvoicesQuery.refetch();
|
||||
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
|
||||
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
|
||||
if (selectableIds.length) {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])),
|
||||
}));
|
||||
}
|
||||
|
||||
const messages = [
|
||||
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
|
||||
...result.ocrFailures,
|
||||
];
|
||||
if (result.pendingIds.length) {
|
||||
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`);
|
||||
}
|
||||
|
||||
if (messages.length) {
|
||||
const message = messages.join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceId(result.invoicePoolId);
|
||||
toast.success("发票上传成功,OCR 识别中");
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2_000);
|
||||
try {
|
||||
const invoice = await fetchInvoicePoolDetail(result.invoicePoolId);
|
||||
if (invoice.status === "available") {
|
||||
const refreshed = await bindableInvoicesQuery.refetch();
|
||||
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
|
||||
}));
|
||||
}
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.success("发票 OCR 已完成,可直接绑定");
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.status === "ocr_failed") {
|
||||
setPendingOcrInvoiceId(null);
|
||||
const message = `发票 OCR 未完成:${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}可前往票据中心继续修正或重试。`;
|
||||
setInvoiceActionError(message);
|
||||
toast.error("发票 OCR 识别失败,请在当前提示或票据中心继续处理");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Polling errors should not block eventual refresh; keep retrying until timeout.
|
||||
}
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceId(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
|
||||
setInvoiceActionError("发票仍在 OCR 处理中,请稍后刷新列表;若长时间未完成,可前往票据中心查看详情。");
|
||||
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
|
||||
setInvoiceActionError("");
|
||||
toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`);
|
||||
},
|
||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "上传发票失败");
|
||||
setInvoiceActionError(message);
|
||||
@@ -698,9 +743,9 @@ export function ReimbursementDetailPage() {
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFile(null);
|
||||
setSelectedInvoiceFiles([]);
|
||||
setInvoiceActionError("");
|
||||
setPendingOcrInvoiceId(null);
|
||||
setPendingOcrInvoiceIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1376,7 +1421,11 @@ export function ReimbursementDetailPage() {
|
||||
htmlFor="reimbursement-inline-invoice-upload"
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
|
||||
>
|
||||
{selectedInvoiceFile ? selectedInvoiceFile.name : "选择发票文件"}
|
||||
{selectedInvoiceFiles.length
|
||||
? selectedInvoiceFiles.length === 1
|
||||
? selectedInvoiceFiles[0].name
|
||||
: `已选择 ${selectedInvoiceFiles.length} 个文件`
|
||||
: "选择发票文件"}
|
||||
</label>
|
||||
<input
|
||||
id="reimbursement-inline-invoice-upload"
|
||||
@@ -1384,21 +1433,35 @@ export function ReimbursementDetailPage() {
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
|
||||
multiple
|
||||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||
type="button"
|
||||
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
|
||||
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
|
||||
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
|
||||
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
||||
>
|
||||
{uploadInvoiceMutation.isPending ? "上传中..." : "上传并等待 OCR"}
|
||||
{uploadInvoiceMutation.isPending
|
||||
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
|
||||
: "上传并等待 OCR"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pendingOcrInvoiceId ? (
|
||||
{selectedInvoiceFiles.length ? (
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
|
||||
{selectedInvoiceFiles.slice(0, 5).map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="truncate">
|
||||
{file.name} · {Math.ceil(file.size / 1024)} KB
|
||||
</div>
|
||||
))}
|
||||
{selectedInvoiceFiles.length > 5 ? <div>另有 {selectedInvoiceFiles.length - 5} 个文件</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingOcrInvoiceIds.length ? (
|
||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
|
||||
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别中,系统会在可绑定后自动刷新列表。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户