feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区
这个提交包含在:
@@ -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 } = {}) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+20
-11
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户