feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览
这个提交包含在:
@@ -25,6 +25,15 @@ export type PaymentBatchItem = {
|
||||
failureMessage: string | null;
|
||||
createdAt: string | null;
|
||||
openRiskFlags: RiskFlag[];
|
||||
expenseItems?: Array<{
|
||||
id: string;
|
||||
expenseTypeName: string;
|
||||
expenseDate: string | null;
|
||||
description: string;
|
||||
amount: string;
|
||||
}>;
|
||||
expenseItemsTotal?: string;
|
||||
expenseItemsAmountMatches?: boolean;
|
||||
};
|
||||
|
||||
export type PaymentBatch = {
|
||||
@@ -235,6 +244,14 @@ export async function downloadPaymentBatchReceiptAttachment(
|
||||
return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason));
|
||||
}
|
||||
|
||||
export async function previewPaymentBatchReceiptAttachment(
|
||||
batchId: string,
|
||||
attachmentId: string,
|
||||
): Promise<ApiBlobResponse> {
|
||||
const params = new URLSearchParams({ mode: "preview" });
|
||||
return apiRequestBlob(`${paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId)}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function voidPaymentBatchReceiptAttachment(batchId: string, attachmentId: string, reason: string) {
|
||||
return apiRequestData<{ attachmentId: string; outcome: "voided"; voidedAt: string }>(
|
||||
`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/void`,
|
||||
|
||||
@@ -40,7 +40,7 @@ export function AttachmentGovernanceBadges({
|
||||
<span className={sensitivityClass(attachment.sensitivityLevel)}>
|
||||
{SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel}
|
||||
</span>
|
||||
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">需填用途</span> : null}
|
||||
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">下载需说明用途</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
description: string | null;
|
||||
editable: boolean;
|
||||
mobile?: boolean;
|
||||
showDescription?: boolean;
|
||||
};
|
||||
|
||||
export function ReimbursementHeaderEditor({
|
||||
@@ -22,14 +23,18 @@ export function ReimbursementHeaderEditor({
|
||||
description,
|
||||
editable,
|
||||
mobile = false,
|
||||
showDescription = true,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [date, setDate] = useState(reimbursementDate ?? "");
|
||||
const [reason, setReason] = useState(description ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const dirty = editing && (date !== (reimbursementDate ?? "") || reason !== (description ?? ""));
|
||||
const runGuarded = useUnsavedChangesGuard(dirty, "报销事由和日期");
|
||||
const dirty = editing && (
|
||||
date !== (reimbursementDate ?? "") ||
|
||||
(showDescription && reason !== (description ?? ""))
|
||||
);
|
||||
const runGuarded = useUnsavedChangesGuard(dirty, showDescription ? "报销事由和日期" : "报销日期");
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
@@ -40,7 +45,7 @@ export function ReimbursementHeaderEditor({
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateReimbursement(reimbursementId, {
|
||||
reimbursementDate: date,
|
||||
description: reason.trim() || null,
|
||||
...(showDescription ? { description: reason.trim() || null } : {}),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
@@ -59,16 +64,19 @@ export function ReimbursementHeaderEditor({
|
||||
});
|
||||
|
||||
if (!editing) {
|
||||
if (!showDescription && !editable) return null;
|
||||
return (
|
||||
<div className={mobile ? "mt-3" : "mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2"}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-sm leading-6 text-zinc-600">
|
||||
<span className="font-medium text-zinc-800">报销事由:</span>
|
||||
{description || "未填写"}
|
||||
</div>
|
||||
{showDescription ? (
|
||||
<div className="min-w-0 text-sm leading-6 text-zinc-600">
|
||||
<span className="font-medium text-zinc-800">报销事由:</span>
|
||||
{description || "未填写"}
|
||||
</div>
|
||||
) : <div className="min-w-0 text-sm leading-6 text-zinc-600">报销日期可在草稿阶段修改</div>}
|
||||
{editable ? (
|
||||
<Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
{showDescription ? "编辑" : "编辑报销日期"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -78,15 +86,17 @@ export function ReimbursementHeaderEditor({
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3" data-testid="reimbursement-header-editor">
|
||||
<div className={mobile ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
|
||||
<div className={mobile || !showDescription ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
|
||||
<label className="space-y-1 text-sm text-zinc-700">
|
||||
<span>报销日期</span>
|
||||
<Input type="date" value={date} disabled={mutation.isPending} onChange={(event) => setDate(event.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm text-zinc-700">
|
||||
<span>报销事由</span>
|
||||
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
|
||||
</label>
|
||||
{showDescription ? (
|
||||
<label className="space-y-1 text-sm text-zinc-700">
|
||||
<span>报销事由</span>
|
||||
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { isTravelReimbursement } from "../lib/reimbursementTravel";
|
||||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
@@ -934,6 +935,7 @@ export function MobileReimbursementDetailPage() {
|
||||
description={detail.description}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
showDescription={!isTravelReimbursement(detail)}
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
cancelPaymentBatch,
|
||||
createPaymentBatch,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
fetchPaymentBatchDetail,
|
||||
fetchPaymentBatchReceiptAttachments,
|
||||
fetchPaymentBatches,
|
||||
previewPaymentBatchReceiptAttachment,
|
||||
reviewPaymentBatch,
|
||||
revealPaymentBatchPayeeBank,
|
||||
updatePaymentBatchItem,
|
||||
@@ -173,6 +174,17 @@ export function PaymentBatchesPage() {
|
||||
>(null);
|
||||
const [actionReason, setActionReason] = useState("");
|
||||
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
|
||||
const [receiptPreview, setReceiptPreview] = useState<{
|
||||
attachment: PaymentBatchReceiptAttachment;
|
||||
objectUrl: string;
|
||||
mimeType: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (receiptPreview?.objectUrl) URL.revokeObjectURL(receiptPreview.objectUrl);
|
||||
};
|
||||
}, [receiptPreview?.objectUrl]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["payment-batches"],
|
||||
@@ -421,6 +433,26 @@ export function PaymentBatchesPage() {
|
||||
downloadReceiptMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
const previewReceiptMutation = useMutation({
|
||||
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
|
||||
if (!selectedBatch) throw new Error("请先选择付款批次");
|
||||
const response = await previewPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
|
||||
return { attachment, response };
|
||||
},
|
||||
onSuccess: ({ attachment, response }) => {
|
||||
setReceiptPreview({
|
||||
attachment,
|
||||
objectUrl: URL.createObjectURL(response.blob),
|
||||
mimeType: response.blob.type || attachment.mimeType,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveError(err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const voidReceiptMutation = useMutation({
|
||||
mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => {
|
||||
return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason);
|
||||
@@ -704,7 +736,9 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
|
||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
|
||||
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">
|
||||
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{selectedBatch.items.map((item) => (
|
||||
<div key={item.id} className="detail-evidence-card">
|
||||
@@ -713,9 +747,42 @@ export function PaymentBatchesPage() {
|
||||
<div className="font-medium text-zinc-900">{item.entityCode}</div>
|
||||
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-zinc-600">
|
||||
<span>{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</span>
|
||||
{selectedBatch.entityType === "reimbursement" ? (
|
||||
<Link className="inline-flex min-h-11 items-center font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2" to={`/reimbursements/${item.entityId}`}>
|
||||
查看报销单
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
|
||||
{selectedBatch.entityType === "reimbursement" ? (
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50/60 p-3" data-testid="payment-batch-expense-items">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-zinc-900">费用明细 · {item.expenseItems?.length ?? 0} 条</span>
|
||||
<span className="font-medium text-zinc-900">明细小计 {formatMoney(item.expenseItemsTotal ?? "0.00")}</span>
|
||||
</div>
|
||||
{item.expenseItems?.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{item.expenseItems.map((expenseItem) => (
|
||||
<div key={expenseItem.id} className="grid gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm md:grid-cols-[10rem_8rem_minmax(0,1fr)_8rem] md:items-center">
|
||||
<div><span className="text-xs text-zinc-500 md:hidden">费用类型:</span>{expenseItem.expenseTypeName}</div>
|
||||
<div><span className="text-xs text-zinc-500 md:hidden">发生日期:</span>{formatDisplayDate(expenseItem.expenseDate)}</div>
|
||||
<div className="min-w-0 break-words"><span className="text-xs text-zinc-500 md:hidden">说明:</span>{expenseItem.description || "-"}</div>
|
||||
<div className="font-medium text-zinc-900 md:text-right"><span className="text-xs font-normal text-zinc-500 md:hidden">金额:</span>{formatMoney(expenseItem.amount)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">未读取到费用明细,请进入原报销单核对后再付款。</div>
|
||||
)}
|
||||
{(item.expenseItems?.length ?? 0) > 0 && item.expenseItemsAmountMatches === false ? (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">费用明细小计与批次付款金额不一致,请进入原报销单核对。</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 text-sm font-medium text-zinc-900">付款信息</div>
|
||||
<div className="mt-2 detail-fact-grid text-sm md:grid-cols-4">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
|
||||
@@ -827,6 +894,17 @@ export function PaymentBatchesPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="min-h-11"
|
||||
disabled={previewReceiptMutation.isPending || Boolean(attachment.archivedAt)}
|
||||
onClick={() => previewReceiptMutation.mutate(attachment)}
|
||||
>
|
||||
{previewReceiptMutation.isPending ? "加载中..." : "在线查看"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="min-h-11"
|
||||
disabled={downloadReceiptMutation.isPending || Boolean(attachment.archivedAt)}
|
||||
onClick={() => void handleDownloadReceipt(attachment)}
|
||||
>
|
||||
@@ -1040,6 +1118,37 @@ export function PaymentBatchesPage() {
|
||||
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
|
||||
}}
|
||||
/>
|
||||
<Dialog
|
||||
open={Boolean(receiptPreview)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReceiptPreview(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[92vh] max-w-5xl flex-col p-5" data-testid="payment-batch-receipt-preview-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>在线查看银行回单</DialogTitle>
|
||||
<DialogDescription>{receiptPreview?.attachment.originalName ?? "银行回单"} · 本次查看已自动写入审计记录。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 bg-zinc-100 p-2">
|
||||
{receiptPreview?.mimeType === "application/pdf" ? (
|
||||
<iframe
|
||||
className="h-[68vh] min-h-[28rem] w-full rounded bg-white"
|
||||
src={receiptPreview.objectUrl}
|
||||
title={`银行回单 ${receiptPreview.attachment.originalName}`}
|
||||
/>
|
||||
) : receiptPreview ? (
|
||||
<img
|
||||
alt={`银行回单 ${receiptPreview.attachment.originalName}`}
|
||||
className="mx-auto max-h-[68vh] max-w-full rounded bg-white object-contain"
|
||||
src={receiptPreview.objectUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" className="min-h-11" onClick={() => setReceiptPreview(null)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={Boolean(reasonAction)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
@@ -1137,6 +1137,7 @@ export function ReimbursementDetailPage() {
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
showDescription={!isTravelReimbursement(detail)}
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
|
||||
@@ -80,7 +80,7 @@ function reimbursementListItem() {
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft", isTravel = false) {
|
||||
const workflowTaskId = "workflow-task-action-confirm";
|
||||
return {
|
||||
...reimbursementListItem(),
|
||||
@@ -90,7 +90,9 @@ function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
|
||||
departmentId: "department-action-confirm",
|
||||
department: { id: "department-action-confirm", name: "合成部门" },
|
||||
submittedAt: null,
|
||||
travelSummary: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
|
||||
travelSummary: isTravel
|
||||
? { isTravel: true, purpose: "拜访客户讨论需求", location: "西安", startDate: "2026-05-14", endDate: "2026-05-15" }
|
||||
: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
|
||||
projectId: null,
|
||||
linkedTravelRequestId: null,
|
||||
linkedTravelRequest: null,
|
||||
@@ -103,7 +105,9 @@ function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
|
||||
expenseDate: "2026-05-15",
|
||||
amount: "88.00",
|
||||
description: "合成报销动作回归",
|
||||
expenseType: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
|
||||
expenseType: isTravel
|
||||
? { id: "expense-type-action-confirm", code: "travel_train", name: "火车票", category: "travel", enabled: true }
|
||||
: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
|
||||
},
|
||||
],
|
||||
invoices: [
|
||||
@@ -192,11 +196,12 @@ function paymentBatch(
|
||||
paymentChannel: "bank" | "cash" = "bank",
|
||||
canExportPaymentOrder = false,
|
||||
paymentOrderExportCount = 0,
|
||||
entityType: "reimbursement" | "travel_order" = "reimbursement",
|
||||
) {
|
||||
return {
|
||||
id: paymentBatchId,
|
||||
batchNo: "PAY-ACTION-001",
|
||||
entityType: "reimbursement",
|
||||
entityType,
|
||||
name: "R7E 高风险付款确认",
|
||||
status,
|
||||
defaultPaymentDate: "2026-05-15",
|
||||
@@ -250,6 +255,17 @@ function paymentBatch(
|
||||
failureMessage: null,
|
||||
createdAt: "2026-05-15T08:00:00.000Z",
|
||||
openRiskFlags: [],
|
||||
expenseItems: [
|
||||
{
|
||||
id: "expense-item-action-confirm",
|
||||
expenseTypeName: "办公用品",
|
||||
expenseDate: "2026-05-14",
|
||||
description: "项目讨论资料",
|
||||
amount: "88.00",
|
||||
},
|
||||
],
|
||||
expenseItemsTotal: "88.00",
|
||||
expenseItemsAmountMatches: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -265,7 +281,9 @@ async function mockSystemActionApis(
|
||||
paymentChannel?: "bank" | "cash";
|
||||
canExportPaymentOrder?: boolean;
|
||||
paymentOrderExportCount?: number;
|
||||
paymentEntityType?: "reimbursement" | "travel_order";
|
||||
reimbursementStatus?: "draft" | "reviewing" | "paid";
|
||||
travelReimbursement?: boolean;
|
||||
meOverride?: Partial<typeof me>;
|
||||
} = {},
|
||||
) {
|
||||
@@ -279,10 +297,12 @@ async function mockSystemActionApis(
|
||||
exportReimbursements: 0,
|
||||
executePaymentBatch: 0,
|
||||
paymentBatchDetail: 0,
|
||||
previewPaymentReceipt: 0,
|
||||
uploadPaymentReceipt: 0,
|
||||
voidPaymentReceipt: 0,
|
||||
attachmentDeleteReasons: [] as string[],
|
||||
receiptVoidReasons: [] as string[],
|
||||
reimbursementUpdates: [] as Array<Record<string, unknown>>,
|
||||
submitReimbursement: 0,
|
||||
withdrawReimbursement: 0,
|
||||
remindWorkflow: 0,
|
||||
@@ -370,7 +390,17 @@ async function mockSystemActionApis(
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus));
|
||||
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus, options.travelReimbursement));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
calls.reimbursementUpdates.push(body);
|
||||
await fulfillJson(route, {
|
||||
...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement),
|
||||
reimbursementDate: body.reimbursementDate ?? "2026-05-14",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -460,6 +490,7 @@ async function mockSystemActionApis(
|
||||
options.paymentChannel,
|
||||
options.canExportPaymentOrder,
|
||||
options.paymentOrderExportCount,
|
||||
options.paymentEntityType,
|
||||
);
|
||||
await fulfillJson(route, {
|
||||
items: [{
|
||||
@@ -485,6 +516,7 @@ async function mockSystemActionApis(
|
||||
options.paymentChannel,
|
||||
options.canExportPaymentOrder,
|
||||
options.paymentOrderExportCount,
|
||||
options.paymentEntityType,
|
||||
),
|
||||
});
|
||||
return;
|
||||
@@ -528,6 +560,21 @@ async function mockSystemActionApis(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/file` &&
|
||||
request.method() === "GET" &&
|
||||
url.searchParams.get("mode") === "preview"
|
||||
) {
|
||||
calls.previewPaymentReceipt += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/pdf",
|
||||
headers: { "content-disposition": "inline; filename=synthetic-receipt.pdf" },
|
||||
body: Buffer.from("%PDF-1.4\nsynthetic preview\n%%EOF\n", "utf8"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "POST") {
|
||||
calls.uploadPaymentReceipt += 1;
|
||||
hasReceipt = true;
|
||||
@@ -644,6 +691,26 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`));
|
||||
});
|
||||
|
||||
test("差旅报销在 PC 和移动详情只展示出差事由", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page, { travelReimbursement: true });
|
||||
await login(page, user);
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "编辑报销日期" }).click();
|
||||
await page.getByTestId("reimbursement-header-editor").locator('input[type="date"]').fill("2026-05-13");
|
||||
await page.getByTestId("reimbursement-header-editor").getByRole("button", { name: "保存" }).click();
|
||||
await expect.poll(() => calls.reimbursementUpdates).toHaveLength(1);
|
||||
expect(calls.reimbursementUpdates[0]).toEqual({ reimbursementDate: "2026-05-13" });
|
||||
|
||||
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByText("出差事由", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText("拜访客户讨论需求", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("报销事由", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
page.on("dialog", (dialog) => {
|
||||
@@ -748,6 +815,45 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
expect(responsiveContract.actionPosition).toBe("sticky");
|
||||
});
|
||||
|
||||
test("付款详情展示费用明细并支持回单在线查看,下载仍要求用途", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page, { hasReceipt: true });
|
||||
await login(page, user);
|
||||
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||
|
||||
const itemsPanel = page.getByTestId("payment-batch-items-panel");
|
||||
await expect(itemsPanel).toContainText("本批次报销单");
|
||||
await expect(itemsPanel).toContainText("办公用品");
|
||||
await expect(itemsPanel).toContainText("项目讨论资料");
|
||||
await expect(itemsPanel).toContainText("明细小计 ¥88.00");
|
||||
await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveAttribute("href", `/reimbursements/${reimbursementId}`);
|
||||
|
||||
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
||||
await expect(receipt).toContainText("下载需说明用途");
|
||||
await receipt.getByRole("button", { name: "在线查看" }).click();
|
||||
await expect.poll(() => calls.previewPaymentReceipt).toBe(1);
|
||||
const preview = page.getByTestId("payment-batch-receipt-preview-dialog");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText("本次查看已自动写入审计记录");
|
||||
await expect(preview.locator("iframe")).toHaveAttribute("title", /银行回单/);
|
||||
await preview.getByRole("button", { name: "关闭" }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
|
||||
await receipt.getByRole("button", { name: "下载" }).click();
|
||||
await expect(page.getByRole("dialog")).toContainText("填写下载用途");
|
||||
});
|
||||
|
||||
test("历史差旅付款批次不冒充报销明细或生成错误报销入口", async ({ page }) => {
|
||||
await mockSystemActionApis(page, { paymentEntityType: "travel_order" });
|
||||
await login(page, user);
|
||||
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||
|
||||
const itemsPanel = page.getByTestId("payment-batch-items-panel");
|
||||
await expect(itemsPanel).toContainText("本批次差旅订单");
|
||||
await expect(itemsPanel.getByTestId("payment-batch-expense-items")).toHaveCount(0);
|
||||
await expect(itemsPanel.getByRole("link", { name: "查看报销单" })).toHaveCount(0);
|
||||
await expect(itemsPanel).toContainText("付款信息");
|
||||
});
|
||||
|
||||
test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => {
|
||||
const cases: Array<[SyntheticPaymentBatchStatus, string]> = [
|
||||
["draft", "待复核"],
|
||||
|
||||
在新工单中引用
屏蔽一个用户