收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 00:29:47 +08:00
父节点 78b80f88d7
当前提交 7c39f8b0f9
共修改 17 个文件,包含 591 行新增和 227 行删除
+8
查看文件
@@ -173,6 +173,14 @@ export function mapApiErrorMessage(
return "当前批次状态不允许继续复核。";
}
if (details?.review === "no_eligible_reviewer") {
return "当前没有可复核人(复核人必须是非制单人且在职),请联系管理员。";
}
if (details?.receiptAttachments === "bank_receipt_required") {
return "请先上传银行回单,再登记付款完成。";
}
if (details?.transfer === "chain_limit_exceeded") {
return "当前任务转审次数已达上限。";
}
+1 -1
查看文件
@@ -123,7 +123,7 @@ export async function fetchPaymentBatchCandidates(query: { q?: string; page?: nu
export async function createPaymentBatch(payload: {
entityType: PaymentBatchEntityType;
name: string;
name?: string;
entityIds: string[];
defaultPaymentDate?: string;
defaultPaymentChannel?: PaymentChannel;
+14
查看文件
@@ -0,0 +1,14 @@
import type { PaymentBatchStatus } from "../api/paymentBatches";
const PAYMENT_BATCH_STATUS_LABELS: Record<PaymentBatchStatus, string> = {
draft: "待付款复核",
reviewed: "待财务付款",
executed: "已付款",
failed: "付款登记失败",
cancelled: "已取消",
};
export function paymentBatchStatusLabel(value: PaymentBatchStatus | string | null | undefined) {
if (!value) return "-";
return PAYMENT_BATCH_STATUS_LABELS[value as PaymentBatchStatus] ?? value;
}
+2 -10
查看文件
@@ -65,6 +65,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { downloadBlob } from "../lib/download";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation";
type RiskActionType = "resolve" | "dismiss";
@@ -138,15 +139,6 @@ function entityTypeLabel(value: string) {
return value || "-";
}
function batchStatusLabel(value: string) {
if (value === "draft") return "草稿";
if (value === "reviewed") return "已复核";
if (value === "executed") return "已执行";
if (value === "failed") return "执行失败";
if (value === "cancelled") return "已取消";
return value || "-";
}
function reimbursementStatusLabel(value: string) {
if (value === "draft") return "草稿";
if (value === "reviewing") return "审批中";
@@ -961,7 +953,7 @@ export function ApOperationsPage() {
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}
>
{batchStatusLabel(item.status)}
{paymentBatchStatusLabel(item.status)}
</span>
{item.highRisk ? (
<span className="inline-flex rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
+78 -119
查看文件
@@ -47,19 +47,10 @@ import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
import { downloadBlob } from "../lib/download";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function batchStatusLabel(value: string) {
if (value === "draft") return "草稿";
if (value === "reviewed") return "已复核";
if (value === "executed") return "已执行";
if (value === "failed") return "执行失败";
if (value === "cancelled") return "已取消";
return value || "-";
}
function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销";
@@ -79,23 +70,13 @@ function paymentChannelLabel(value: PaymentChannel | string | null | undefined)
function paymentItemStatusLabel(value: string) {
if (value === "pending") return "待付款";
if (value === "paid") return "已支付";
if (value === "executed") return "已执行";
if (value === "executed") return "已付款";
if (value === "failed") return "付款失败";
if (value === "cancelled") return "已取消";
if (value === "skipped") return "已跳过";
return value || "-";
}
function reviewNextStepLabel(value: PaymentBatch["reviewRequirement"]["nextStep"]) {
if (value === "first_review") return "等待独立复核";
return "可执行付款标记";
}
function reviewerDisplayName(value: string | null | undefined) {
if (!value) return "";
return UUID_PATTERN.test(value) ? "已记录" : value;
}
function formatFileSize(value: number) {
if (!Number.isFinite(value) || value <= 0) return "-";
if (value < 1024) return `${value} B`;
@@ -119,14 +100,9 @@ export function PaymentBatchesPage() {
const canReviewBatch =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const [name, setName] = useState("");
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
@@ -172,9 +148,11 @@ export function PaymentBatchesPage() {
canManageBatch &&
!!selectedBatch &&
selectedBatch.status !== "cancelled";
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
selectedBatch.createdBy !== me?.id &&
(
selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review"
);
@@ -183,6 +161,16 @@ export function PaymentBatchesPage() {
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status) &&
selectedBatch.reviewRequirement.nextStep === "ready_for_execute";
const requiresBankReceipt = Boolean(selectedBatch?.items.some((item) =>
["pending", "failed"].includes(item.status) &&
(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank",
));
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
const needsBankReceipt =
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
const canRegisterPaymentComplete =
canExecuteSelectedBatch &&
(!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0));
const canCancelSelectedBatch =
canManageBatch &&
!!selectedBatch &&
@@ -205,11 +193,7 @@ export function PaymentBatchesPage() {
mutationFn: async () =>
createPaymentBatch({
entityType: "reimbursement",
name: name.trim() || "报销付款批次",
entityIds: selectedCandidateIds,
defaultPaymentDate: defaultPaymentDate || undefined,
defaultPaymentChannel: defaultPaymentChannel || undefined,
voucherPrefix: voucherPrefix || undefined,
}),
onSuccess: async ({ item }) => {
setError("");
@@ -219,7 +203,7 @@ export function PaymentBatchesPage() {
next.set("id", item.id);
setSearchParams(next, { replace: true });
await refresh();
toast.success("付款批次已创建");
toast.success("已提交付款复核,等待负责人处理");
},
onError: (err) => {
const message = resolveError(err);
@@ -251,7 +235,7 @@ export function PaymentBatchesPage() {
onSuccess: async () => {
setError("");
await refresh();
toast.success("付款批次已执行");
toast.success("付款已登记完成");
},
onError: (err) => {
const message = resolveError(err);
@@ -296,18 +280,17 @@ export function PaymentBatchesPage() {
});
const uploadReceiptMutation = useMutation({
mutationFn: async () => {
mutationFn: async (files: File[]) => {
if (!selectedBatch) {
throw new Error("请先选择付款批次");
}
if (!selectedReceiptFiles.length) {
if (!files.length) {
throw new Error("请选择银行回单附件");
}
return uploadPaymentBatchReceiptAttachments(selectedBatch.id, selectedReceiptFiles);
return uploadPaymentBatchReceiptAttachments(selectedBatch.id, files);
},
onSuccess: async () => {
setError("");
setSelectedReceiptFiles([]);
if (receiptInputRef.current) {
receiptInputRef.current.value = "";
}
@@ -400,16 +383,6 @@ export function PaymentBatchesPage() {
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
</div>
) : null}
<div className="space-y-2">
<Label>批次名称</Label>
<Input
data-testid="payment-batch-create-name"
value={name}
disabled={!canManageBatch}
onChange={(event) => setName(event.target.value)}
placeholder="例如:2026Q2-财务付款批次-01"
/>
</div>
<div className="space-y-2">
<Label>选择待付款报销</Label>
<Input
@@ -457,44 +430,12 @@ export function PaymentBatchesPage() {
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
) : null}
</div>
<div className="text-xs text-zinc-500">已选择 {selectedCandidateIds.length} 项;创建时服务端会再次核对审批和付款状态。</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-2">
<Label>默认付款日期</Label>
<DateTextInput
data-testid="payment-batch-create-payment-date"
value={defaultPaymentDate}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentDate(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>默认渠道</Label>
<select
className="select-std"
data-testid="payment-batch-create-payment-channel"
value={defaultPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "")}
>
<option value="">不设置</option>
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{paymentChannelLabel(item)}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>凭证前缀</Label>
<Input
data-testid="payment-batch-create-voucher-prefix"
value={voucherPrefix}
disabled={!canManageBatch}
onChange={(event) => setVoucherPrefix(event.target.value)}
placeholder="例如 PBX"
/>
<div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
</div>
</div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建批次"}
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button>
</div>
) : null}
@@ -514,11 +455,10 @@ export function PaymentBatchesPage() {
</div>
</div>
<div className="flex flex-wrap gap-2">
{canReviewBatch ? (
{canReviewSelectedBatch ? (
<Button
type="button"
variant="secondary"
disabled={reviewMutation.isPending || !canReviewSelectedBatch}
disabled={reviewMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financeReview",
batchId: selectedBatch.id,
@@ -527,13 +467,22 @@ export function PaymentBatchesPage() {
totalAmount: selectedBatch.totalAmount,
})}
>
复核
复核通过
</Button>
) : null}
{canManageBatch ? (
{needsBankReceipt ? (
<Button
type="button"
disabled={executeMutation.isPending || !canExecuteSelectedBatch}
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
{canRegisterPaymentComplete ? (
<Button
type="button"
disabled={executeMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financePayment",
batchId: selectedBatch.id,
@@ -542,7 +491,7 @@ export function PaymentBatchesPage() {
totalAmount: selectedBatch.totalAmount,
})}
>
执行
登记付款完成
</Button>
) : null}
{canManageBatch ? (
@@ -565,7 +514,7 @@ export function PaymentBatchesPage() {
</div>
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-6">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{batchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{selectedBatch.totalAmount}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
@@ -573,10 +522,20 @@ export function PaymentBatchesPage() {
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
当前要求:单次独立复核后可执行。
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},执行前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
下一步:{reviewNextStepLabel(selectedBatch.reviewRequirement.nextStep)}。
{selectedBatch.reviewedBy ? ` 复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""}
{selectedBatch.status === "draft"
? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
</div>
@@ -585,9 +544,9 @@ export function PaymentBatchesPage() {
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
<p className="mt-1 text-sm text-zinc-500">先在网银完成对公付款,再上传银行回单并执行 OA 已付款标记。</p>
<p className="mt-1 text-sm text-zinc-500">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p>
</div>
{canManageBatch ? (
{canUploadReceipt ? (
<div className="flex flex-wrap items-center gap-2">
<input
ref={receiptInputRef}
@@ -596,35 +555,35 @@ export function PaymentBatchesPage() {
className="hidden"
accept=".pdf,image/jpeg,image/png,image/webp"
multiple
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending}
onChange={(event) => setSelectedReceiptFiles(Array.from(event.target.files ?? []))}
disabled={uploadReceiptMutation.isPending}
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length) uploadReceiptMutation.mutate(files);
}}
/>
<Button
type="button"
variant="secondary"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending}
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
选择回单
</Button>
<Button
type="button"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending || selectedReceiptFiles.length === 0}
onClick={() => uploadReceiptMutation.mutate()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传"}
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
</div>
) : null}
</div>
{selectedReceiptFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
待上传:{selectedReceiptFiles.map((file) => file.name).join("、")}
</div>
) : null}
{receiptAttachmentsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(receiptAttachmentsQuery.error)}
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<span>{resolveError(receiptAttachmentsQuery.error)}</span>
<Button
type="button"
size="sm"
variant="secondary"
disabled={receiptAttachmentsQuery.isFetching}
onClick={() => void receiptAttachmentsQuery.refetch()}
>
{receiptAttachmentsQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div>
) : null}
<div className="space-y-2">
@@ -671,9 +630,9 @@ export function PaymentBatchesPage() {
</div>
</div>
))}
{!receiptAttachmentsQuery.isLoading && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
{receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单附件;默认银行渠道执行前需要上传至少一份回单。
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
</div>
) : null}
</div>
@@ -752,7 +711,7 @@ export function PaymentBatchesPage() {
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} />
<StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
</div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">
@@ -790,7 +749,7 @@ export function PaymentBatchesPage() {
</td>
<td className="table-td">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} />
<StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
{item.highRisk ? <span className="badge-warning">高风险</span> : null}
</div>
</td>
@@ -834,8 +793,8 @@ export function PaymentBatchesPage() {
? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
: "确认后将完成一次独立复核并写入付款批次审计记录。"
: confirmPaymentAction?.highRisk
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将更新源单付款状态。`
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和回单均已核对。"
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。`
: "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。"
}
onConfirm={async () => {
if (!confirmPaymentAction) return;
+12 -18
查看文件
@@ -107,10 +107,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
const paymentDate = new Date().toISOString().slice(0, 10);
const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`);
const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId;
const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "新建批次" }).click();
@@ -120,18 +118,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
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 } } };
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string; name?: string } } };
const batchId = createBatchPayload.data?.item?.id;
if (!batchId) {
const batchName = createBatchPayload.data?.item?.name;
if (!batchId || !batchName) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
@@ -155,24 +150,23 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
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 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"),
});
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 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 expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
@@ -1159,8 +1159,6 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
};
}
const batchName = `${trialrunMarkerPrefix} 浏览器付款批次 ${marker}`;
state.paymentBatchName = batchName;
await page.goto("/payment-batches");
await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible();
if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) {
@@ -1174,21 +1172,17 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
}
await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeDisabled();
await expect(createCard.getByRole("button", { name: "创建并提交复核" })).toBeDisabled();
const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches";
});
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
const reimbursementCode = state.reimbursementCode ?? state.reimbursementId;
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10));
await createCard.locator("select").nth(0).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`);
await createCard.getByRole("button", { name: "创建批次" }).click();
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
const createBatchResponse = await createBatchResponsePromise;
if (!createBatchResponse.ok()) {
throw new Error(`创建付款批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`);
@@ -1198,6 +1192,8 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
if (!batch?.id) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
const batchName = batch.name;
state.paymentBatchName = batchName;
state.paymentBatchId = batch.id;
await logoutLiveUser(page, { name: finance.name }).catch(() => undefined);
@@ -1215,10 +1211,23 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const financeAgain = await loginRole(page, "finance");
await page.goto(`/payment-batches?id=${batch.id}`);
const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last();
const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(batchDetailCard).toBeVisible();
await batchDetailCard.getByRole("button", { name: "执行" }).click();
await expect(batchDetailCard).toContainText("状态:已执行", { timeout: 30_000 });
const receiptResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batch.id}/receipt-attachments` &&
response.ok();
});
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: `${trialrunSafePrefix}-bank-receipt.pdf`,
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\n${trialrunMarkerPrefix} ${marker}\n%%EOF\n`, "utf8"),
});
await receiptResponsePromise;
await batchDetailCard.getByRole("button", { name: "登记付款完成" }).click();
await page.getByTestId("action-confirm-submit").click();
await expect(batchDetailCard).toContainText("已付款", { timeout: 30_000 });
await page.goto(`/reimbursements/${state.reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByTestId("reimbursement-payment-status")).toContainText(/已付款|paid/, { timeout: 30_000 });
@@ -1232,7 +1241,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
if (/\/403/.test(page.url())) {
await expect(page.getByRole("heading", { name: "无权限访问", exact: true })).toBeVisible();
} else {
const createButton = page.getByRole("button", { name: "创建批次" }).first();
const createButton = page.getByRole("button", { name: "创建并提交复核" }).first();
await expect(createButton).toBeDisabled();
}
await logoutLiveUser(page, { name: admin.name }).catch(() => undefined);
@@ -414,16 +414,14 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0);
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
await page.getByTestId("payment-batch-create-name").fill("合成付款批次");
await candidateRow.getByRole("checkbox").check();
await page.getByRole("button", { name: "创建批次", exact: true }).click();
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible();
await expect(page.getByText("付款批次已创建", { exact: true })).toBeVisible();
await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible();
expect(createPayload).toEqual({
entityType: "reimbursement",
name: "合成付款批次",
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
});
expectNoPageErrors(pageErrors);
@@ -24,7 +24,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const adminApi = await createAuthenticatedApiClient(ADMIN);
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
const paymentDate = new Date().toISOString().slice(0, 10);
try {
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow");
@@ -100,7 +99,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, secondApprovalUser.name);
await login(page, FINANCE);
const batchName = `R78-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches/);
@@ -112,27 +110,25 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
});
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check();
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("R78PB");
await createCard.getByRole("button", { name: "创建批次" }).click();
await createCard.getByRole("button", { name: "创建并提交复核" }).click();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as {
data?: { item?: { id?: string } };
data?: { item?: { id?: string; name?: string } };
};
const batchId = createBatchPayload.data?.item?.id;
if (!batchId) {
const batchName = createBatchPayload.data?.item?.name;
if (!batchId || !batchName) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
}
await page.goto(`/payment-batches?id=${batchId}`);
const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(batchDetailCard).toBeVisible();
await expect(batchDetailCard).toContainText("草稿");
await expect(batchDetailCard).toContainText("待付款复核");
await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理");
await expect(batchDetailCard).toContainText("0/1");
await logout(page, FINANCE.name);
@@ -157,20 +153,22 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
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: "r78-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
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 receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "r78-bank-receipt.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toBeVisible();
const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
@@ -178,11 +176,11 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
url.pathname === `/api/payment-batches/${batchId}/execute` &&
response.ok();
});
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次");
await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click();
await executeBatchResponsePromise;
await expect(executedBatchDetailCard).toContainText("已执行", { timeout: 30_000 });
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
@@ -182,15 +182,22 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
async function mockSystemActionApis(
page: Page,
options: { batchStatus?: "draft" | "reviewed"; meOverride?: Partial<typeof me> } = {},
options: {
batchStatus?: "draft" | "reviewed";
hasReceipt?: boolean;
receiptQueryError?: boolean;
meOverride?: Partial<typeof me>;
} = {},
) {
const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
const calls = {
deleteReimbursement: 0,
deleteReimbursementAttachment: 0,
exportReimbursements: 0,
executePaymentBatch: 0,
uploadPaymentReceipt: 0,
voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[],
@@ -327,8 +334,19 @@ async function mockSystemActionApis(
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") {
if (options.receiptQueryError) {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({
requestId: "mock-system-action-confirm",
error: { code: "SYS_001", message: "回单状态暂时不可用" },
}),
});
return;
}
await fulfillJson(route, {
items: [
items: hasReceipt ? [
{
id: paymentReceiptId,
originalName: "合成银行回单.pdf",
@@ -348,11 +366,18 @@ async function mockSystemActionApis(
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:20:00.000Z",
},
],
] : [],
});
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "POST") {
calls.uploadPaymentReceipt += 1;
hasReceipt = true;
await fulfillJson(route, { items: [{ id: paymentReceiptId }] });
return;
}
if (
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` &&
request.method() === "POST"
@@ -419,18 +444,37 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
await page.getByRole("button", { name: "执行" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次");
await page.getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
expect(calls.executePaymentBatch).toBe(0);
await page.getByTestId("action-confirm-cancel").click();
expect(calls.executePaymentBatch).toBe(0);
await page.getByRole("button", { name: "执行" }).click();
await page.getByRole("button", { name: "登记付款完成" }).click();
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.executePaymentBatch).toBe(1);
});
test("银行回单上传前后只显示一个付款主动作", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "合成银行回单.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nsynthetic receipt\n%%EOF\n", "utf8"),
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(page.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
});
test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
@@ -493,6 +537,33 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
await expect(page.getByRole("button", { name: "复核", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0);
});
test("兼任付款复核职责的制单人也不显示自审动作", async ({ page }) => {
await mockSystemActionApis(page, {
batchStatus: "draft",
meOverride: {
roleCodes: ["manager", "finance"],
permissions: [...me.permissions, "payment.batch.review"],
},
});
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("制单人不能复核自己的批次")).toBeVisible();
await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0);
});
test("回单查询失败时不把未知状态误报为空且不显示付款动作", async ({ page }) => {
await mockSystemActionApis(page, { receiptQueryError: true });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText(/服务暂不可用/)).toBeVisible();
await expect(page.getByRole("button", { name: "重新加载" })).toBeVisible();
await expect(page.getByText(/暂无银行回单/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
});
});