feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-09 23:19:02 +08:00
父节点 b87cc55aca
当前提交 e5685f18b2
共修改 28 个文件,包含 1616 行新增和 121 行删除
+3
查看文件
@@ -1,8 +1,11 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"reimbursement.invoice_prefill_incomplete": "票据日期或金额尚未完整识别,请先处理该票据;其他已识别票据可继续添加。",
"reimbursement.items_description_required": "请补充明细的支出说明后再提交。",
"reimbursement.material_preview_limit": "报销材料较多,暂时无法连续预览,请分批查看材料或联系管理员协助。",
"purchase_attachment.unsupported_type": "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。",
"reimbursement.invoice_amount_required": "票据金额尚未确认,无法自动生成明细,请先核对票据金额或手动选择明细。",
"reimbursement.expense_type_required": "当前没有可用费用类型,请联系管理员维护后再添加票据。",
"reimbursement.items_expense_type_required": "请补充明细的费用类型;同类明细可批量设置,已停用的类型请重新选择。",
"reimbursement.invoice_not_bound": "该票据已不再关联业务单据,请刷新票据列表后确认。",
"reimbursement.invoice_unbind_status_invalid": "报销单当前不能移出票据,请先核实审批和付款状态。",
"reimbursement.invoice_bind_status_invalid": "报销单当前不能添加票据,请刷新并确认单据状态。",
+1
查看文件
@@ -129,6 +129,7 @@ export type InvoiceBindPayload =
| {
reimbursementId: string;
linkedItemId?: string;
createItem?: true;
}
| {
travelOrderId: string;
+3 -3
查看文件
@@ -13,7 +13,7 @@ export type ReimbursementStatus =
export type ReimbursementItem = {
id: string;
expenseTypeId: string;
expenseTypeId: string | null;
expenseDate: string | null;
amount: string;
description: string | null;
@@ -346,7 +346,7 @@ export type LinkableTravelRequestListResponse = {
};
export type ReimbursementItemPayload = {
expenseTypeId: string;
expenseTypeId: string | null;
expenseDate: string;
amount: string;
description?: string;
@@ -356,7 +356,7 @@ export type ReimbursementItemPayload = {
export type ReimbursementItemMutationResponse = {
item: {
id: string;
expenseTypeId: string;
expenseTypeId: string | null;
expenseDate: string;
amount: string;
description: string | null;
@@ -0,0 +1,56 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequestData } from "../../api/http";
import type { ExpenseType } from "../../api/expenseTypes";
import type { ReimbursementItem } from "../../api/reimbursements";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
export function ReimbursementBatchClassification({ reimbursementId, items, expenseTypes }: {
reimbursementId: string;
items: ReimbursementItem[];
expenseTypes: ExpenseType[];
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expenseTypeId, setExpenseTypeId] = useState("");
const queryClient = useQueryClient();
const visibleIds = selectedIds.filter((id) => items.some((item) => item.id === id));
const mutation = useMutation({
mutationFn: () => apiRequestData(`/api/reimbursements/${reimbursementId}/items/classify`, {
method: "POST", body: JSON.stringify({ itemIds: visibleIds, expenseTypeId }),
}),
onSuccess: async () => {
setSelectedIds([]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }),
]);
},
});
if (!items.length) return null;
const unclassified = items.filter((item) => !item.expenseType || !item.expenseType.enabled).length;
return (
<details className="rounded-lg border border-zinc-200 p-3" open={unclassified > 0 ? true : undefined}>
<summary className="cursor-pointer text-sm font-medium">费用分类{unclassified ? `:${unclassified} 条待补充` : "(可批量修改)"}</summary>
<p className="my-2 text-xs text-zinc-500">选中同类明细,一次设置;未选中的明细不会修改。</p>
<div className="space-y-2">
{items.map((item, index) => (
<label key={item.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={visibleIds.includes(item.id)} disabled={mutation.isPending}
onChange={(event) => setSelectedIds((previous) => event.target.checked ? [...new Set([...previous, item.id])] : previous.filter((id) => id !== item.id))} />
<span>明细 {index + 1} · {item.expenseType?.name ?? "待补充类型"} · ¥{item.amount} · {item.description || "待补充说明"}</span>
</label>
))}
<select aria-label="批量费用类型" className="select-std w-full" value={expenseTypeId} disabled={mutation.isPending}
onChange={(event) => setExpenseTypeId(event.target.value)}>
<option value="">选择所选明细的费用类型</option>
{expenseTypes.filter((type) => type.enabled).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}
</select>
<Button type="button" disabled={!visibleIds.length || visibleIds.length > 100 || !expenseTypeId || mutation.isPending}
onClick={() => mutation.mutate()}>{mutation.isPending ? "设置中…" : `设置所选 ${visibleIds.length} 条分类`}</Button>
{visibleIds.length > 100 ? <p role="alert">每次最多设置 100 条,请分批选择。</p> : null}
{mutation.isError ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error, "分类设置失败,请刷新后重试")}</p> : null}
</div>
</details>
);
}
@@ -15,6 +15,7 @@ export type ReimbursementEvidenceCapabilities = {
onOpenMaterialBundle?: () => void;
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onAddInvoices?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
@@ -95,7 +96,7 @@ function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSumma
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button></td> : null}
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button>{capabilities.onAddInvoices ? <Button className="min-h-11" onClick={() => capabilities.onAddInvoices?.(item)} size="sm" type="button" variant="secondary">为明细添加发票</Button> : null}</td> : null}
</tr>
);
})}
@@ -125,6 +126,7 @@ function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummar
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
<ItemIssues item={item} />
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
{capabilities.onAddInvoices ? <Button className="mt-2 min-h-11 w-full" onClick={() => capabilities.onAddInvoices?.(item)} type="button" variant="secondary">为明细添加发票</Button> : null}
</article>
);
})}
+2 -2
查看文件
@@ -110,10 +110,10 @@ export function useInvoiceOcrTracker({
}
if (hasLongWait) {
return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后会自动添加。`;
return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后默认勾选,请点击添加到报销单。`;
}
return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`;
return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后默认勾选,请点击添加到报销单。`;
}, [hasLongWait, pendingInvoiceIds.length]);
return {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ReimbursementBatchClassification } from "../components/reimbursements/ReimbursementBatchClassification";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -178,6 +179,7 @@ export function MobileReimbursementDetailPage() {
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const deselectedInvoiceIds = useRef(new Set<string>());
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
@@ -327,7 +329,7 @@ export function MobileReimbursementDetailPage() {
setItemForm({
mode: "edit",
itemId: item.id,
expenseTypeId: item.expenseTypeId,
expenseTypeId: item.expenseTypeId ?? "",
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
amount: item.amount,
description: item.description ?? "",
@@ -342,10 +344,10 @@ export function MobileReimbursementDetailPage() {
}
const payload = {
expenseTypeId: form.expenseTypeId,
expenseTypeId: form.expenseTypeId || null,
expenseDate: form.expenseDate,
amount: form.amount,
description: form.description.trim() || undefined,
description: form.description.trim(),
};
if (form.mode === "edit" && form.itemId) {
@@ -428,7 +430,7 @@ export function MobileReimbursementDetailPage() {
try {
await bindInvoice(selection.invoiceId, {
reimbursementId,
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}),
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : { createItem: true as const }),
});
results.push({ invoiceId: selection.invoiceId, success: true });
} catch (error) {
@@ -479,14 +481,18 @@ export function MobileReimbursementDetailPage() {
if (!invoiceIds.length) {
return;
}
bindInvoicesMutation.mutate(
invoiceIds.map((invoiceId) => ({
invoiceId,
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
})),
);
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setSelectedInvoices((previous) => {
const next = { ...previous };
for (const invoiceId of invoiceIds) {
if (!deselectedInvoiceIds.current.has(invoiceId) && !(invoiceId in next)) {
next[invoiceId] = pendingOcrLinkedItemIds[invoiceId] || "";
}
}
return next;
});
},
[bindInvoicesMutation, pendingOcrLinkedItemIds],
[pendingOcrLinkedItemIds, queryClient],
);
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
@@ -585,7 +591,7 @@ export function MobileReimbursementDetailPage() {
const failures: Array<{ file: File; message: string }> = [];
const pendingIds: string[] = [];
let uploadedCount = 0;
let boundCount = 0;
const availableIds: string[] = [];
setInvoiceUploadProgress({ completed: 0, total: files.length });
@@ -602,15 +608,7 @@ export function MobileReimbursementDetailPage() {
message: resolveInvoiceUserMessage(uploadResult),
});
} else if (uploadResult.status === "available") {
try {
await bindInvoice(uploadResult.invoicePoolId, {
reimbursementId,
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
});
boundCount += 1;
} catch (error) {
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
}
availableIds.push(uploadResult.invoicePoolId);
} else if (uploadResult.status === "pending_ocr") {
pendingIds.push(uploadResult.invoicePoolId);
} else if (uploadResult.status === "ocr_failed") {
@@ -625,14 +623,14 @@ export function MobileReimbursementDetailPage() {
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
}
return { uploadedCount, boundCount, pendingIds, failures };
return { uploadedCount, availableIds, pendingIds, failures };
},
onSuccess: async (result) => {
await invalidateDetail();
await bindableInvoicesQuery.refetch();
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
if (invoiceUploadLinkedItemId) {
setPendingOcrLinkedItemIds((previous) => ({
...previous,
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
@@ -646,8 +644,13 @@ export function MobileReimbursementDetailPage() {
}
const messages: string[] = [];
if (result.boundCount) {
messages.push(`${result.boundCount} 张已添加`);
setSelectedInvoices((previous) => ({
...Object.fromEntries(result.availableIds.filter((id) => !deselectedInvoiceIds.current.has(id)).map((id) => [id, invoiceUploadLinkedItemId])),
...previous,
}));
setInvoicePanel("library");
if (result.availableIds.length) {
messages.push(`${result.availableIds.length} 张已入我的发票,请选择后添加`);
}
if (result.pendingIds.length) {
messages.push(`已上传 ${result.pendingIds.length} 张,正在识别`);
@@ -855,6 +858,8 @@ export function MobileReimbursementDetailPage() {
};
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
if (checked) deselectedInvoiceIds.current.delete(invoice.id);
else deselectedInvoiceIds.current.add(invoice.id);
setSelectedInvoices((previous) => {
if (!checked) {
const next = { ...previous };
@@ -863,7 +868,7 @@ export function MobileReimbursementDetailPage() {
}
return {
...previous,
[invoice.id]: previous[invoice.id] ?? "",
[invoice.id]: previous[invoice.id] ?? invoiceUploadLinkedItemId,
};
});
};
@@ -911,6 +916,10 @@ export function MobileReimbursementDetailPage() {
});
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onAddInvoices: canOpenInvoiceDialog ? (item: { id: string }) => {
closeInvoiceDialog(true);
setInvoiceUploadLinkedItemId(item.id);
} : undefined,
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
@@ -929,6 +938,7 @@ export function MobileReimbursementDetailPage() {
};
const workspaceHeaderDetails = (
<>
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} mobile embedded />
@@ -952,12 +962,12 @@ export function MobileReimbursementDetailPage() {
<input ref={attachmentInputRef} className="sr-only" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files=Array.from(event.target.files ?? []); if(files.length>MAX_REIMBURSEMENT_ATTACHMENT_FILES){ setSelectedAttachmentFiles([]); event.target.value=""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return;} setSelectedAttachmentFiles(files); }} />
{itemForm ? (
<div className="space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}>{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}><option value="">待补充费用类型</option>{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>日期</span><Input disabled={itemSaveMutation.isPending} type="date" value={itemForm.expenseDate} onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button>{itemForm.mode === "edit" && itemForm.itemId ? <Button className="col-span-2" data-testid="reimbursement-item-delete" disabled={itemSaveMutation.isPending} onClick={() => setConfirmDeleteItemId(itemForm.itemId ?? "")} type="button" variant="destructive">删除明细</Button> : null}</div>
</div>
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</section>
@@ -979,14 +989,15 @@ export function MobileReimbursementDetailPage() {
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
<DialogContent
className="overflow-y-auto p-4"
className="left-0 top-0 w-screen max-w-none translate-x-0 translate-y-0 overflow-y-auto rounded-none p-4"
data-testid={TEST_IDS.reimbursement.invoiceDialog}
onOpenAutoFocus={(event) => event.preventDefault()}
style={{ maxHeight: "min(86vh, calc(var(--mobile-viewport-height, 100vh) - 3rem))" }}
style={{ height: "var(--mobile-viewport-height, 100dvh)" }}
>
<DialogHeader className="text-left">
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
<DialogTitle>我的发票</DialogTitle>
<Button type="button" variant="secondary" onClick={() => closeInvoiceDialog(false)}>返回报销单</Button>
<DialogDescription>可一次上传多张,识别完成后默认勾选,请点击添加到报销单。</DialogDescription>
</DialogHeader>
<div className="mobile-two-action-grid mt-4 rounded-lg bg-zinc-100 p-1">
@@ -1062,14 +1073,14 @@ export function MobileReimbursementDetailPage() {
</div>
) : null}
{detail.invoiceGuardEnabled ? (
{detail.items.length > 0 ? (
<select
aria-label="新上传发票关联明细"
aria-label="新发票添加位置"
className="select-std w-full"
value={invoiceUploadLinkedItemId}
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
>
<option value="">请选择新上传发票对应的费用明细</option>
<option value="">自动创建新明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
@@ -1082,7 +1093,7 @@ export function MobileReimbursementDetailPage() {
className="w-full"
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending
@@ -1175,17 +1186,17 @@ export function MobileReimbursementDetailPage() {
) : null}
{isAvailable && checked ? (
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细" : "添加后自动生成或匹配报销明细"}
点击添加后生成可编辑明细;也可选择加入已有明细。
</div>
) : null}
{isAvailable && checked && detail.invoiceGuardEnabled ? (
{isAvailable && checked && detail.items.length > 0 ? (
<select
aria-label="关联费用明细"
aria-label="添加到哪条明细"
className="select-std mt-3 w-full"
value={selectedInvoices[invoice.id] ?? ""}
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.id]: event.target.value }))}
>
<option value="">请选择费用明细</option>
<option value="">自动创建新明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
@@ -1211,7 +1222,7 @@ export function MobileReimbursementDetailPage() {
<Button
className="w-full"
type="button"
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={() =>
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
@@ -1221,7 +1232,7 @@ export function MobileReimbursementDetailPage() {
)
}
>
{bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`}
{bindInvoicesMutation.isPending ? "添加中..." : `添加到报销单 ${selectedInvoiceEntries.length}`}
</Button>
</div>
)}
+48 -42
查看文件
@@ -1,5 +1,6 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useCallback, useEffect, useState } from "react";
import { ReimbursementBatchClassification } from "../components/reimbursements/ReimbursementBatchClassification";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
@@ -62,7 +63,6 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
@@ -121,6 +121,7 @@ export function ReimbursementDetailPage() {
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const deselectedInvoiceIds = useRef(new Set<string>());
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
@@ -284,10 +285,10 @@ export function ReimbursementDetailPage() {
}
const payload = {
expenseTypeId: data.expenseTypeId,
expenseTypeId: data.expenseTypeId || null,
expenseDate: data.expenseDate,
amount: data.amount,
description: data.description.trim() || undefined,
description: data.description.trim(),
};
if (itemEditor.mode === "create") {
@@ -371,7 +372,7 @@ export function ReimbursementDetailPage() {
try {
await bindInvoice(selection.invoiceId, {
reimbursementId,
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : {}),
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : { createItem: true as const }),
});
results.push({ invoiceId: selection.invoiceId, success: true });
} catch (error) {
@@ -406,6 +407,7 @@ export function ReimbursementDetailPage() {
if (!failedResults.length) {
setInvoiceActionError("");
setIsInvoiceDialogOpen(false);
setInvoiceUploadLinkedItemId("");
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
@@ -442,14 +444,18 @@ export function ReimbursementDetailPage() {
if (!invoiceIds.length) {
return;
}
bindInvoicesMutation.mutate(
invoiceIds.map((invoiceId) => ({
invoiceId,
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
})),
);
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setSelectedInvoices((previous) => {
const next = { ...previous };
for (const invoiceId of invoiceIds) {
if (!deselectedInvoiceIds.current.has(invoiceId) && !(invoiceId in next)) {
next[invoiceId] = pendingOcrLinkedItemIds[invoiceId] || "";
}
}
return next;
});
},
[bindInvoicesMutation, pendingOcrLinkedItemIds],
[pendingOcrLinkedItemIds, queryClient],
);
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
@@ -532,7 +538,7 @@ export function ReimbursementDetailPage() {
const availableIds: string[] = [];
const ocrFailures: string[] = [];
const bindFailures: string[] = [];
let boundCount = 0;
setInvoiceUploadProgress({ completed: 0, total: files.length });
for (const [index, file] of files.entries()) {
@@ -562,29 +568,22 @@ export function ReimbursementDetailPage() {
setPendingOcrInvoiceIds((previous) =>
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
);
if (detail.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
if (invoiceUploadLinkedItemId) {
setPendingOcrLinkedItemIds((previous) => ({
...previous,
...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
}));
}
for (const invoiceId of availableIds) {
try {
await bindInvoice(invoiceId, {
reimbursementId,
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
});
boundCount += 1;
} catch (error) {
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
}
}
setSelectedInvoices((previous) => ({
...Object.fromEntries(availableIds.filter((id) => !deselectedInvoiceIds.current.has(id)).map((id) => [id, invoiceUploadLinkedItemId])),
...previous,
}));
return {
uploadedCount: files.length - uploadFailures.length,
uploadFailures,
boundCount,
bindFailures,
pendingIds: Array.from(pendingById.keys()),
ocrFailures,
@@ -616,10 +615,10 @@ export function ReimbursementDetailPage() {
setInvoiceActionError("");
if (result.pendingIds.length) {
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`);
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后默认勾选,请点击添加到报销单`);
return;
}
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
toast.success(`发票上传完成:${result.uploadedCount} 张已入我的发票,请选择后添加`);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
onError: (error) => {
@@ -811,7 +810,7 @@ export function ReimbursementDetailPage() {
new Date().toISOString().slice(0, 10);
const defaultExpenseTypeId =
item?.expenseTypeId ?? expenseTypes[0]?.id ?? "";
item ? item.expenseTypeId ?? "" : expenseTypes[0]?.id ?? "";
return {
expenseDate: defaultDate,
@@ -903,6 +902,8 @@ export function ReimbursementDetailPage() {
};
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
if (checked) deselectedInvoiceIds.current.delete(invoiceId);
else deselectedInvoiceIds.current.add(invoiceId);
setSelectedInvoices((previous) => {
if (!checked) {
const next = { ...previous };
@@ -912,7 +913,7 @@ export function ReimbursementDetailPage() {
return {
...previous,
[invoiceId]: previous[invoiceId] ?? "",
[invoiceId]: previous[invoiceId] ?? invoiceUploadLinkedItemId,
};
});
};
@@ -1055,6 +1056,10 @@ export function ReimbursementDetailPage() {
});
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onAddInvoices: canOpenInvoiceBindDialog ? (item: { id: string }) => {
handleInvoiceDialogOpenChange(true);
setInvoiceUploadLinkedItemId(item.id);
} : undefined,
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
@@ -1074,6 +1079,7 @@ export function ReimbursementDetailPage() {
};
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
<SectionPanel title="补充信息">
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
{canMaintainReimbursement ? (
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
) : null}
@@ -1116,7 +1122,8 @@ export function ReimbursementDetailPage() {
data-testid={TEST_IDS.reimbursement.invoiceDialog}
>
<DialogHeader>
<DialogTitle>添加发票</DialogTitle>
<DialogTitle>我的发票</DialogTitle>
<Button type="button" variant="secondary" onClick={() => handleInvoiceDialogOpenChange(false)}>返回报销单</Button>
<DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
</DialogHeader>
@@ -1153,7 +1160,6 @@ export function ReimbursementDetailPage() {
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => {
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
const invoiceHint = resolveInvoiceNextStepText({ ...item, status: "available" });
return (
<label
key={item.id}
@@ -1179,17 +1185,17 @@ export function ReimbursementDetailPage() {
</div>
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细。" : `${invoiceHint} 添加后自动生成或匹配报销明细。`}
点击添加后生成可编辑明细;也可选择加入已有明细。
</div>
</div>
{isSelected && detail.invoiceGuardEnabled ? (
{isSelected && detail.items.length > 0 ? (
<select
aria-label="关联费用明细"
aria-label="添加到哪条明细"
className="select-std mt-3 w-full"
value={selectedInvoices[item.id] ?? ""}
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [item.id]: event.target.value }))}
>
<option value="">请选择费用明细</option>
<option value="">自动创建新明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
@@ -1211,10 +1217,10 @@ export function ReimbursementDetailPage() {
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
<Button
type="button"
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={handleBindSelectedInvoices}
>
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
{bindInvoicesMutation.isPending ? "添加中..." : "添加到报销单"}
</Button>
</div>
@@ -1250,7 +1256,7 @@ export function ReimbursementDetailPage() {
<Button
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending
@@ -1259,14 +1265,14 @@ export function ReimbursementDetailPage() {
</Button>
</div>
{detail.invoiceGuardEnabled ? (
{detail.items.length > 0 ? (
<select
aria-label="新上传发票关联明细"
aria-label="新发票添加位置"
className="select-std mt-3 w-full"
value={invoiceUploadLinkedItemId}
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
>
<option value="">请选择新上传发票对应的费用明细</option>
<option value="">自动创建新明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
@@ -121,7 +121,7 @@ export function ReimbursementItemEditor({
onChange={(event) => setExpenseTypeId(event.target.value)}
value={expenseTypeId}
>
{expenseTypes.length === 0 ? <option value="">暂无可用费用类型</option> : null}
<option value="">待补充费用类型</option>
{expenseTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
@@ -199,7 +199,6 @@ export function ReimbursementItemEditor({
data-testid="reimbursement-item-save"
disabled={
isPending ||
!expenseTypeId ||
!expenseDate ||
!amount.trim() ||
!hasPositiveAmount
@@ -585,6 +585,9 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === "/api/invoice-pool/status") {
state.statusPollCount += 1;
const stillPending = state.statusPollCount === 1;
if (!stillPending && !state.availableInvoices.some((invoice) => invoice.id === pendingInvoiceId)) {
state.availableInvoices.push({ ...buildAvailableInvoice(), id: pendingInvoiceId, invoiceNumber: "12345678" });
}
await fulfillJson(route, {
items: [
{
@@ -835,7 +838,135 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
}
test.describe("R111 国内报销发票与附件双端体验", () => {
test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => {
for (const mobile of [false, true]) {
test(`选票优先空单 ${mobile ? "手机" : "桌面"} 上传不占用、一次建明细并批量分类`, async ({ page }) => {
const state = buildReimbursementState();
state.detailItems = [];
state.invoices = [];
state.availableInvoices = [];
state.failedInvoices = [];
state.invoiceGuardEnabled = true;
await mockApis(page, state);
let bindCount = 0;
let classificationCount = 0;
await page.route("**/api/invoice-pool/upload", async (route) => {
state.availableInvoices = [buildAvailableInvoice()];
await fulfillJson(route, { invoicePoolId: availableInvoiceId, status: "available", uploadAction: "create" });
});
await page.route(`**/api/invoice-pool/${availableInvoiceId}/bind`, async (route) => {
expect(route.request().postDataJSON()).toEqual({ reimbursementId, createItem: true });
bindCount += 1;
state.detailItems = [{ id: "item-auto", expenseTypeId: null, expenseType: null, expenseDate: "2026-05-08", amount: "1683.00", description: "" }];
state.availableInvoices = [];
await fulfillJson(route, { id: availableInvoiceId, status: "bound", linkedItemId: "item-auto" });
});
await page.route(`**/api/reimbursements/${reimbursementId}/items/classify`, async (route) => {
expect(route.request().postDataJSON()).toEqual({ itemIds: ["item-auto"], expenseTypeId: "expense-type-office-supplies-r111" });
classificationCount += 1;
const expenseType = buildExpenseTypes().items.find((type) => type.id === "expense-type-office-supplies-r111")!;
state.detailItems = state.detailItems!.map((item) => ({ ...item, expenseTypeId: expenseType.id, expenseType }));
await fulfillJson(route, {});
});
await login(page);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-invoice-add").click();
await expect(page.getByRole("dialog")).toContainText("我的发票");
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ name: "synthetic.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4 synthetic") });
await page.getByTestId("reimbursement-invoice-upload-submit").click();
const dialog = page.getByRole("dialog");
const checkbox = dialog.getByRole("checkbox").first();
await expect(checkbox).toBeChecked();
expect(bindCount).toBe(0);
await dialog.getByRole("button", { name: "返回报销单" }).click();
expect(bindCount).toBe(0);
await page.getByTestId("reimbursement-invoice-add").click();
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await expect(checkbox).not.toBeChecked();
await checkbox.check();
await checkbox.uncheck();
await expect(dialog.getByRole("button", { name: /添加到报销单/ })).toBeDisabled();
expect(bindCount).toBe(0);
await checkbox.check();
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => bindCount).toBe(1);
if (mobile) await page.keyboard.press("Escape");
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
await page.reload();
await expect(page.getByText("费用分类:1 条待补充")).toBeVisible();
await page.getByRole("checkbox", { name: /明细 1/ }).check();
await page.getByLabel("批量费用类型").selectOption("expense-type-office-supplies-r111");
await page.getByRole("button", { name: "设置所选 1 条分类" }).click();
await expect.poll(() => classificationCount).toBe(1);
await expect(page.getByText("费用分类:1 条待补充")).toHaveCount(0);
await page.reload();
await expect(page.getByText("费用分类(可批量修改)")).toBeVisible();
});
test(`已有明细入口 ${mobile ? "手机" : "桌面"} 选旧票只发送指定明细`, async ({ page }) => {
const state = buildReimbursementState();
await mockApis(page, state);
const requests: unknown[] = [];
await page.route(`**/api/invoice-pool/${availableInvoiceId}/bind`, async (route) => {
requests.push(route.request().postDataJSON());
await fulfillJson(route, { id: availableInvoiceId, linkedItemId: "item-r111" });
});
await login(page);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
await page.getByRole("button", { name: "为明细添加发票" }).first().click();
const dialog = page.getByRole("dialog");
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await dialog.getByRole("checkbox").first().check();
await expect(dialog.getByLabel("添加到哪条明细")).toHaveValue("item-r111");
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => requests.length).toBe(1);
expect(requests[0]).toEqual({ reimbursementId, linkedItemId: "item-r111" });
if (mobile) await dialog.getByRole("button", { name: "返回报销单" }).click();
await expect(dialog).not.toBeVisible();
await page.getByTestId("reimbursement-invoice-add").click();
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await dialog.getByRole("checkbox").first().check();
await expect(dialog.getByLabel("添加到哪条明细")).toHaveValue("");
});
test(`多票添加 ${mobile ? "手机" : "桌面"} 部分失败仅重试失败票`, async ({ page }) => {
const state = buildReimbursementState();
state.failedInvoices = [];
state.availableInvoices = [buildAvailableInvoice(), { ...buildAvailableInvoice(), id: pendingInvoiceId, invoiceNumber: "87654321" }];
await mockApis(page, state);
const attempts: Record<string, number> = {};
await page.route("**/api/invoice-pool/*/bind", async (route) => {
const invoiceId = new URL(route.request().url()).pathname.split("/").at(-2)!;
expect(route.request().postDataJSON()).toEqual({ reimbursementId, createItem: true });
attempts[invoiceId] = (attempts[invoiceId] ?? 0) + 1;
if (invoiceId === pendingInvoiceId && attempts[invoiceId] === 1) {
await route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ error: { code: "VALID_001", message: "validation failed", details: { reason: "invoice_prefill_incomplete" } } }) });
return;
}
state.availableInvoices = state.availableInvoices.filter((invoice) => invoice.id !== invoiceId);
await fulfillJson(route, { id: invoiceId, status: "bound" });
});
await login(page);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-invoice-add").click();
const dialog = page.getByRole("dialog");
if (mobile) await dialog.getByRole("button", { name: "已有发票", exact: true }).click();
await dialog.getByRole("checkbox").nth(0).check();
await dialog.getByRole("checkbox").nth(1).check();
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => attempts[pendingInvoiceId]).toBe(1);
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
await expect(dialog.getByRole("checkbox")).toBeChecked();
await expect(dialog.getByRole("button", { name: /添加到报销单/ })).toBeEnabled();
await dialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect.poll(() => attempts[pendingInvoiceId]).toBe(2);
expect(attempts[availableInvoiceId]).toBe(1);
});
}
test("PC 报销详情上传发票后选中添加,并提供附件材料", async ({ page }) => {
const state = buildReimbursementState();
await page.addInitScript(() => {
const openCalls: unknown[][] = [];
@@ -868,6 +999,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
buffer: Buffer.from("%PDF r111 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
expect(state.bindRequests).toHaveLength(0);
await page.getByRole("dialog").getByRole("button", { name: "添加到报销单" }).click();
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
@@ -946,10 +1080,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByTestId("reimbursement-invoice-add").click();
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
await invoiceDialog.locator("input[type='checkbox']").first().check();
await expect(invoiceDialog.getByText("请选择该发票对应的费用明细。")).toBeVisible();
const bindButton = invoiceDialog.getByRole("button", { name: "添加选中发票" });
await expect(bindButton).toBeDisabled();
await invoiceDialog.getByLabel("关联费用明细").selectOption("item-r111");
await expect(invoiceDialog.getByText("点击添加后生成可编辑明细;也可选择加入已有明细。")).toBeVisible();
const bindButton = invoiceDialog.getByRole("button", { name: "添加到报销单" });
await expect(bindButton).toBeEnabled();
await invoiceDialog.getByLabel("添加到哪条明细").selectOption("item-r111");
await expect(bindButton).toBeEnabled();
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
@@ -957,8 +1091,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 guarded invoice"),
});
await invoiceDialog.getByLabel("新上传发票关联明细").selectOption("item-r111");
await invoiceDialog.getByLabel("新发票添加位置").selectOption("item-r111");
await invoiceDialog.locator("input[type='checkbox']").first().uncheck();
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
expect(state.bindRequests).toHaveLength(0);
await bindButton.click();
await expect.poll(() => state.bindRequests).toContainEqual({
reimbursementId,
linkedItemId: "item-r111",
@@ -969,10 +1107,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
await page.getByRole("button", { name: "新增明细", exact: true }).click();
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
await page.getByLabel(/^费用类型/).selectOption("expense-type-office-supplies-r111");
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
await page.getByLabel(/^费用类型/).selectOption("expense-type-assets-r111");
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
await page.getByRole("button", { name: "保存", exact: true }).click();
@@ -1251,6 +1389,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
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.poll(() => state.statusPollCount).toBeGreaterThanOrEqual(2);
expect(state.bindRequests).toHaveLength(0);
await invoiceDialog.getByRole("button", { name: /添加到报销单/ }).click();
await expect(
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
).toBeVisible({ timeout: 15_000 });