文件
hyb-oa/client/tests/e2e/system-action-confirmation.spec.ts
T
wangsli 5d274af8d4
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
2026-08-02 09:02:40 +08:00

597 行
22 KiB
TypeScript

import type { Page, Route } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
import { login } from "./helpers/auth";
test.use({ serviceWorkers: "block" });
function envelope<T>(data: T) {
return { requestId: "mock-system-action-confirm", data };
}
async function fulfillJson<T>(route: Route, data: T) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
const user = {
identifier: "[email protected]",
password: "ActionConfirm1234",
name: "Action Confirm",
};
const me = {
id: "employee-action-confirm",
employeeNo: "E-action-confirm",
name: user.name,
email: user.identifier,
phone: null,
roleCodes: ["manager", "finance"],
permissions: [
"expense.create",
"expense.view_own",
"expense.delete_own",
"expense.approve",
"payment.batch.manage",
"risk.review.manage",
],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 80,
},
};
const reimbursementId = "reimbursement-action-confirm";
const paymentBatchId = "payment-batch-action-confirm";
const reimbursementAttachmentId = "reimbursement-attachment-action-confirm";
const paymentReceiptId = "payment-receipt-action-confirm";
function reimbursementListItem() {
return {
id: reimbursementId,
formNumber: "BX-ACTION-001",
status: "draft",
reimbursementDate: "2026-05-15",
totalAmount: "88.00",
description: "R7E 高风险确认测试",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
project: null,
pendingWorkflow: null,
createdAt: "2026-05-15T08:00:00.000Z",
updatedAt: "2026-05-15T08:10:00.000Z",
};
}
function reimbursementDetail() {
return {
...reimbursementListItem(),
submitterId: me.id,
ownerId: me.id,
departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
items: [],
invoices: [],
attachments: [
{
id: reimbursementAttachmentId,
originalName: "合成报销敏感附件.pdf",
mimeType: "application/pdf",
fileSize: 1024,
fileSha256: "synthetic-attachment-hash",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "supporting_document",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:05:00.000Z",
},
],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
};
}
function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = false) {
return {
id: paymentBatchId,
batchNo: "PAY-ACTION-001",
entityType: "reimbursement",
name: "R7E 高风险付款确认",
status,
defaultPaymentDate: "2026-05-15",
defaultPaymentChannel: "bank",
voucherPrefix: "R7E",
totalAmount: "88.00",
highRisk,
reviewRequirement: {
thresholdAmount: "1000.00",
requiredReviewCount: 1,
completedReviewCount: status === "draft" ? 0 : 1,
nextStep: status === "draft" ? "first_review" : "ready_for_execute",
makerCheckerRequired: false,
reviewerPermission: "payment.batch.review",
},
reviewComment: null,
secondaryReviewComment: null,
executionSummaryJson: null,
createdAt: "2026-05-15T08:00:00.000Z",
createdBy: me.id,
reviewedAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z",
reviewedBy: status === "draft" ? null : me.id,
secondaryReviewedAt: null,
secondaryReviewedBy: null,
executedAt: null,
executedBy: null,
cancelledAt: null,
cancelledBy: null,
cancelledReason: null,
items: [
{
id: "payment-batch-item-action-confirm",
entityId: reimbursementId,
entityCode: "BX-ACTION-001",
entityTitle: "R7E 高风险确认测试",
amount: "88.00",
currencyCode: "CNY",
paymentVoucherNoOverride: null,
paymentDateOverride: null,
paymentChannelOverride: null,
status: "pending",
failureCode: null,
failureMessage: null,
snapshotJson: {},
createdAt: "2026-05-15T08:00:00.000Z",
openRiskFlags: [],
},
],
};
}
async function mockSystemActionApis(
page: Page,
options: {
batchStatus?: "draft" | "reviewed";
hasReceipt?: boolean;
receiptQueryError?: boolean;
highRisk?: 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[],
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
if (path === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (path === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "wx-test-corp",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
bindEnabled: true,
defaultReturnTo: "/portal",
status: "ready",
statusReasons: [],
});
return;
}
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({
accessToken: "mock-system-action-confirm-token",
expiresIn: 900,
mustChangePassword: false,
})),
});
return;
}
if (path === "/api/auth/logout") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
body: JSON.stringify(envelope({})),
});
return;
}
if (path === "/api/me") {
await fulfillJson(route, currentMe);
return;
}
if (path === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-action-confirm",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
enabledGroups: ["main"],
quickActions: [],
},
items: [
navigationItem("portal", "/portal", "首页", "home"),
navigationItem("reimbursements", "/reimbursements", "报销", "receipt"),
navigationItem("payment-batches", "/payment-batches", "付款", "credit_card"),
],
});
return;
}
if (path === "/api/reimbursements" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [reimbursementListItem()] });
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail());
return;
}
if (
path === `/api/reimbursements/${reimbursementId}/attachments/${reimbursementAttachmentId}` &&
request.method() === "DELETE"
) {
calls.deleteReimbursementAttachment += 1;
const body = request.postDataJSON() as { reason?: string };
calls.attachmentDeleteReasons.push(body.reason ?? "");
await fulfillJson(route, { attachmentId: reimbursementAttachmentId, outcome: "deleted" });
return;
}
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
calls.deleteReimbursement += 1;
await fulfillJson(route, { id: reimbursementId, deleted: true });
return;
}
if (path === "/api/exports/reimbursements") {
calls.exportReimbursements += 1;
await route.fulfill({
status: 200,
contentType: url.searchParams.get("format") === "xlsx"
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
: "text/csv",
headers: { "content-disposition": "attachment; filename=\"reimbursements.csv\"" },
body: "formNumber,totalAmount\nBX-ACTION-001,88.00\n",
});
return;
}
if (path === "/api/reimbursement-templates") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (path === "/api/reimbursements/linkable-travel-requests") {
await fulfillJson(route, { ownerEmployeeId: me.id, items: [] });
return;
}
if (path === "/api/payment-batches" && request.method() === "GET") {
await fulfillJson(route, { items: [paymentBatch(batchStatus, options.highRisk)] });
return;
}
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: hasReceipt ? [
{
id: paymentReceiptId,
originalName: "合成银行回单.pdf",
mimeType: "application/pdf",
fileSize: 2048,
fileSha256: "synthetic-receipt-hash",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "payment_receipt",
downloadPolicy: "reason_required",
reviewedBy: null,
reviewedAt: null,
reviewNote: null,
archivedBy: null,
archivedAt: null,
archiveNote: null,
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"
) {
calls.voidPaymentReceipt += 1;
const body = request.postDataJSON() as { reason?: string };
calls.receiptVoidReasons.push(body.reason ?? "");
await fulfillJson(route, { attachmentId: paymentReceiptId, outcome: "voided", voidedAt: "2026-05-15T08:30:00.000Z" });
return;
}
if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") {
calls.executePaymentBatch += 1;
await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk) });
return;
}
await fulfillJson(route, {});
});
return calls;
}
test.describe("R7E 全站高风险操作二次确认", () => {
test("报销删除和敏感导出必须确认后才调用接口", async ({ page }) => {
const calls = await mockSystemActionApis(page);
page.on("dialog", (dialog) => {
throw new Error(`不应出现浏览器原生弹窗:${dialog.message()}`);
});
await login(page, user);
await page.goto("/reimbursements");
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-cancel").click();
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursement).toBe(1);
await page.getByTestId("reimbursements-export").click();
await page.getByTestId("reimbursements-export-csv").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
expect(calls.exportReimbursements).toBe(0);
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.exportReimbursements).toBe(1);
});
test("付款批次执行必须确认后才调用接口", async ({ page }) => {
const calls = await mockSystemActionApis(page);
page.on("dialog", (dialog) => {
throw new Error(`不应出现浏览器原生弹窗:${dialog.message()}`);
});
await login(page, user);
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("登记付款完成");
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.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(1);
await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
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 }) => {
await mockSystemActionApis(page, { hasReceipt: false, highRisk: true });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible();
const sectionOrder = await page.locator([
'[data-testid="payment-batch-detail-panel"]',
'[data-testid="payment-batch-current-processing"]',
'[data-testid="payment-batch-items-panel"]',
'[data-testid="payment-batch-receipt-panel"]',
].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(sectionOrder).toEqual([
"payment-batch-detail-panel",
"payment-batch-current-processing",
"payment-batch-items-panel",
"payment-batch-receipt-panel",
]);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("¥1,000.00");
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单");
});
test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const attachment = page.locator(".detail-evidence-card").filter({ hasText: "合成报销敏感附件.pdf" });
await expect(attachment).toBeVisible();
await attachment.getByRole("button", { name: "删除" }).click();
const dialog = page.getByTestId("attachment-delete-confirm-dialog");
await expect(dialog).toContainText("合成报销敏感附件.pdf");
await expect(dialog).toContainText("无法在 OA 内恢复");
expect(calls.deleteReimbursementAttachment).toBe(0);
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect(dialog).toContainText("请填写至少 2 个字的删除理由");
expect(calls.deleteReimbursementAttachment).toBe(0);
await dialog.getByRole("button", { name: "取消" }).click();
expect(calls.deleteReimbursementAttachment).toBe(0);
await attachment.getByRole("button", { name: "删除" }).click();
await page.getByRole("textbox", { name: "删除理由" }).fill("附件重复上传");
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursementAttachment).toBe(1);
expect(calls.attachmentDeleteReasons).toEqual(["附件重复上传"]);
});
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page);
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
await expect(receipt).toBeVisible();
await receipt.getByRole("button", { name: "作废" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("合成银行回单.pdf");
await expect(dialog).toContainText("文件记录会保留并标记为已作废");
await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled();
expect(calls.voidPaymentReceipt).toBe(0);
await page.getByRole("textbox", { name: "回单作废原因" }).fill("一");
await expect(page.getByRole("button", { name: "确认作废" })).toBeDisabled();
expect(calls.voidPaymentReceipt).toBe(0);
await dialog.getByRole("button", { name: "返回" }).click();
expect(calls.voidPaymentReceipt).toBe(0);
await receipt.getByRole("button", { name: "作废" }).click();
await page.getByRole("textbox", { name: "回单作废原因" }).fill("回单文件上传有误");
await page.getByRole("button", { name: "确认作废" }).click();
await expect.poll(() => calls.voidPaymentReceipt).toBe(1);
expect(calls.receiptVoidReasons).toEqual(["回单文件上传有误"]);
});
test("只有超级管理员职责且未兼任经理或负责人时不显示付款复核", async ({ page }) => {
await mockSystemActionApis(page, {
batchStatus: "draft",
meOverride: { roleCodes: ["admin"] },
});
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);
});
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);
});
});