重构报销详情证据与动作区(reimbursement-detail-evidence-actions)
这个提交包含在:
@@ -3,7 +3,7 @@
|
||||
"name": "reimbursement-detail-evidence-actions",
|
||||
"title": "报销详情证据与动作区重构",
|
||||
"description": "按全页面设计规则重构报销详情:状态与下一步先行,证据材料居中,付款动作集中,右侧动作区降噪。",
|
||||
"status": "planning",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "frontend-page-redesign",
|
||||
"package": null,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
@@ -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> }>;
|
||||
|
||||
在新工单中引用
屏蔽一个用户