fix: 统一报销付款状态与进度(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -10,6 +10,7 @@ type Props = {
|
||||
testId: string;
|
||||
mobile?: boolean;
|
||||
targetLinkLabel?: string;
|
||||
suppressedTargetHref?: string | null;
|
||||
};
|
||||
|
||||
const STATE_TEXT = {
|
||||
@@ -26,6 +27,7 @@ export function ProcessingProgress({
|
||||
testId,
|
||||
mobile = false,
|
||||
targetLinkLabel = "查看详情",
|
||||
suppressedTargetHref,
|
||||
}: Props) {
|
||||
return (
|
||||
<ol className="space-y-0" data-testid={testId} aria-label={ariaLabel}>
|
||||
@@ -61,7 +63,7 @@ export function ProcessingProgress({
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className={cn("min-w-0 flex-1 pb-5", mobile ? "pt-1" : "pt-1.5")}>
|
||||
<div className={cn("min-w-0 flex-1 break-words pb-5", mobile ? "pt-1" : "pt-1.5")}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-semibold text-zinc-900">{stage.label}</span>
|
||||
{facts.length ? <span className="text-zinc-500">· {facts.join(" · ")}</span> : null}
|
||||
@@ -73,7 +75,7 @@ export function ProcessingProgress({
|
||||
{stage.comment}
|
||||
</blockquote>
|
||||
) : null}
|
||||
{stage.targetHref ? (
|
||||
{stage.targetHref && stage.targetHref !== suppressedTargetHref ? (
|
||||
<Link
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProcessingProgress } from "../types/processingProgress";
|
||||
|
||||
export function resolveReimbursementPaymentHandoff({
|
||||
status,
|
||||
deletedAt,
|
||||
progress,
|
||||
}: {
|
||||
status: string;
|
||||
deletedAt?: string | null;
|
||||
progress?: ProcessingProgress | null;
|
||||
}) {
|
||||
const unavailable = { nextStep: "付款进度暂不可用,请刷新后重试", canCreateBatch: false, targetHref: null as string | null };
|
||||
if (deletedAt || !["approved", "paid"].includes(status)) return null;
|
||||
const stages = progress?.stages ?? [];
|
||||
const created = stages.find((stage) => stage.key === "payment_batch_created");
|
||||
const review = stages.find((stage) => stage.key === "payment_batch_review");
|
||||
const payment = stages.find((stage) => stage.key === "payment_and_receipt");
|
||||
if (!created || !review || !payment) return unavailable;
|
||||
if (status === "paid") {
|
||||
return {
|
||||
...unavailable,
|
||||
nextStep: payment.state === "completed" ? payment.detail ?? "查看付款结果" : unavailable.nextStep,
|
||||
targetHref: payment.state === "completed" ? payment.targetHref : null,
|
||||
};
|
||||
}
|
||||
if (created.state === "current" && review.state === "pending" && payment.state === "pending"
|
||||
&& !created.targetHref && !review.targetHref && !payment.targetHref) {
|
||||
return { ...unavailable, nextStep: created.detail ?? "待财务组建付款批次", canCreateBatch: true };
|
||||
}
|
||||
if (created.state !== "completed") return unavailable;
|
||||
const current = [payment, review].find((stage) => stage.state === "current" || stage.state === "exception");
|
||||
if (!current || !current.detail) return unavailable;
|
||||
return { ...unavailable, nextStep: current.detail, targetHref: current.targetHref };
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { ReimbursementHeaderEditor } from "../components/reimbursements/Reimburs
|
||||
import { isTravelReimbursement } from "../lib/reimbursementTravel";
|
||||
import { ReimbursementMobileWorkspaceShell } from "../components/reimbursements/ReimbursementMobileWorkspaceShell";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { resolveReimbursementPaymentHandoff } from "../lib/reimbursementPaymentHandoff";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -191,6 +192,9 @@ export function MobileReimbursementDetailPage() {
|
||||
enabled: Boolean(reimbursementId),
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: (query) => Boolean(query.state.data && !["draft", "rejected"].includes(query.state.data.status)),
|
||||
});
|
||||
|
||||
const expenseTypesQuery = useQuery({
|
||||
@@ -907,6 +911,7 @@ export function MobileReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
|
||||
const paymentHandoff = resolveReimbursementPaymentHandoff({ ...detail, progress: detail.processingProgress });
|
||||
|
||||
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
|
||||
standardViolations: (reimbursementPrecheckQuery.data?.standardChecks ?? []).filter((item) => item.exceeded),
|
||||
@@ -978,7 +983,8 @@ export function MobileReimbursementDetailPage() {
|
||||
const workspaceAfterEvidence = (
|
||||
<>
|
||||
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
|
||||
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
|
||||
{paymentHandoff ? <section className="mobile-card space-y-3" data-testid="mobile-reimbursement-payment-actions"><p className="text-sm">下一步:{paymentHandoff.nextStep}</p>{paymentHandoff.targetHref ? <Button asChild className="w-full"><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button> : null}</section> : null}
|
||||
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" suppressedTargetHref={paymentHandoff?.targetHref} targetLinkLabel="查看所在付款批次" mobile /></section>
|
||||
{canDeleteDraft ? <section className="mobile-card"><details><summary className="cursor-pointer py-3 text-sm" data-testid="mobile-reimbursement-more">更多</summary><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button></details></section> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ import { ReimbursementTravelSummary } from "../components/reimbursements/Reimbur
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { resolveReimbursementPaymentHandoff } from "../lib/reimbursementPaymentHandoff";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -150,6 +151,9 @@ export function ReimbursementDetailPage() {
|
||||
enabled: Boolean(reimbursementId),
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: (query) => Boolean(query.state.data && !["draft", "rejected"].includes(query.state.data.status)),
|
||||
});
|
||||
|
||||
const expenseTypesQuery = useQuery({
|
||||
@@ -936,6 +940,7 @@ export function ReimbursementDetailPage() {
|
||||
const invoiceCount = detail.invoices.length;
|
||||
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
||||
const processingProgress = detail.processingProgress ?? { stages: [] };
|
||||
const paymentHandoff = resolveReimbursementPaymentHandoff({ ...detail, progress: detail.processingProgress });
|
||||
const currentProcessingStage = processingProgress.stages.find((stage) =>
|
||||
stage.state === "current" || stage.state === "exception",
|
||||
);
|
||||
@@ -959,12 +964,7 @@ export function ReimbursementDetailPage() {
|
||||
if (detail.status === "reviewing") {
|
||||
return currentProcessingStage?.detail ?? "查看处理进度";
|
||||
}
|
||||
if (detail.status === "approved") {
|
||||
return "等待财务组建付款批次";
|
||||
}
|
||||
if (detail.status === "paid") {
|
||||
return "查看付款结果";
|
||||
}
|
||||
if (paymentHandoff) return paymentHandoff.nextStep;
|
||||
return "查看记录";
|
||||
})();
|
||||
const primaryAction = (() => {
|
||||
@@ -1005,7 +1005,11 @@ export function ReimbursementDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.status === "approved" && canManagePaymentBatch) {
|
||||
if (paymentHandoff?.targetHref) {
|
||||
return <Button asChild><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button>;
|
||||
}
|
||||
|
||||
if (paymentHandoff?.canCreateBatch && canManagePaymentBatch) {
|
||||
return (
|
||||
<Button asChild data-testid="reimbursement-open-pending-payment-batches">
|
||||
<Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}>
|
||||
@@ -1105,11 +1109,10 @@ export function ReimbursementDetailPage() {
|
||||
const workspaceAfterEvidence = (
|
||||
<>
|
||||
{(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <SectionPanel title="需要关注">{budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</SectionPanel> : null}
|
||||
<SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" /></SectionPanel>
|
||||
</>
|
||||
);
|
||||
const workspaceActionPanel = (
|
||||
<BusinessDetailActionPanel status={<StatusBadge status={detail.deletedAt ? "deleted" : 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} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
||||
<BusinessDetailActionPanel className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : 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} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
||||
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} 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>
|
||||
);
|
||||
@@ -1117,7 +1120,7 @@ export function ReimbursementDetailPage() {
|
||||
return (
|
||||
<>
|
||||
{downloadReasonDialog}
|
||||
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
|
||||
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel, asideContext: <SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" suppressedTargetHref={paymentHandoff?.targetHref} /></SectionPanel> }} />
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ReimbursementReturnReferencePicker } from "../../components/reimburseme
|
||||
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
|
||||
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
|
||||
import { SectionPanel } from "../../components/patterns/SectionPanel";
|
||||
import { ProcessingProgress } from "../../components/patterns/ProcessingProgress";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
@@ -96,6 +97,9 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
enabled: Boolean(reimbursementId) && online,
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: detail.task.status !== "pending",
|
||||
});
|
||||
const snapshot = detail.workflowInstance?.contextData ?? null;
|
||||
const evidence = useMemo(() => {
|
||||
@@ -314,6 +318,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
const processingProgress = (
|
||||
<SectionPanel title="处理进度">
|
||||
<ProcessingProgress progress={reimbursementDetailQuery.data?.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="reimbursement-approval-processing-progress" targetLinkLabel="查看所在付款批次" mobile={layout === "mobile"} />
|
||||
</SectionPanel>
|
||||
);
|
||||
const hasMaterials = model.evidence.items.some((item) => item.invoices.length > 0)
|
||||
|| model.evidence.unlinkedInvoices.length > 0
|
||||
|| model.evidence.attachments.length > 0;
|
||||
@@ -351,19 +360,19 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
);
|
||||
|
||||
if (layout === "mobile") {
|
||||
const mobileActions = detail.task.status === "pending" ? (
|
||||
<>
|
||||
const mobileDecisionContext = detail.task.status === "pending" ? (
|
||||
<section className="mobile-card space-y-3">
|
||||
{offlineNotice}
|
||||
{issueAnchors}
|
||||
{commentEditor}
|
||||
{canTransfer || !online ? <Button className="min-h-11 w-full" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
|
||||
</section>
|
||||
) : null;
|
||||
const mobileActions = detail.task.status === "pending" ? (
|
||||
<MobileStickyActionBar contentClassName="grid-cols-2" testId="mobile-approvals-sticky-actions">
|
||||
{allowedActions.has("return") || !online ? <Button className="min-h-12 w-full" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回修改</Button> : null}
|
||||
<Button className="min-h-12 w-full" data-testid="mobile-approvals-approve" disabled={actionDisabled || !allowedActions.has("approve")} onClick={() => requestAction("approve")} type="button">通过</Button>
|
||||
</MobileStickyActionBar>
|
||||
</>
|
||||
) : null;
|
||||
return (
|
||||
<>
|
||||
@@ -375,7 +384,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
status: <StatusBadge label={workflowTaskStatusLabel(detail.task.status)} status={detail.task.status} />,
|
||||
beforeEvidence: detail.task.status !== "pending" ? <p className="mobile-card text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
|
||||
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="mobile-card text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
|
||||
timeline,
|
||||
timeline: <>{mobileDecisionContext}{processingProgress}{timeline}</>,
|
||||
mobileActions,
|
||||
}}
|
||||
/>
|
||||
@@ -386,6 +395,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
|
||||
const actionPanel = (
|
||||
<BusinessDetailActionPanel
|
||||
className="lg:static"
|
||||
title="审批决定"
|
||||
beforeActions={actionPanelBeforeActions}
|
||||
primaryAction={allowedActions.has("approve") || allowedActions.has("return") ? (
|
||||
@@ -413,7 +423,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
beforeEvidence: detail.task.status !== "pending" ? <p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
|
||||
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
|
||||
actionPanel,
|
||||
asideContext: timeline,
|
||||
asideContext: <>{processingProgress}{timeline}</>,
|
||||
}}
|
||||
/>
|
||||
{dialogs}
|
||||
|
||||
@@ -126,8 +126,9 @@ test("PC 审批历史只进入右栏且移动历史保持正文末尾", () => {
|
||||
expect(desktopShell).not.toContain("slots.timeline");
|
||||
expect(desktopShell.indexOf("slots.actionPanel")).toBeLessThan(desktopShell.indexOf("slots.asideContext"));
|
||||
expect(mobileShell.indexOf("slots.afterEvidence")).toBeLessThan(mobileShell.indexOf("slots.timeline"));
|
||||
expect(approvalAdapter).toContain("asideContext: timeline");
|
||||
expect(approvalAdapter.match(/^\s+timeline,$/gm)).toHaveLength(1);
|
||||
expect(approvalAdapter).toContain("asideContext: <>{processingProgress}{timeline}</>");
|
||||
expect(approvalAdapter).toContain("timeline: <>{mobileDecisionContext}{processingProgress}{timeline}</>");
|
||||
expect(approvalAdapter).toContain('className="lg:static"');
|
||||
});
|
||||
|
||||
test("申请人进度与审批历史保持有意分工", () => {
|
||||
@@ -140,7 +141,7 @@ test("申请人进度与审批历史保持有意分工", () => {
|
||||
expect(desktopDetail.match(/targetLinkLabel="查看所在付款批次"/g)).toHaveLength(1);
|
||||
expect(mobileDetail.match(/targetLinkLabel="查看所在付款批次"/g)).toHaveLength(1);
|
||||
expect(approvalAdapter).toContain('data-testid="reimbursement-workflow-history"');
|
||||
expect(approvalAdapter).not.toContain("<ProcessingProgress");
|
||||
expect(approvalAdapter.match(/<ProcessingProgress /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("待组批工作台保持单一候选源且报销详情使用可刷新深链", () => {
|
||||
@@ -154,7 +155,7 @@ test("待组批工作台保持单一候选源且报销详情使用可刷新深
|
||||
expect(paymentPage).toContain("candidatesQuery.data?.total");
|
||||
expect(reimbursementDetail).toContain('hasPermission(permissions, "payment.batch.manage")');
|
||||
expect(reimbursementDetail).toContain("/payment-batches?view=pending&q=");
|
||||
expect(reimbursementDetail).toContain("等待财务组建付款批次");
|
||||
expect(reimbursementDetail).toContain("paymentHandoff?.canCreateBatch && canManagePaymentBatch");
|
||||
});
|
||||
|
||||
test("票据申报事实与系统金额证据状态分属正文和处理上下文", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { login } from "./helpers/auth";
|
||||
import type { ProcessingProgress } from "../../src/types/processingProgress";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
const loadServerModule = createRequire(import.meta.url);
|
||||
@@ -103,7 +104,7 @@ function reimbursementListItem() {
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft", isTravel = false) {
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "approved" | "paid" = "draft", isTravel = false) {
|
||||
const workflowTaskId = "workflow-task-action-confirm";
|
||||
return {
|
||||
...reimbursementListItem(),
|
||||
@@ -309,7 +310,8 @@ async function mockSystemActionApis(
|
||||
paymentOrderFile?: Buffer;
|
||||
paymentOrderError?: boolean;
|
||||
paymentEntityType?: "reimbursement" | "travel_order";
|
||||
reimbursementStatus?: "draft" | "reviewing" | "paid";
|
||||
reimbursementStatus?: "draft" | "reviewing" | "approved" | "paid";
|
||||
reimbursementProgress?: () => ProcessingProgress;
|
||||
travelReimbursement?: boolean;
|
||||
meOverride?: Partial<typeof me>;
|
||||
} = {},
|
||||
@@ -419,7 +421,10 @@ async function mockSystemActionApis(
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus, options.travelReimbursement));
|
||||
await fulfillJson(route, {
|
||||
...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement),
|
||||
...(options.reimbursementProgress ? { processingProgress: options.reimbursementProgress() } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,6 +651,100 @@ async function mockSystemActionApis(
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.describe("报销付款交接一致性", () => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅合成数据,不访问公网");
|
||||
|
||||
function progress(status: SyntheticPaymentBatchStatus | "none" | "unknown", canRead = true, exported = 1): ProcessingProgress {
|
||||
if (status === "unknown") return { stages: [] };
|
||||
const { buildPaymentProcessingSemantics } = loadServerModule("../../../server/src/services/paymentProcessingProgress");
|
||||
const semantics = buildPaymentProcessingSemantics({ status, requiresBankReceipt: true, paymentOrderExportCount: exported });
|
||||
const stages = Object.values(semantics) as ProcessingProgress["stages"];
|
||||
for (const stage of stages) Object.assign(stage, { actorName: null, targetHref: null });
|
||||
if (status === "none") {
|
||||
Object.assign(stages[0], { state: "current", result: "待组批", detail: "待财务组建付款批次" });
|
||||
} else if (canRead) {
|
||||
stages[status === "draft" ? 1 : 2].targetHref = `/payment-batches?id=${paymentBatchId}`;
|
||||
}
|
||||
return { stages };
|
||||
}
|
||||
|
||||
for (const mobile of [false, true]) {
|
||||
test(`${mobile ? "手机" : "桌面"}已复核制单后返回报销不再组批且进度跟随操作`, async ({ page }) => {
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
|
||||
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") });
|
||||
await login(page, user);
|
||||
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
|
||||
const actions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
|
||||
await expect(actions).toContainText("已生成银行制单,等待付款完成并上传回单");
|
||||
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
|
||||
const link = page.getByRole("link", { name: "查看所在付款批次", exact: true });
|
||||
await expect(link).toHaveCount(1);
|
||||
await link.click();
|
||||
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(actions).toContainText("已生成银行制单");
|
||||
const timeline = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
|
||||
await expect(timeline).toHaveCount(1);
|
||||
const panel = actions.locator("..");
|
||||
expect(await panel.evaluate((element) => getComputedStyle(element).position)).not.toBe("sticky");
|
||||
const actionBounds = await actions.boundingBox();
|
||||
const progressBounds = await timeline.boundingBox();
|
||||
expect(progressBounds!.y).toBeGreaterThan(actionBounds!.y + actionBounds!.height);
|
||||
if (!mobile) expect(Math.abs(progressBounds!.x - actionBounds!.x)).toBeLessThan(35);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [status, expected] of [
|
||||
["none", "待财务组建付款批次"], ["draft", "待非制单人复核付款"],
|
||||
["reviewed", "已生成银行制单"], ["failed", "付款登记失败"],
|
||||
["cancelled", "付款安排已取消"], ["unknown", "付款进度暂不可用"],
|
||||
] as const) {
|
||||
test(`${status}阶段不因无批次读取权限而误判待组批`, async ({ page }) => {
|
||||
await mockSystemActionApis(page, {
|
||||
reimbursementStatus: "approved", reimbursementProgress: () => progress(status, false),
|
||||
meOverride: { roleCodes: ["employee"], permissions: ["expense.view_own"] },
|
||||
});
|
||||
await login(page, user);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText(expected);
|
||||
await expect(page.getByRole("link", { name: /付款批次/ })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("重新聚焦后不保留旧的组批动作", async ({ page }) => {
|
||||
let current: SyntheticPaymentBatchStatus | "none" = "none";
|
||||
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress(current) });
|
||||
await login(page, user);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toBeVisible();
|
||||
current = "reviewed";
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
});
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("已生成银行制单");
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("已付款只保留结果入口,进度未知不让财务猜测组批", async ({ page }) => {
|
||||
await mockSystemActionApis(page, { reimbursementStatus: "paid", reimbursementProgress: () => progress("executed") });
|
||||
await login(page, user);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款已登记完成");
|
||||
await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
|
||||
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, { ...reimbursementDetail("approved"), processingProgress: { stages: [] } }));
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款进度暂不可用");
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
test("报销删除和敏感导出必须确认后才调用接口", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
|
||||
在新工单中引用
屏蔽一个用户