import type { Page, Route } from "@playwright/test"; import { test, expect } from "./helpers/fixtures"; import { login } from "./helpers/auth"; test.use({ serviceWorkers: "block" }); function envelope(data: T) { return { requestId: "mock-system-action-confirm", data }; } async function fulfillJson(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: "action-confirm@hyinda.com", 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") { return { id: paymentBatchId, batchNo: "PAY-ACTION-001", entityType: "reimbursement", name: "R7E 高风险付款确认", status, defaultPaymentDate: "2026-05-15", defaultPaymentChannel: "bank", voucherPrefix: "R7E", totalAmount: "88.00", highRisk: false, 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"; meOverride?: Partial } = {}, ) { const currentMe = { ...me, ...options.meOverride }; const batchStatus = options.batchStatus ?? "reviewed"; const calls = { deleteReimbursement: 0, deleteReimbursementAttachment: 0, exportReimbursements: 0, executePaymentBatch: 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)] }); return; } if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") { await fulfillJson(route, { items: [ { 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/${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") }); 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); 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); }); });