feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -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": "报销单当前不能添加票据,请刷新并确认单据状态。",
|
||||
|
||||
@@ -129,6 +129,7 @@ export type InvoiceBindPayload =
|
||||
| {
|
||||
reimbursementId: string;
|
||||
linkedItemId?: string;
|
||||
createItem?: true;
|
||||
}
|
||||
| {
|
||||
travelOrderId: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
在新工单中引用
屏蔽一个用户