R123/A1-A6 收敛移动发票反馈并补部署检查
这个提交包含在:
@@ -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();
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户