feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区

这个提交包含在:
2026-08-03 16:00:42 +08:00
父节点 8d428bd51d
当前提交 b7d5761067
共修改 31 个文件,包含 1453 行新增和 383 行删除
+6 -1
查看文件
@@ -1,6 +1,7 @@
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { RiskFlag } from "./feeControl";
import type { ProcessingProgress } from "../types/processingProgress";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
@@ -75,6 +76,10 @@ export type PaymentBatch = {
items: PaymentBatchItem[];
};
export type PaymentBatchDetail = PaymentBatch & {
processingProgress: ProcessingProgress;
};
export type PaymentBatchCandidate = {
entityId: string;
entityType: "reimbursement";
@@ -123,7 +128,7 @@ export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEnti
}
export async function fetchPaymentBatchDetail(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}`);
return apiRequestData<{ item: PaymentBatchDetail }>(`/api/payment-batches/${batchId}`);
}
export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) {
+3 -15
查看文件
@@ -1,6 +1,7 @@
import { apiRequestBlob, apiRequestData } from "./http";
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { ProcessingProgress, ProcessingStage } from "../types/processingProgress";
export type ReimbursementStatus =
| "draft"
@@ -112,21 +113,8 @@ export type ReimbursementWorkflowProgress = {
}>;
};
export type ReimbursementProcessingStage = {
key: string;
label: string;
state: "completed" | "current" | "pending" | "exception";
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ReimbursementProcessingProgress = {
stages: ReimbursementProcessingStage[];
};
export type ReimbursementProcessingStage = ProcessingStage;
export type ReimbursementProcessingProgress = ProcessingProgress;
export type ReimbursementApprovalHistoryItem = {
id: string;
@@ -98,7 +98,7 @@ export function BusinessDetailActionPanel({
const hasContentBeforeSummary = Boolean(primaryAction || secondaryActions || moreActions || hasChildren);
return (
<SectionPanel title={title} actions={status} className={cn("detail-action-panel xl:sticky xl:top-24", className)}>
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
<div className="detail-action-stack" data-testid={testId}>
{primaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{secondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
@@ -1,12 +1,15 @@
import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react";
import { AlertCircle, Ban, Check, Circle, Clock, ExternalLink } from "lucide-react";
import { Link } from "react-router-dom";
import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements";
import type { ProcessingProgress as ProcessingProgressValue } from "../../types/processingProgress";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import { cn } from "../../lib/utils";
type Props = {
progress: ProcessingProgress;
progress: ProcessingProgressValue;
ariaLabel: string;
testId: string;
mobile?: boolean;
targetLinkLabel?: string;
};
const STATE_TEXT = {
@@ -14,15 +17,18 @@ const STATE_TEXT = {
current: "处理中",
pending: "待处理",
exception: "需处理",
terminated: "已终止",
} as const;
export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) {
export function ProcessingProgress({
progress,
ariaLabel,
testId,
mobile = false,
targetLinkLabel = "查看详情",
}: Props) {
return (
<ol
className="space-y-0"
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
aria-label="报销处理进度"
>
<ol className="space-y-0" data-testid={testId} aria-label={ariaLabel}>
{progress.stages.map((stage, index) => {
const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment);
const Icon = stage.state === "completed"
@@ -31,7 +37,9 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
? Clock
: stage.state === "exception"
? AlertCircle
: Circle;
: stage.state === "terminated"
? Ban
: Circle;
const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result]
.filter(Boolean);
@@ -47,6 +55,7 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700",
stage.state === "exception" && "border-red-300 bg-red-50 text-red-700",
stage.state === "pending" && "border-zinc-300 text-zinc-400",
stage.state === "terminated" && "border-zinc-300 bg-zinc-100 text-zinc-500",
)}
aria-hidden="true"
>
@@ -69,7 +78,7 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm 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={stage.targetHref}
>
查看所在付款批次
{targetLinkLabel}
<ExternalLink aria-hidden="true" className="size-4" />
</Link>
) : null}
+2 -2
查看文件
@@ -585,7 +585,7 @@
}
.detail-workspace-grid {
@apply grid gap-5 xl:grid-cols-[minmax(0,1.45fr),minmax(300px,0.72fr)];
@apply grid gap-5 lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)];
}
.detail-command-panel {
@@ -666,7 +666,7 @@
}
.workflow-action-panel {
@apply relative z-30 xl:sticky xl:top-20;
@apply relative z-30 lg:sticky lg:top-20;
}
.workflow-action-bar {
@@ -44,7 +44,7 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -944,6 +944,34 @@ export function MobileReimbursementDetailPage() {
) : null}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ProcessingProgress
progress={detail.processingProgress ?? { stages: [] }}
ariaLabel="报销处理进度"
testId="mobile-reimbursement-processing-progress"
targetLinkLabel="查看所在付款批次"
mobile
/>
</div>
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1253,28 +1281,6 @@ export function MobileReimbursementDetailPage() {
)}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
</div>
</section>
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
+188 -143
查看文件
@@ -24,6 +24,13 @@ import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import {
BusinessListToolbar,
BusinessMobileList,
@@ -44,6 +51,12 @@ import {
DialogTitle,
} from "../components/ui/dialog";
import { Textarea } from "../components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -54,6 +67,14 @@ import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
type PaymentActionKind =
| "review"
| "downloadOrder"
| "uploadReceipt"
| "execute"
| "cancel"
| "redownloadOrder";
function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销";
if (value === "travel_order") return "差旅订单";
@@ -185,7 +206,6 @@ export function PaymentBatchesPage() {
canManageBatch &&
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status);
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
@@ -202,6 +222,8 @@ export function PaymentBatchesPage() {
["pending", "failed"].includes(item.status) &&
(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank",
));
const canUploadReceipt =
canEditReceiptAttachments && requiresBankReceipt && receiptAttachmentsQuery.isSuccess;
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
const needsBankReceipt =
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
@@ -213,6 +235,33 @@ export function PaymentBatchesPage() {
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
const paymentActionModel = useMemo(() => {
const primary = canReviewSelectedBatch
? "review"
: needsBankReceipt
? "uploadReceipt"
: canRegisterPaymentComplete
? "execute"
: null;
const more: PaymentActionKind[] = [];
if (
selectedBatch?.status === "reviewed" &&
selectedBatch.canExportPaymentOrder &&
canExportPaymentOrder
) {
more.push(selectedBatch.paymentOrderExportCount > 0 ? "redownloadOrder" : "downloadOrder");
}
if (canCancelSelectedBatch) more.push("cancel");
return { primary: primary as PaymentActionKind | null, more };
}, [
canCancelSelectedBatch,
canExportPaymentOrder,
canRegisterPaymentComplete,
canReviewSelectedBatch,
needsBankReceipt,
selectedBatch,
]);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] });
@@ -421,6 +470,68 @@ export function PaymentBatchesPage() {
},
});
const paymentActionLabel = (kind: PaymentActionKind) => {
if (kind === "review") return reviewMutation.isPending ? "复核中..." : "复核通过";
if (kind === "downloadOrder") return paymentOrderMutation.isPending ? "生成中..." : "下载银行制单";
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单";
if (kind === "execute") return executeMutation.isPending ? "登记中..." : "登记付款完成";
if (kind === "redownloadOrder") return "重新下载制单";
return "取消批次";
};
const paymentActionDisabled = (kind: PaymentActionKind) => {
if (kind === "review") return reviewMutation.isPending;
if (["downloadOrder", "redownloadOrder"].includes(kind)) return paymentOrderMutation.isPending;
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending;
if (kind === "execute") return executeMutation.isPending;
return cancelMutation.isPending || !canCancelSelectedBatch;
};
const activatePaymentAction = (kind: PaymentActionKind) => {
if (!selectedBatch) return;
if (kind === "review" || kind === "execute") {
setConfirmPaymentAction({
kind: kind === "review" ? "financeReview" : "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
});
return;
}
if (kind === "uploadReceipt") {
receiptInputRef.current?.click();
return;
}
setActionReason("");
if (kind === "cancel") {
setReasonAction({ kind: "cancel", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo });
return;
}
setReasonAction({
kind: "paymentOrder",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
reexport: kind === "redownloadOrder",
});
};
const renderPaymentActionButton = (
kind: PaymentActionKind,
variant: "default" | "secondary" | "destructive" = "default",
) => (
<Button
key={kind}
type="button"
variant={variant}
disabled={paymentActionDisabled(kind)}
data-testid={`payment-batch-action-${kind}`}
onClick={() => activatePaymentAction(kind)}
>
{paymentActionLabel(kind)}
</Button>
);
return (
<ListWorkspace>
{downloadReasonDialog}
@@ -543,7 +654,11 @@ export function PaymentBatchesPage() {
</Button>
</div>
) : selectedBatch ? (
<div className="space-y-5">
<div
className={paymentActionModel.primary || paymentActionModel.more.length ? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)] lg:pb-0" : ""}
>
<BusinessDetailWorkspace testId="payment-batch-detail-workspace">
<BusinessDetailMain>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -555,89 +670,7 @@ export function PaymentBatchesPage() {
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canReviewSelectedBatch ? (
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financeReview",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
复核通过
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
{canRegisterPaymentComplete ? (
<Button
type="button"
disabled={executeMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
登记付款完成
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => {
setActionReason("");
setReasonAction({
kind: "cancel",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
});
}}
>
取消
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
@@ -661,59 +694,13 @@ export function PaymentBatchesPage() {
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
<h3 className="text-base font-semibold text-zinc-900">当前处理</h3>
<div className="detail-fact-grid text-sm md:grid-cols-2">
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前责任</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "负责人(非制单人)"
: ["reviewed", "failed"].includes(selectedBatch.status)
? "财务"
: selectedBatch.status === "executed"
? "已完成"
: "已结束"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">下一步</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
? "核对失败原因并重试"
: "-"}
</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
{selectedBatch.status === "draft"
? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${formatMoney(selectedBatch.reviewRequirement.thresholdAmount)},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<ProcessingProgress
progress={selectedBatch.processingProgress}
ariaLabel="付款批次处理进度"
testId="payment-batch-processing-progress"
/>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
@@ -775,9 +762,11 @@ export function PaymentBatchesPage() {
<div>
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
<p className="mt-1 text-sm text-zinc-500">
{selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
{!requiresBankReceipt
? "当前付款渠道不要求银行回单,核对付款结果后可直接登记完成。"
: selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
</p>
</div>
{canUploadReceipt ? (
@@ -866,14 +855,70 @@ export function PaymentBatchesPage() {
</div>
</div>
))}
{receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
{requiresBankReceipt && receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
</div>
) : null}
</div>
</div>
</div>
</BusinessDetailMain>
<BusinessDetailAside className="hidden lg:block">
<BusinessDetailActionPanel
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
<details className="detail-disclosure">
<summary>更多</summary>
<div className="mt-3 grid gap-2">
{paymentActionModel.more.map((kind) =>
renderPaymentActionButton(kind, kind === "cancel" ? "destructive" : "secondary"),
)}
</div>
</details>
) : undefined}
summary={(
<>
<p>批次:{selectedBatch.batchNo}</p>
<p>金额:{formatMoney(selectedBatch.totalAmount)}</p>
</>
)}
testId="payment-batch-action-panel"
/>
</BusinessDetailAside>
</BusinessDetailWorkspace>
{paymentActionModel.primary || paymentActionModel.more.length ? (
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur lg:hidden"
data-testid="payment-batch-mobile-actions"
>
<div className="mobile-sticky-action-grid">
{paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : <span />}
{paymentActionModel.more.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="secondary">更多</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{paymentActionModel.more.map((kind) => (
<DropdownMenuItem
key={kind}
disabled={paymentActionDisabled(kind)}
className={kind === "cancel" ? "text-red-600 focus:text-red-600" : undefined}
onSelect={() => activatePaymentAction(kind)}
>
{paymentActionLabel(kind)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
) : null}
</div>
) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
)
+60 -80
查看文件
@@ -47,7 +47,7 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
@@ -60,6 +60,12 @@ import {
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
@@ -1028,30 +1034,6 @@ export function ReimbursementDetailPage() {
);
}
if (detail.status === "reviewing") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-workflow-progress-panel">看审批进度</a>
</Button>
);
}
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
</Button>
);
}
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
</Button>
);
}
return null;
})();
@@ -1105,16 +1087,10 @@ export function ReimbursementDetailPage() {
<StatusBadge status={detail.status} />
</>
)}
actions={(
<div className="flex flex-wrap gap-2" data-testid="reimbursement-first-screen-actions">
{primaryAction}
{secondaryActions}
</div>
)}
/>
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="space-y-5">
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div>
@@ -1227,35 +1203,17 @@ export function ReimbursementDetailPage() {
) : null}
</div>
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h4 className="text-base font-semibold text-zinc-900">更多</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
</div>
</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="处理进度">
<ProcessingProgress
progress={processingProgress}
ariaLabel="报销处理进度"
testId="reimbursement-workflow-progress"
targetLinkLabel="查看所在付款批次"
/>
</SectionPanel>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
@@ -1574,27 +1532,49 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="处理进度">
<div id="reimbursement-processing-progress-panel">
<ReimbursementProcessingProgress progress={processingProgress} />
</div>
</SectionPanel>
</div>
</BusinessDetailMain>
<div className="space-y-5">
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
</div>
</div>
<BusinessDetailAside>
<BusinessDetailActionPanel
status={<StatusBadge status={detail.status} />}
primaryAction={primaryAction}
secondaryActions={secondaryActions}
moreActions={showSecondaryDetail ? (
<div className="grid gap-2" data-testid="reimbursement-secondary-detail">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
) : null}
testId="reimbursement-action-panel"
>
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent
+1 -1
查看文件
@@ -444,7 +444,7 @@ export function WorkflowTaskDetailPage() {
<BusinessDetailAside>
<BusinessDetailActionPanel
title="处理"
className="workflow-action-panel xl:top-20"
className="workflow-action-panel lg:top-20"
secondaryActions={(
<>
<Button
@@ -37,9 +37,7 @@ export function ReimbursementActions({
}
return (
<div id="reimbursement-action-panel" className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">可执行动作</h3>
<div className="space-y-4">
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
+17
查看文件
@@ -0,0 +1,17 @@
export type ProcessingStageState = "completed" | "current" | "pending" | "exception" | "terminated";
export type ProcessingStage = {
key: string;
label: string;
state: ProcessingStageState;
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ProcessingProgress = {
stages: ProcessingStage[];
};
+2
查看文件
@@ -116,8 +116,10 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
data: { asset: { id: string } };
};
const assetId = createAssetPayload.data.asset.id;
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto(`/assets?id=${assetId}`);
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
await expect(page.locator(".detail-action-panel")).toHaveCSS("position", "sticky");
const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first();
if (await assetRow.isVisible().catch(() => false)) {
+1 -1
查看文件
@@ -163,7 +163,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
});
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("e2e-bank-receipt.pdf");
await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click();
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click();
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
@@ -1140,8 +1140,10 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-first-screen-actions").locator("a,button")).toHaveCount(2);
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度");
await expect(page.getByTestId("reimbursement-first-screen-actions")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-action-panel").getByRole("button", { name: "撤回" })).toBeVisible();
await expect(page.getByRole("link", { name: /看审批进度|看处理进度|查看付款结果/ })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
@@ -1149,12 +1151,15 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
await page.goto("/workflow/tasks/task-wave3");
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0);
await expect(page.getByText("审批动作")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("position", "sticky");
await page.setViewportSize({ width: 1440, height: 960 });
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
@@ -638,10 +638,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(pcSectionOrder).toEqual([
"reimbursement-overview",
"reimbursement-workflow-progress",
"reimbursement-items",
"reimbursement-materials",
"reimbursement-workflow-progress",
]);
await expect(page.locator('a[href="#reimbursement-workflow-progress-panel"]')).toHaveCount(0);
await expect(page.locator('a[href="#reimbursement-processing-progress-panel"]')).toHaveCount(0);
await expect(page.getByText("ERP")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
@@ -791,9 +793,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(mobileSectionOrder).toEqual([
"mobile-reimbursement-detail-focus",
"mobile-reimbursement-processing-section",
"mobile-reimbursement-items",
"mobile-reimbursement-materials",
"mobile-reimbursement-processing-section",
]);
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
@@ -155,7 +155,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(managerBatchDetail).toBeVisible();
await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name);
await managerBatchDetail.getByRole("button", { name: "复核通过" }).click();
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "复核通过" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
await page.getByTestId("action-confirm-submit").click();
await expect(managerBatchDetail).toContainText("1/1");
@@ -170,8 +170,8 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible();
await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "上传银行回单" })).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
@@ -185,7 +185,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
});
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
@@ -193,7 +193,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
url.pathname === `/api/payment-batches/${batchId}/execute` &&
response.ok();
});
await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click();
await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click();
await executeBatchResponsePromise;
@@ -80,21 +80,50 @@ function reimbursementListItem() {
};
}
function reimbursementDetail() {
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
const workflowTaskId = "workflow-task-action-confirm";
return {
...reimbursementListItem(),
status,
submitterId: me.id,
ownerId: me.id,
departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null,
travelSummary: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
items: [],
invoices: [],
items: [
{
id: "reimbursement-item-action-confirm",
expenseTypeId: "expense-type-action-confirm",
expenseDate: "2026-05-15",
amount: "88.00",
description: "合成报销动作回归",
expenseType: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
},
],
invoices: [
{
id: "invoice-action-confirm",
status: "bound",
invoiceCode: "SYNTHETIC",
invoiceNumber: "ACTION001",
invoiceDate: "2026-05-15",
sellerName: "合成供应方",
buyerName: "合成购买方",
documentCategory: "vat_invoice",
documentSubtype: null,
totalAmount: "88.00",
originalName: "合成动作发票.pdf",
mimeType: "application/pdf",
fileSize: 1024,
linkedItemId: "reimbursement-item-action-confirm",
},
],
attachments: [
{
id: reimbursementAttachmentId,
@@ -117,14 +146,53 @@ function reimbursementDetail() {
},
],
budgetHint: null,
pendingWorkflow: null,
pendingWorkflow: status === "reviewing" ? {
instanceId: "workflow-instance-action-confirm",
stepId: "workflow-step-action-confirm",
task: {
id: workflowTaskId,
step_name: "负责人复核",
assignee_id: "another-manager",
assignee_role: "manager",
},
} : null,
workflowProgress: null,
processingProgress: { stages: [] },
approvalHistory: [],
riskFlags: [],
};
}
function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" = "reviewed", highRisk = false) {
type SyntheticPaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
function paymentProcessingProgress(status: SyntheticPaymentBatchStatus, hasReceipt: boolean, requiresBankReceipt = true) {
const reviewState = status === "draft" ? "current" : status === "cancelled" ? "exception" : "completed";
const paymentState = status === "executed"
? "completed"
: status === "failed"
? "exception"
: status === "cancelled"
? "terminated"
: status === "reviewed"
? "current"
: "pending";
return {
stages: [
{ key: "payment_batch_created", label: "批次创建", state: "completed", actorName: me.name, occurredAt: "2026-05-15T08:00:00.000Z", result: "已创建", comment: null, detail: null, targetHref: null },
{ key: "payment_batch_review", label: "付款复核", state: reviewState, actorName: status === "draft" ? null : me.name, occurredAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z", result: status === "draft" ? "待复核" : status === "cancelled" ? "批次已取消" : "通过", comment: status === "cancelled" ? "合成取消原因" : null, detail: status === "draft" ? "待非制单人复核付款" : null, targetHref: null },
{ key: "payment_and_receipt", label: "付款完成", state: paymentState, actorName: ["executed", "failed"].includes(status) ? me.name : null, occurredAt: ["executed", "failed"].includes(status) ? "2026-05-15T08:30:00.000Z" : null, result: status === "executed" ? "已付款" : status === "failed" ? "付款失败" : status === "cancelled" ? "已终止" : "待付款", comment: null, detail: status === "reviewed" ? (!requiresBankReceipt ? "等待财务登记付款完成,非审批环节" : hasReceipt ? "银行回单已上传,等待财务登记付款完成,非审批环节" : "等待财务线下付款并上传回单,非审批环节") : null, targetHref: null },
],
};
}
function paymentBatch(
status: SyntheticPaymentBatchStatus = "reviewed",
highRisk = false,
hasReceipt = true,
paymentChannel: "bank" | "cash" = "bank",
canExportPaymentOrder = false,
paymentOrderExportCount = 0,
) {
return {
id: paymentBatchId,
batchNo: "PAY-ACTION-001",
@@ -132,10 +200,14 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
name: "R7E 高风险付款确认",
status,
defaultPaymentDate: "2026-05-15",
defaultPaymentChannel: "bank",
defaultPaymentChannel: paymentChannel,
voucherPrefix: "R7E",
totalAmount: "88.00",
highRisk,
paymentAccount: null,
paymentOrderExportCount,
canExportPaymentOrder,
paymentOrderExportBlockers: [],
reviewRequirement: {
thresholdAmount: "1000.00",
requiredReviewCount: 1,
@@ -158,6 +230,7 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
cancelledAt: null,
cancelledBy: null,
cancelledReason: null,
processingProgress: paymentProcessingProgress(status, hasReceipt, paymentChannel === "bank"),
items: [
{
id: "payment-batch-item-action-confirm",
@@ -172,7 +245,7 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
payeeName: "合成收款员工",
bankName: "合成银行",
bankAccountMasked: "6222********5678",
status: "pending",
status: status === "executed" ? "executed" : status === "failed" ? "failed" : status === "cancelled" ? "cancelled" : "pending",
failureCode: null,
failureMessage: null,
createdAt: "2026-05-15T08:00:00.000Z",
@@ -185,16 +258,21 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
async function mockSystemActionApis(
page: Page,
options: {
batchStatus?: "draft" | "reviewed" | "executed" | "cancelled";
batchStatus?: SyntheticPaymentBatchStatus;
hasReceipt?: boolean;
receiptQueryError?: boolean;
highRisk?: boolean;
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
paymentOrderExportCount?: number;
reimbursementStatus?: "draft" | "reviewing" | "paid";
meOverride?: Partial<typeof me>;
} = {},
) {
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
const calls = {
deleteReimbursement: 0,
deleteReimbursementAttachment: 0,
@@ -205,6 +283,10 @@ async function mockSystemActionApis(
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
submitReimbursement: 0,
withdrawReimbursement: 0,
remindWorkflow: 0,
withdrawComments: [] as string[],
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
@@ -288,7 +370,45 @@ async function mockSystemActionApis(
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail());
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus));
return;
}
if (path === `/api/reimbursements/${reimbursementId}/precheck` && request.method() === "POST") {
await fulfillJson(route, {
entityType: "reimbursement",
entityId: reimbursementId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["submit"],
});
return;
}
if (path === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
calls.submitReimbursement += 1;
currentReimbursementStatus = "reviewing";
await fulfillJson(route, { reimbursementId, status: "reviewing" });
return;
}
if (path === `/api/reimbursements/${reimbursementId}/withdraw` && request.method() === "POST") {
calls.withdrawReimbursement += 1;
const body = request.postDataJSON() as { comment?: string };
calls.withdrawComments.push(body.comment ?? "");
currentReimbursementStatus = "draft";
await fulfillJson(route, { reimbursementId, status: "draft" });
return;
}
if (path === "/api/workflow/tasks/workflow-task-action-confirm/remind" && request.method() === "POST") {
calls.remindWorkflow += 1;
await fulfillJson(route, { taskId: "workflow-task-action-confirm", remindedAt: "2026-05-15T09:00:00.000Z" });
return;
}
@@ -333,7 +453,14 @@ async function mockSystemActionApis(
}
if (path === "/api/payment-batches" && request.method() === "GET") {
const listItem = paymentBatch(batchStatus, options.highRisk);
const listItem = paymentBatch(
batchStatus,
options.highRisk,
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
);
await fulfillJson(route, {
items: [{
...listItem,
@@ -350,7 +477,16 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") {
calls.paymentBatchDetail += 1;
await fulfillJson(route, { item: paymentBatch(batchStatus, options.highRisk) });
await fulfillJson(route, {
item: paymentBatch(
batchStatus,
options.highRisk,
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
),
});
return;
}
@@ -412,7 +548,7 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") {
calls.executePaymentBatch += 1;
await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk) });
await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk, hasReceipt, options.paymentChannel) });
return;
}
@@ -455,6 +591,59 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect.poll(() => calls.exportReimbursements).toBe(1);
});
test("报销详情迁移后保留提交与删除的状态、确认和刷新合同", async ({ page }) => {
const calls = await mockSystemActionApis(page, {
reimbursementStatus: "draft",
meOverride: { permissions: [...me.permissions, "expense.submit"] },
});
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const actionPanel = page.getByTestId("reimbursement-action-panel");
await expect(actionPanel.getByRole("button", { name: "提交报销" })).toBeVisible();
await actionPanel.getByRole("button", { name: "提交报销" }).click();
await expect.poll(() => calls.submitReimbursement).toBe(1);
await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible();
await page.unrouteAll({ behavior: "wait" });
const deleteCalls = await mockSystemActionApis(page, { reimbursementStatus: "draft" });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-more-toggle").click();
await page.getByTestId("reimbursement-delete-draft").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
expect(deleteCalls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);
await expect(page).toHaveURL(/\/reimbursements\?status=draft/);
});
test("报销详情迁移后保留催办与撤回理由及刷新合同", async ({ page }) => {
const calls = await mockSystemActionApis(page, { reimbursementStatus: "reviewing" });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const actionPanel = page.getByTestId("reimbursement-action-panel");
await actionPanel.getByRole("button", { name: "催办审批" }).click();
await expect.poll(() => calls.remindWorkflow).toBe(1);
await page.getByTestId("reimbursement-withdraw-comment").fill("合成撤回原因");
await page.getByTestId("reimbursement-withdraw").click();
await expect.poll(() => calls.withdrawReimbursement).toBe(1);
expect(calls.withdrawComments).toEqual(["合成撤回原因"]);
await expect(page.getByText("草稿", { exact: true }).first()).toBeVisible();
});
test("报销详情迁移后已支付状态仍可进入打印页", async ({ page }) => {
await mockSystemActionApis(page, { reimbursementStatus: "paid" });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const printLink = page.getByTestId("reimbursement-action-panel").getByRole("link", { name: "打印 / PDF" });
await expect(printLink).toBeVisible();
await printLink.click();
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`));
});
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
const calls = await mockSystemActionApis(page);
page.on("dialog", (dialog) => {
@@ -478,12 +667,15 @@ test.describe("R7E 全站高风险操作二次确认", () => {
});
test("银行回单上传前后只显示一个付款主动作", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false });
const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(1);
const actionPanel = page.getByTestId("payment-batch-action-panel");
await expect(actionPanel.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await actionPanel.getByText("更多", { exact: true }).click();
await expect(actionPanel.getByRole("button", { name: "下载银行制单" })).toBeVisible();
await expect(page.getByTestId("payment-batch-mobile-actions")).toBeHidden();
await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
@@ -494,32 +686,102 @@ test.describe("R7E 全站高风险操作二次确认", () => {
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(page.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
});
test("移动端固定操作区复用同一动作模型并在上传后原地切换", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true });
await page.setViewportSize({ width: 390, height: 844 });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const mobileActions = page.getByTestId("payment-batch-mobile-actions");
await expect(mobileActions).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel")).toBeHidden();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await mobileActions.getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "下载银行制单" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "移动端合成银行回单.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmobile synthetic receipt\n%%EOF\n", "utf8"),
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(mobileActions.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
});
test("付款详情按处理顺序展示并统一人民币金额格式", async ({ page }) => {
await mockSystemActionApis(page, { hasReceipt: false, highRisk: true });
await page.setViewportSize({ width: 1024, height: 768 });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible();
const sectionOrder = await page.locator([
'[data-testid="payment-batch-detail-panel"]',
'[data-testid="payment-batch-current-processing"]',
'[data-testid="payment-batch-processing-panel"]',
'[data-testid="payment-batch-items-panel"]',
'[data-testid="payment-batch-receipt-panel"]',
].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(sectionOrder).toEqual([
"payment-batch-detail-panel",
"payment-batch-current-processing",
"payment-batch-processing-panel",
"payment-batch-items-panel",
"payment-batch-receipt-panel",
]);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("¥1,000.00");
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单");
await expect(page.getByTestId("payment-batch-processing-panel")).toContainText("等待财务线下付款并上传回单");
const responsiveContract = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const actionPanel = workspace.querySelector(".detail-action-panel");
return {
gridColumns: getComputedStyle(workspace).gridTemplateColumns,
actionPosition: actionPanel ? getComputedStyle(actionPanel).position : null,
};
});
expect(responsiveContract.gridColumns.split(" ").filter(Boolean)).toHaveLength(2);
expect(responsiveContract.actionPosition).toBe("sticky");
});
test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => {
const cases: Array<[SyntheticPaymentBatchStatus, string]> = [
["draft", "待复核"],
["reviewed", "待付款"],
["executed", "已付款"],
["failed", "付款失败"],
["cancelled", "已终止"],
];
for (const [status, expected] of cases) {
await page.unrouteAll({ behavior: "wait" });
await mockSystemActionApis(page, { batchStatus: status, hasReceipt: status === "executed" });
await page.context().clearCookies();
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const progress = page.getByTestId("payment-batch-processing-progress");
await expect(progress).toBeVisible();
await expect(progress).toContainText("批次创建");
await expect(progress).toContainText("付款复核");
await expect(progress).toContainText("付款完成");
await expect(progress).toContainText(expected);
}
});
test("非银行渠道直接等待登记完成且不引导回单或制单", async ({ page }) => {
await mockSystemActionApis(page, { batchStatus: "reviewed", hasReceipt: false, paymentChannel: "cash" });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("等待财务登记付款完成");
await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("银行制单");
await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("上传回单");
await expect(page.getByTestId("payment-batch-receipt-panel")).toContainText("当前付款渠道不要求银行回单");
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
});
test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => {
@@ -626,7 +888,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("制单人不能复核自己的批次")).toBeVisible();
await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("待非制单人复核付款");
await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0);
});