重构报销详情证据与动作区(reimbursement-detail-evidence-actions)

这个提交包含在:
2026-05-30 03:29:07 +08:00
父节点 65a8f64b92
当前提交 cb03f2b63d
共修改 9 个文件,包含 434 行新增和 446 行删除
+305 -324
查看文件
@@ -14,7 +14,6 @@ import {
deleteReimbursementItem,
downloadReimbursementAttachment,
fetchReimbursementDetail,
payReimbursement,
precheckReimbursement,
submitReimbursement,
updateReimbursementItem,
@@ -53,8 +52,6 @@ import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
@@ -69,7 +66,7 @@ import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
type ItemEditorInitialValues,
@@ -162,6 +159,15 @@ function materialCompletenessLabel(count: number, noun: string) {
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
}
function paymentChannelLabel(value: string | null | undefined) {
if (value === "bank") return "银行转账";
if (value === "alipay") return "支付宝";
if (value === "wechat") return "微信";
if (value === "cash") return "现金";
if (value === "other") return "其他";
return value || "-";
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -265,7 +271,7 @@ export function ReimbursementDetailPage() {
}, [bindableInvoicesQuery.data?.items]);
const mutation = useMutation({
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string }) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
@@ -277,13 +283,6 @@ export function ReimbursementDetailPage() {
if (params.action === "withdraw") {
return withdrawReimbursement(reimbursementId, params.comment);
}
return payReimbursement(reimbursementId, {
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
paymentDate: params.payload.paymentDate || undefined,
paymentChannel: params.payload.paymentChannel,
paymentNote: params.payload.paymentNote || undefined,
});
},
onSuccess: async (_data, params) => {
await Promise.all([
@@ -295,7 +294,7 @@ export function ReimbursementDetailPage() {
if (params.action === "submit") {
setSubmitConfirmPrecheck(null);
}
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回" };
toast.success(actionMessages[params.action] ?? "操作成功");
},
onError: (error) => {
@@ -991,7 +990,6 @@ export function ReimbursementDetailPage() {
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const attachmentCount = (detail.attachments ?? []).length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
const currentHandlerText = (() => {
if (detail.status === "reviewing") {
@@ -1027,7 +1025,7 @@ export function ReimbursementDetailPage() {
: "查看审批进度";
}
if (detail.status === "approved") {
return canPay ? "执行付款" : "等待财务付款";
return "等待财务处理";
}
if (detail.status === "paid") {
return "查看付款结果";
@@ -1083,9 +1081,7 @@ export function ReimbursementDetailPage() {
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href={canPay ? "#reimbursement-action-panel" : "#reimbursement-payment-panel"}>
{canPay ? "执行支付" : "看付款状态"}
</a>
<a href="#reimbursement-payment-panel">看付款状态</a>
</Button>
);
}
@@ -1237,10 +1233,10 @@ export function ReimbursementDetailPage() {
<div className="detail-fact-card__label">付款凭证号</div>
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{payment.paymentChannel ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{paymentChannelLabel(payment.paymentChannel)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
@@ -1251,54 +1247,44 @@ export function ReimbursementDetailPage() {
</div>
) : null}
{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}
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
{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">
<div>
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
<p className="text-xs text-zinc-500">低频操作集中在这里,材料和关联事项已放在主内容区。</p>
</div>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
<Link to="/reimbursements">报销列表</Link>
</Button>
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
</div>
</div>
</div>
</div>
) : null}
</SectionPanel>
<StatStrip columns={4} className="ops-status-strip">
<MetricCard
title="关联项目"
value={
<span className="text-base">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</span>
}
/>
<MetricCard title="明细数" value={detail.items.length} />
<MetricCard title="发票" value={detail.invoices.length} />
<MetricCard
title="付款状态"
value={<span className="text-base">{isPaidReimbursement ? "已付款" : "未付款"}</span>}
/>
</StatStrip>
<div className="detail-command-panel" data-testid="reimbursement-secondary-summary">
<div className="detail-fact-grid">
<SectionPanel title="业务摘要">
<div className="detail-command-panel" data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销编号</div>
<div className="detail-fact-card__value">{displayCode}</div>
@@ -1312,195 +1298,17 @@ export function ReimbursementDetailPage() {
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">说明</div>
<div className="detail-fact-card__value">{detail.description || "-"}</div>
</div>
</div>
</div>
<div className="detail-command-panel" data-testid="reimbursement-secondary-payment-status">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
<p className="text-xs text-zinc-500">凭证、日期和操作人用于财务台账核对。</p>
</div>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
detail.status === "paid"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
>
{detail.status === "paid" ? "已付款" : "未付款"}
</span>
</div>
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款日期</div>
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款凭证号</div>
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">付款渠道</div>
<div className="detail-fact-card__value">{payment.paymentChannel ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__label">关联项目</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</div>
</div>
</div>
</div>
{detail.linkedTravelRequest ? (
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
</h4>
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
</div>
<Link
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
className="text-sm text-primary hover:underline"
>
查看申请
</Link>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">日期范围</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">预估金额</div>
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">申请编号</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
</div>
</div>
</div>
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{detail.description || "未填写报销说明"}
</div>
) : null}
<div className="detail-command-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
<p className="text-xs text-zinc-500">用于项目执行面板汇总。</p>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
<select
className="select-std mt-4 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
</div>
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">补充附件</h3>
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;敏感附件下载会要求填写用途。</p>
</div>
{canEditItems ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<label
htmlFor="reimbursement-attachment-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedAttachmentFiles.length
? selectedAttachmentFiles.length === 1
? selectedAttachmentFiles[0].name
: `已选择 ${selectedAttachmentFiles.length} 个文件`
: "选择附件"}
</label>
<input
id="reimbursement-attachment-upload"
className="hidden"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
/>
<Button
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
</div>
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={openAttachmentMutation.isPending}
onClick={() => void handleOpenAttachment(attachment)}
>
查看
</Button>
{canEditItems ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无附件材料</p>
)}
</div>
</div>
) : null}
</SectionPanel>
</SectionPanel>
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
<div className="space-y-4">
@@ -1736,10 +1544,169 @@ export function ReimbursementDetailPage() {
)}
</div>
</div>
</SectionPanel>
</SectionPanel>
<SectionPanel title="预算与审核提示">
<div className="space-y-4">
<SectionPanel title="附件材料" description="订单、付款截图、情况说明等补充材料;PDF 发票仍在发票区处理。">
<div className="space-y-4 text-sm" data-testid="reimbursement-attachments">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">补充附件</h3>
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;敏感附件下载会要求填写用途。</p>
</div>
{canEditItems ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<label
htmlFor="reimbursement-attachment-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedAttachmentFiles.length
? selectedAttachmentFiles.length === 1
? selectedAttachmentFiles[0].name
: `已选择 ${selectedAttachmentFiles.length} 个文件`
: "选择附件"}
</label>
<input
id="reimbursement-attachment-upload"
className="hidden"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
/>
<Button
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
</div>
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={openAttachmentMutation.isPending}
onClick={() => void handleOpenAttachment(attachment)}
>
查看
</Button>
{canEditItems ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无附件材料</p>
)}
</div>
</SectionPanel>
<SectionPanel title="关联事项" description="只显示项目、差旅等关联摘要;具体流程回到对应模块处理。">
<div className="space-y-4" data-testid="reimbursement-related-records">
<div className="detail-command-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
<p className="text-xs text-zinc-500">用于项目执行面板汇总,不在报销详情复制项目流程。</p>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
查看项目
</Link>
) : null}
</div>
{canEditItems ? (
<select
className="select-std mt-4 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
) : (
<div className="mt-3 text-sm text-zinc-600">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
</div>
)}
</div>
{detail.linkedTravelRequest ? (
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
</h4>
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
</div>
<Link
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
className="text-sm text-primary hover:underline"
>
查看申请
</Link>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">日期范围</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">预估金额</div>
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">申请编号</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
未关联差旅申请。
</div>
)}
</div>
</SectionPanel>
<SectionPanel title="预算与审核提示">
<div className="space-y-4">
{budgetHint ? (
<div className="detail-command-panel text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
@@ -1831,85 +1798,100 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
) : null}
</div>
</SectionPanel>
</div>
</div>
</SectionPanel>
<div className="space-y-5">
{showSecondaryDetail ? (
<DetailSideMeta title="报销摘要">
<div>报销编号:{displayCode}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
<div>明细数:{detail.items.length}</div>
<div>票据数:{detail.invoices.length}</div>
<div>附件数:{attachmentCount}</div>
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
) : null}
{detail.status === "reviewing" && detail.workflowProgress ? (
<div id="reimbursement-workflow-progress-panel">
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
<SectionPanel title="审批与处理记录" description="审批进度和历史只用于追踪,不在这里复制审批或付款动作。">
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
{detail.workflowProgress ? (
<div className="detail-command-panel">
<div className="mb-3 text-sm text-zinc-500">
当前位于第 {detail.workflowProgress.currentStepIndex + 1} / {detail.workflowProgress.totalSteps} 步。
</div>
))}
</div>
</DetailSideMeta>
</div>
) : null}
{showSecondaryDetail ? (
<DetailSideMeta title="审批记录">
{detail.approvalHistory.length ? (
<div className="detail-timeline" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "detail-timeline-item detail-timeline-item--current"
: step.status === "completed"
? "detail-timeline-item detail-timeline-item--done"
: "detail-timeline-item"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
))}
</div>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
当前没有进行中的审批节点。
</div>
)}
<div className="detail-command-panel">
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
{detail.approvalHistory.length ? (
<div className="detail-timeline mt-3" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
</div>
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
</div>
))}
</div>
) : (
<div className="mt-3 text-sm text-zinc-500">暂无审批记录。</div>
)}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</div>
</SectionPanel>
</div>
<div className="space-y-5">
<DetailSideMeta title="当前可做">
{["draft", "rejected"].includes(detail.status) ? (
<div>补齐明细和 PDF 发票后提交审批;附件作为补充材料放在主内容区。</div>
) : null}
{detail.status === "reviewing" ? (
<div>审批动作只在“待我处理”或任务详情执行;如需修改,可撤回后补正。</div>
) : null}
{detail.status === "approved" ? (
<div className="space-y-3">
<div>本页只显示付款状态;付款登记、批量付款和回单处理请进入付款上下文。</div>
{canPay ? (
<Button asChild variant="secondary" size="sm">
<Link to="/payment-batches">进入付款批次</Link>
</Button>
) : null}
</div>
) : null}
{detail.status === "paid" ? (
<div>已付款报销用于查证据、看回单状态和打印归档,不再编辑或重复付款。</div>
) : null}
{detail.status === "cancelled" ? (
<div>该报销已取消,仅保留查看和归档记录。</div>
) : null}
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
canPay={canPay}
showSubmitAction={false}
showPayAction={detail.status === "approved"}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
@@ -1917,7 +1899,6 @@ export function ReimbursementDetailPage() {
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
/>
</div>
</div>
@@ -1,63 +1,38 @@
import { useState } from "react";
import type { ReimbursementStatus } from "../../api/reimbursements";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
function todayDateString() {
return new Date().toISOString().slice(0, 10);
}
export type PayPayload = {
paymentVoucherNo: string;
paymentDate: string;
paymentChannel: PaymentChannel;
paymentNote: string;
};
export interface ReimbursementActionsProps {
status: ReimbursementStatus;
canPay: boolean;
actionError: string;
submitDisabled?: boolean;
submitHint?: string;
submitNotice?: string;
showSubmitAction?: boolean;
showWithdrawAction?: boolean;
showPayAction?: boolean;
isPending: boolean;
onSubmit: () => void;
onWithdraw: (comment: string) => void;
onPay: (payload: PayPayload) => void;
}
export function ReimbursementActions({
status,
canPay,
actionError,
submitDisabled = false,
submitHint = "",
submitNotice = "",
showSubmitAction = true,
showWithdrawAction = true,
showPayAction = true,
isPending,
onSubmit,
onWithdraw,
onPay,
}: ReimbursementActionsProps) {
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
const [paymentDate, setPaymentDate] = useState(todayDateString);
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
const [paymentNote, setPaymentNote] = useState("");
const canShowSubmit = showSubmitAction && ["draft", "rejected"].includes(status);
const canShowWithdraw = showWithdrawAction && status === "reviewing";
const canShowPay = showPayAction && canPay && status === "approved";
if (!canShowSubmit && !canShowWithdraw && !canShowPay && !actionError && !submitHint && !submitNotice) {
if (!canShowSubmit && !canShowWithdraw && !actionError && !submitHint && !submitNotice) {
return null;
}
@@ -113,61 +88,6 @@ export function ReimbursementActions({
</Button>
</div>
) : null}
{canShowPay ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">财务支付</p>
<Input
data-testid="reimbursement-pay-voucher-no"
onChange={(event) => setPaymentVoucherNo(event.target.value)}
placeholder="付款凭证号"
value={paymentVoucherNo}
/>
<Input
data-testid="reimbursement-pay-date"
onChange={(event) => setPaymentDate(event.target.value)}
type="date"
value={paymentDate}
/>
<select
className="select-std"
data-testid="reimbursement-pay-channel"
onChange={(event) =>
setPaymentChannel(event.target.value as PaymentChannel)
}
value={paymentChannel}
>
<option value="bank">银行转账</option>
<option value="alipay">支付宝</option>
<option value="wechat">微信</option>
<option value="cash">现金</option>
<option value="other">其他</option>
</select>
<Textarea
data-testid="reimbursement-pay-note"
onChange={(event) => setPaymentNote(event.target.value)}
placeholder="付款备注(可选,用于审计留痕)"
rows={2}
value={paymentNote}
/>
<Button
data-testid="reimbursement-pay"
variant="secondary"
disabled={isPending}
onClick={() =>
onPay({
paymentVoucherNo: paymentVoucherNo.trim(),
paymentDate,
paymentChannel,
paymentNote: paymentNote.trim(),
})
}
type="button"
>
执行支付
</Button>
</div>
) : null}
</div>
);
}
+40
查看文件
@@ -0,0 +1,40 @@
import type { APIRequestContext } from "@playwright/test";
import { apiGetData } from "./auth";
type FeatureFlagItem = {
flagKey: string;
overrideMode: "inherit" | "force_on" | "force_off";
rolloutType: "global" | "department_allowlist";
departmentIds: string[];
note: string;
version: number;
effectiveEnabled: boolean;
};
export async function ensureFeatureFlagOn(
api: APIRequestContext,
flagKey: string,
note = "playwright feature flag setup",
) {
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
const item = list.items.find((flag) => flag.flagKey === flagKey);
if (!item) {
throw new Error(`Missing feature flag: ${flagKey}`);
}
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
return;
}
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
data: {
overrideMode: "force_on",
rolloutType: "global",
departmentIds: [],
note,
expectedVersion: item.version,
},
});
if (!response.ok()) {
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
}
}
+74 -5
查看文件
@@ -1,11 +1,16 @@
import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
test.setTimeout(240_000);
test.setTimeout(300_000);
const adminApi = await createAuthenticatedApiClient(ADMIN);
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright main flow");
await adminApi.dispose();
// 1) employee: create reimbursement
await login(page, EMPLOYEE);
@@ -86,7 +91,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await logout(page, MANAGER.name);
// 6) finance approve + pay
// 6) finance approve, then pay from the payment batch context
await login(page, FINANCE);
await (await openNavLink(page, "待我处理")).click();
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
@@ -99,9 +104,73 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`);
await reimbursementDetail.payBtn.click();
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
const paymentDate = new Date().toISOString().slice(0, 10);
const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches");
const createCard = page.getByTestId("payment-batch-create-panel");
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-entity-type").selectOption("reimbursement");
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-create-entity-ids").fill(reimbursementId);
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate);
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank");
await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("E2EPB");
await createCard.getByRole("button", { name: "创建批次" }).click();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string } } };
const batchId = createBatchPayload.data?.item?.id;
if (!batchId) {
throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
await logout(page, FINANCE.name);
await login(page, FINANCE_MGR);
await page.goto("/ap-operations");
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
await expect(pendingRow).toBeVisible();
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
await expect(page.getByRole("dialog")).toContainText(batchName);
await page.getByPlaceholder("请输入复核意见").fill("E2E main flow payment review");
await page.getByRole("button", { name: "提交复核" }).click();
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, FINANCE_MGR.name);
await login(page, FINANCE);
await page.goto(`/payment-batches?id=${batchId}`);
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(executedBatchDetailCard).toBeVisible();
await expect(executedBatchDetailCard).toContainText("1/1");
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible();
await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "e2e-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok();
});
await receiptCard.getByRole("button", { name: "上传" }).click();
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("e2e-bank-receipt.pdf");
await executedBatchDetailCard.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 });
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
await logout(page, FINANCE.name);
@@ -1066,6 +1066,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0);
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
await page.goto("/workflow/tasks/task-wave3");
@@ -339,9 +339,14 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
await expect(page.getByText("报销ID")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-business-summary")).toBeVisible();
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
await expect(page.getByText("执行支付")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
@@ -522,6 +522,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
await expect(page.getByText("预算与审核提示")).toBeVisible();
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
await expect(page.getByText("ERP")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click();
@@ -10,42 +10,10 @@ import {
logout,
MANAGER,
} from "./helpers/auth";
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds";
type FeatureFlagItem = {
flagKey: string;
overrideMode: "inherit" | "force_on" | "force_off";
rolloutType: "global" | "department_allowlist";
departmentIds: string[];
note: string;
version: number;
effectiveEnabled: boolean;
};
async function ensureFeatureFlagOn(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], flagKey: string) {
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
const item = list.items.find((flag) => flag.flagKey === flagKey);
if (!item) {
throw new Error(`Missing feature flag: ${flagKey}`);
}
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
return;
}
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
data: {
overrideMode: "force_on",
rolloutType: "global",
departmentIds: [],
note: "playwright reimbursement payment batch flow",
expectedVersion: item.version,
},
});
if (!response.ok()) {
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
}
}
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
page,
reimbursementDetail,
@@ -59,8 +27,8 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
const paymentDate = new Date().toISOString().slice(0, 10);
try {
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1");
await ensureFeatureFlagOn(adminApi.api, "erp_sync");
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow");
await ensureFeatureFlagOn(adminApi.api, "erp_sync", "playwright reimbursement payment batch flow");
await login(page, EMPLOYEE);
@@ -220,6 +188,8 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
expect(reimbursementAfterPayment.status).toBe("paid");
await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
await expect(page.getByText("执行支付")).toHaveCount(0);
const paymentAudit = await apiGetData<{
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;