R123/A1-A6 收敛移动发票反馈并补部署检查

这个提交包含在:
2026-05-08 12:32:30 +08:00
父节点 135be878f5
当前提交 052413f407
共修改 8 个文件,包含 427 行新增和 57 行删除
@@ -332,7 +332,7 @@ export function TravelPaymentTrackCard({ travelRequestId }: TravelPaymentTrackCa
{canManagePayments && order.actions.canBindInvoice ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-3">
{availableInvoicesQuery.isError ? (
<p className="text-xs text-zinc-500">可用发票列表暂不可用,可去发票池页面完成绑定。</p>
<p className="text-xs text-zinc-500">可添加发票列表暂不可用,请稍后刷新或联系财务处理。</p>
) : (
<div className="space-y-2">
<select
@@ -1,15 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { 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";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
archiveInvoice,
bindInvoice,
fetchBindableInvoices,
fetchInvoicePoolList,
fetchInvoiceStatuses,
unbindInvoice,
uploadInvoice,
type BindableInvoiceSummary,
type InvoicePoolListItem,
} from "../api/invoicePool";
import {
createReimbursementItem,
@@ -58,6 +61,10 @@ type ItemFormState = {
type SelectedInvoiceMap = Record<string, string>;
type InvoicePanel = "upload" | "library";
type MobileInvoiceLibraryItem = BindableInvoiceSummary & {
status: "available" | "ocr_failed";
unavailableMessage?: string | null;
};
const STATUS_LABEL: Record<ReimbursementStatus, string> = {
draft: "草稿",
@@ -123,6 +130,24 @@ function formatInvoiceTitle(invoice: {
return invoice.sellerName || invoice.originalName || "未识别票据";
}
function matchInvoiceSearch(
invoice: {
invoiceCode?: string | null;
invoiceNumber?: string | null;
sellerName?: string | null;
originalName?: string | null;
},
searchLower: string,
) {
if (!searchLower) {
return true;
}
return [invoice.invoiceCode, invoice.invoiceNumber, invoice.sellerName, invoice.originalName]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(searchLower));
}
function isEditableStatus(status: ReimbursementStatus | undefined) {
return status === "draft" || status === "rejected";
}
@@ -145,6 +170,7 @@ export function MobileReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [actionError, setActionError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
@@ -196,6 +222,18 @@ export function MobileReimbursementDetailPage() {
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const failedInvoicesQuery = useQuery({
enabled: Boolean(reimbursementId && isInvoiceDialogOpen && canOpenInvoiceDialog),
queryKey: ["invoice-pool", "failed-own", reimbursementId],
queryFn: () =>
fetchInvoicePoolList({
status: "ocr_failed",
ownerScope: "own",
pageSize: 20,
}),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const pendingInvoiceStatusesQuery = useQuery({
enabled: pendingOcrInvoiceIds.length > 0,
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
@@ -203,6 +241,46 @@ export function MobileReimbursementDetailPage() {
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[]>(
() =>
(bindableInvoicesQuery.data?.items ?? []).map((item) => ({
...item,
status: "available",
})),
[bindableInvoicesQuery.data?.items],
);
const failedLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
() =>
(failedInvoicesQuery.data?.items ?? [])
.filter((item: InvoicePoolListItem) => !item.linkedReimbursementId && !item.linkedItemId && !item.linkedTravelOrderId)
.filter((item: InvoicePoolListItem) => matchInvoiceSearch(item, invoiceSearchLower))
.map((item: InvoicePoolListItem) => ({
id: item.id,
invoiceCode: item.invoiceCode,
invoiceNumber: item.invoiceNumber,
invoiceDate: item.invoiceDate,
sellerName: null,
totalAmount: item.totalAmount,
originalName: item.originalName,
createdAt: item.createdAt,
status: "ocr_failed",
unavailableMessage: "识别失败,可删除后重新上传。",
})),
[failedInvoicesQuery.data?.items, invoiceSearchLower],
);
const invoiceLibraryItems = useMemo(
() => [...bindableLibraryInvoices, ...failedLibraryInvoices],
[bindableLibraryInvoices, failedLibraryInvoices],
);
const invoiceLibraryLoading = bindableInvoicesQuery.isLoading || failedInvoicesQuery.isLoading;
const invoiceLibraryFetching = bindableInvoicesQuery.isFetching || failedInvoicesQuery.isFetching;
const invoiceLibraryError = bindableInvoicesQuery.error ?? failedInvoicesQuery.error ?? null;
useEffect(() => {
const timerId = window.setTimeout(() => {
setInvoiceSearch(invoiceSearchInput.trim());
@@ -211,11 +289,11 @@ export function MobileReimbursementDetailPage() {
}, [invoiceSearchInput]);
useEffect(() => {
const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id));
const candidateIds = new Set(bindableLibraryInvoices.map((item) => item.id));
setSelectedInvoices((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))),
);
}, [bindableInvoicesQuery.data?.items]);
}, [bindableLibraryInvoices]);
const invalidateDetail = async () => {
await Promise.all([
@@ -382,6 +460,28 @@ export function MobileReimbursementDetailPage() {
},
});
const deleteAvailableInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
onSuccess: async (_result, invoiceId) => {
await invalidateDetail();
await Promise.all([bindableInvoicesQuery.refetch(), failedInvoicesQuery.refetch()]);
setSelectedInvoices((previous) => {
const next = { ...previous };
delete next[invoiceId];
return next;
});
setConfirmDeleteInvoiceId("");
setInvoiceActionError("");
setInvoiceActionMessage("发票已删除");
toast.success("发票已删除");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setInvoiceActionError(message);
toast.error(message);
},
});
const uploadInvoiceMutation = useMutation({
mutationFn: async (files: File[]) => {
if (!detail?.ownerId) {
@@ -448,7 +548,7 @@ export function MobileReimbursementDetailPage() {
messages.push(`${result.boundCount} 张已添加`);
}
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张识别中`);
messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`);
}
if (result.failures.length) {
setInvoiceActionError(result.failures.map((item) => `${item.file.name}:${item.message}`).join(";"));
@@ -461,6 +561,9 @@ export function MobileReimbursementDetailPage() {
toast.error(`上传完成,${result.failures.length} 项需处理`);
return;
}
if (result.pendingIds.length) {
return;
}
toast.success(messages.length ? messages.join(",") : "上传完成");
},
onError: (error) => {
@@ -534,6 +637,7 @@ export function MobileReimbursementDetailPage() {
if (failedMessages.length) {
setInvoiceActionError(failedMessages.join(";"));
setInvoiceActionMessage("");
}
if (availableIds.length) {
@@ -595,6 +699,7 @@ export function MobileReimbursementDetailPage() {
setSelectedInvoiceFiles([]);
setInvoiceActionError("");
setInvoiceActionMessage("");
setConfirmDeleteInvoiceId("");
setPendingOcrInvoiceIds([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
@@ -1024,9 +1129,17 @@ export function MobileReimbursementDetailPage() {
{invoiceActionError}
</div>
) : null}
{invoiceActionMessage ? (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{invoiceActionMessage}
{invoiceStatusMessage ? (
<div
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
invoiceStatusTone === "pending"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700",
)}
data-testid={invoiceStatusTone === "pending" ? TEST_IDS.reimbursement.invoicePendingHint : undefined}
>
{invoiceStatusMessage}
</div>
) : null}
@@ -1064,12 +1177,6 @@ export function MobileReimbursementDetailPage() {
</div>
) : null}
{pendingOcrInvoiceIds.length ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后会自动刷新并添加。
</p>
) : null}
<Button
className="w-full"
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
@@ -1092,37 +1199,57 @@ export function MobileReimbursementDetailPage() {
value={invoiceSearchInput}
onChange={(event) => setInvoiceSearchInput(event.target.value)}
/>
<Button type="button" variant="secondary" disabled={bindableInvoicesQuery.isFetching} onClick={() => void bindableInvoicesQuery.refetch()}>
<Button
type="button"
variant="secondary"
disabled={invoiceLibraryFetching}
onClick={() => {
void bindableInvoicesQuery.refetch();
void failedInvoicesQuery.refetch();
}}
>
刷新
</Button>
</div>
<div className="max-h-[44dvh] space-y-2 overflow-y-auto pr-1">
{bindableInvoicesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{bindableInvoicesQuery.isError ? (
{invoiceLibraryLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{invoiceLibraryError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(bindableInvoicesQuery.error)}
{resolveErrorMessage(invoiceLibraryError)}
</div>
) : null}
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
{!invoiceLibraryLoading && !invoiceLibraryError && !invoiceLibraryItems.length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无可添加发票
</div>
) : null}
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
{invoiceLibraryItems.map((invoice) => {
const isAvailable = invoice.status === "available";
const isFailed = invoice.status === "ocr_failed";
const checked = Object.prototype.hasOwnProperty.call(selectedInvoices, invoice.id);
return (
<label
<article
key={invoice.id}
className={cn("block rounded-lg border px-3 py-3", checked ? "border-primary-300 bg-primary-50" : "border-zinc-200 bg-white")}
className={cn(
"block rounded-lg border px-3 py-3",
checked ? "border-primary-300 bg-primary-50" : "border-zinc-200 bg-white",
isFailed ? "border-red-200 bg-red-50/40" : "",
)}
>
<div className="flex gap-3">
<input
className="mt-1 h-4 w-4 rounded border-zinc-300"
type="checkbox"
checked={checked}
onChange={(event) => toggleInvoiceSelection(invoice, event.target.checked)}
/>
{isAvailable ? (
<input
className="mt-1 h-4 w-4 rounded border-zinc-300"
type="checkbox"
checked={checked}
onChange={(event) => toggleInvoiceSelection(invoice, event.target.checked)}
/>
) : (
<span className="mt-0.5 inline-flex shrink-0 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
识别失败
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
<div className="mt-1 text-xs text-zinc-500">
@@ -1130,12 +1257,27 @@ export function MobileReimbursementDetailPage() {
</div>
</div>
</div>
{checked ? (
{isFailed ? (
<div className="mt-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">
{invoice.unavailableMessage ?? "识别失败,可删除后重新上传。"}
</div>
) : null}
{isAvailable && checked ? (
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
添加后自动生成或匹配报销明细
</div>
) : null}
</label>
<Button
className="mt-3 w-full"
size="sm"
type="button"
variant="secondary"
disabled={deleteAvailableInvoiceMutation.isPending || bindInvoicesMutation.isPending}
onClick={() => setConfirmDeleteInvoiceId(invoice.id)}
>
删除
</Button>
</article>
);
})}
</div>
@@ -1203,6 +1345,22 @@ export function MobileReimbursementDetailPage() {
onConfirm={() => itemDeleteMutation.mutate(confirmDeleteItemId)}
/>
<ConfirmDialog
open={Boolean(confirmDeleteInvoiceId)}
onOpenChange={(open) => {
if (!open) setConfirmDeleteInvoiceId("");
}}
title="删除发票?"
description="删除后将从可添加发票列表中移除。"
variant="destructive"
confirmLabel="删除"
onConfirm={() => {
if (confirmDeleteInvoiceId) {
deleteAvailableInvoiceMutation.mutate(confirmDeleteInvoiceId);
}
}}
/>
<ConfirmDialog
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
@@ -11,6 +11,8 @@ const reimbursementId = "11111111-1111-4111-8111-111111111111";
const ownerId = "employee-r111";
const pendingInvoiceId = "22222222-2222-4222-8222-222222222222";
const attachmentId = "33333333-3333-4333-8333-333333333333";
const availableInvoiceId = "44444444-4444-4444-8444-444444444444";
const failedInvoiceId = "55555555-5555-4555-8555-555555555555";
function envelope(data: unknown) {
return { requestId: "mock-r111-invoice-reimbursement", data };
@@ -92,10 +94,54 @@ function buildAttachment() {
};
}
function buildAvailableInvoice() {
return {
id: availableInvoiceId,
invoiceCode: "044002600111",
invoiceNumber: "87654321",
invoiceDate: "2026-05-08",
sellerName: "北京悦海堂餐饮管理有限公司",
totalAmount: "1683.00",
originalName: "已有发票.pdf",
createdAt: "2026-05-08T08:00:00.000Z",
};
}
function buildFailedInvoice() {
return {
id: failedInvoiceId,
status: "ocr_failed",
ocrType: "vat",
ocrProvider: "tencent",
ocrAttempts: 1,
ocrLastError: "识别服务未返回完整发票信息",
ocrCompletedAt: "2026-05-08T08:05:00.000Z",
invoiceCode: null,
invoiceNumber: null,
invoiceDate: null,
amount: null,
taxAmount: null,
totalAmount: null,
linkedReimbursementId: null,
linkedItemId: null,
linkedTravelOrderId: null,
originalName: "识别失败发票.pdf",
mimeType: "application/pdf",
fileSize: 4096,
verification: null,
owner: { id: ownerId, employeeNo: "E111", name: "测试员工" },
uploadedBy: { id: ownerId, employeeNo: "E111", name: "测试员工" },
createdAt: "2026-05-08T08:01:00.000Z",
updatedAt: "2026-05-08T08:05:00.000Z",
};
}
function buildReimbursementState() {
return {
invoices: [] as ReturnType<typeof buildInvoice>[],
attachments: [] as ReturnType<typeof buildAttachment>[],
availableInvoices: [buildAvailableInvoice()],
failedInvoices: [buildFailedInvoice()],
statusPollCount: 0,
};
}
@@ -283,18 +329,18 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === "/api/invoice-pool/bindable") {
await fulfillJson(route, {
items: [
{
id: "44444444-4444-4444-8444-444444444444",
invoiceCode: "044002600111",
invoiceNumber: "87654321",
invoiceDate: "2026-05-08",
sellerName: "北京悦海堂餐饮管理有限公司",
totalAmount: "1683.00",
originalName: "已有发票.pdf",
createdAt: "2026-05-08T08:00:00.000Z",
},
],
items: state.availableInvoices,
});
return;
}
if (pathName === "/api/invoice-pool" && url.searchParams.get("status") === "ocr_failed") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: state.failedInvoices.length,
ownerScope: "own",
items: state.failedInvoices,
});
return;
}
@@ -314,26 +360,27 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === "/api/invoice-pool/status") {
state.statusPollCount += 1;
const stillPending = state.statusPollCount === 1;
await fulfillJson(route, {
items: [
{
id: pendingInvoiceId,
status: "available",
reason: "available",
message: "发票可添加",
invoiceCode: "044002600111",
invoiceNumber: "12345678",
invoiceDate: "2026-05-08",
sellerName: "北京悦海堂餐饮管理有限公司",
totalAmount: "1683.00",
status: stillPending ? "pending_ocr" : "available",
reason: stillPending ? "pending_ocr" : "available",
message: stillPending ? "发票正在识别" : "发票可添加",
invoiceCode: stillPending ? null : "044002600111",
invoiceNumber: stillPending ? null : "12345678",
invoiceDate: stillPending ? null : "2026-05-08",
sellerName: stillPending ? null : "北京悦海堂餐饮管理有限公司",
totalAmount: stillPending ? null : "1683.00",
originalName: "北京悦海堂发票.pdf",
linkedReimbursementId: null,
linkedItemId: null,
linkedTravelOrderId: null,
ocrAttempts: 1,
ocrAttempts: stillPending ? 0 : 1,
ocrLastError: null,
ocrCompletedAt: "2026-05-08T08:05:00.000Z",
updatedAt: "2026-05-08T08:05:00.000Z",
ocrCompletedAt: stillPending ? null : "2026-05-08T08:05:00.000Z",
updatedAt: stillPending ? "2026-05-08T08:04:00.000Z" : "2026-05-08T08:05:00.000Z",
},
],
missingIds: [],
@@ -367,6 +414,28 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
return;
}
if (pathName === `/api/invoice-pool/${availableInvoiceId}/archive`) {
state.availableInvoices = [];
await fulfillJson(route, {
id: availableInvoiceId,
status: "archived",
archivedReason: "manual",
archivedAt: "2026-05-08T08:20:00.000Z",
});
return;
}
if (pathName === `/api/invoice-pool/${failedInvoiceId}/archive`) {
state.failedInvoices = [];
await fulfillJson(route, {
id: failedInvoiceId,
status: "archived",
archivedReason: "manual",
archivedAt: "2026-05-08T08:21:00.000Z",
});
return;
}
await fulfillJson(route, {});
});
}
@@ -494,15 +563,39 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
const dialogMetrics = await collectVisualMetrics(mobile.page);
expect(dialogMetrics.activeElementTestId).not.toBe("mobile-reimbursement-invoice-search");
const invoiceDialog = mobile.page.getByTestId("reimbursement-invoice-dialog");
await invoiceDialog.getByRole("button", { name: "已有发票" }).click();
const availableInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "044002600111-87654321" });
const failedInvoiceCard = invoiceDialog.locator("article").filter({ hasText: "识别失败发票.pdf" });
await expect(availableInvoiceCard).toBeVisible();
await expect(failedInvoiceCard.getByText("识别失败", { exact: true })).toBeVisible();
await expect(failedInvoiceCard.getByText("识别失败,可删除后重新上传。")).toBeVisible();
await availableInvoiceCard.getByRole("button", { name: "删除" }).click();
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click();
await expect(availableInvoiceCard).toHaveCount(0);
await expect(failedInvoiceCard).toBeVisible();
await failedInvoiceCard.getByRole("button", { name: "删除" }).click();
await expect(mobile.page.getByRole("heading", { name: "删除发票?" })).toBeVisible();
await mobile.page.getByRole("dialog").last().getByRole("button", { name: "删除" }).click();
await expect(failedInvoiceCard).toHaveCount(0);
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
await invoiceDialog.getByRole("button", { name: "上传" }).click();
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 mobile invoice"),
});
await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(mobile.page.getByTestId("reimbursement-invoice-pending-hint")).toHaveCount(1);
await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).not.toContainText("识别完成后可在");
await expect(
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
).toBeVisible({ timeout: 10_000 });
).toBeVisible({ timeout: 15_000 });
await mobile.page.keyboard.press("Escape");
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();