import { expect, test, type Page, type Route } from "@playwright/test"; test.use({ serviceWorkers: "block" }); type SyntheticActor = { id: string; employeeNo: string; name: string; email: string; roleCodes: string[]; }; type SyntheticContract = { id: string; contractNo: string; projectId: string | null; contractType: "purchase" | "sales" | "service" | "framework" | "other"; counterpartyName: string; summary: string; amount: string | null; currency: string; signedDate: string | null; effectiveDate: string | null; expiryDate: string | null; confidentialityLevel: "public" | "internal" | "confidential" | "restricted"; archiveLocation: string; offlineArchiveNo: string | null; ownerEmployeeId: string; status: "draft" | "active" | "expired" | "archived"; persistedStatus: "draft" | "active" | "archived"; createdAt: string | null; updatedAt: string | null; owner: { id: string; employeeNo: string; name: string } | null; project: { id: string; projectNo: string; name: string } | null; }; type MockScenario = { paymentCandidates?: Array<{ entityId: string; entityType: "reimbursement"; businessNo: string; employee: { id: string; employeeNo: string; name: string }; amount: string; currencyCode: "CNY"; businessDate: string | null; summary: string | null; }>; paymentCandidateTotal?: number; onCandidateQuery?: (query: URLSearchParams) => void; contracts?: SyntheticContract[]; onCreatePaymentBatch?: (payload: unknown) => void; }; const REVIEWER_ACTOR: SyntheticActor = { id: "employee-payment-reviewer", employeeNo: "SYN-REVIEWER", name: "合成付款复核人", email: "reviewer@example.test", roleCodes: ["manager"], }; const FINANCE_ACTOR: SyntheticActor = { id: "employee-payment-finance", employeeNo: "SYN-FINANCE", name: "合成财务人员", email: "finance@example.test", roleCodes: ["finance"], }; const CONTRACT_READER_ACTOR: SyntheticActor = { id: "employee-contract-reader", employeeNo: "SYN-CONTRACT-READER", name: "合成合同只读人员", email: "contract-reader@example.test", roleCodes: ["employee"], }; const NO_INVOICE_UPLOAD_ACTOR: SyntheticActor = { id: "employee-no-invoice-upload", employeeNo: "SYN-NO-INVOICE-UPLOAD", name: "合成无票据上传人员", email: "no-invoice-upload@example.test", roleCodes: ["hr"], }; const PAYMENT_CANDIDATE_ENTITY_ID = "11111111-1111-4111-8111-111111111111"; const CREATED_PAYMENT_BATCH_ID = "22222222-2222-4222-8222-222222222222"; const PAYMENT_CANDIDATE = { entityId: PAYMENT_CANDIDATE_ENTITY_ID, entityType: "reimbursement" as const, businessNo: "BX-SYN-20260718-01", employee: { id: "employee-payment-candidate", employeeNo: "SYN-PAYEE", name: "合成付款员工", }, amount: "1280.50", currencyCode: "CNY" as const, businessDate: "2026-07-18", summary: "合成已审批报销", }; const CONTRACT_ITEM: SyntheticContract = { id: "contract-synthetic-readonly", contractNo: "HT-SYN-READ-001", projectId: "project-synthetic-contract", contractType: "service", counterpartyName: "合成合作方", summary: "合成只读合同", amount: "8000.00", currency: "CNY", signedDate: "2026-07-01", effectiveDate: "2026-07-01", expiryDate: "2027-06-30", confidentialityLevel: "internal", archiveLocation: "受控线下归档", offlineArchiveNo: "SYN-ARCHIVE-001", ownerEmployeeId: CONTRACT_READER_ACTOR.id, status: "active", persistedStatus: "active", createdAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:00.000Z", owner: { id: CONTRACT_READER_ACTOR.id, employeeNo: CONTRACT_READER_ACTOR.employeeNo, name: CONTRACT_READER_ACTOR.name, }, project: { id: "project-synthetic-contract", projectNo: "XM-SYN-001", name: "合成合同项目", }, }; function envelope(data: unknown) { return { requestId: "mock-payment-reviewer-permissions", data }; } async function fulfillJson(route: Route, data: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)), }); } function capturePageErrors(page: Page) { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); return errors; } function expectNoPageErrors(errors: string[]) { expect(errors, "页面不应产生未捕获异常").toEqual([]); } async function mockSyntheticApis(page: Page, actor: SyntheticActor, scenario: MockScenario = {}) { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const requestUrl = new URL(request.url()); const pathName = requestUrl.pathname; if (pathName === "/api/auth/capabilities") { await fulfillJson(route, { emailDelivery: { ready: false }, emailLoginCode: { enabled: false }, passwordReset: { enabled: false }, }); return; } if (pathName === "/api/auth/sso/providers") { await fulfillJson(route, []); return; } if (pathName === "/api/auth/wecom/config") { await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [], }); return; } if (pathName === "/api/auth/login" || pathName === "/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-token-${actor.employeeNo}`, expiresIn: 900, mustChangePassword: false, })), }); return; } if (pathName === "/api/me") { await fulfillJson(route, { ...actor, phone: null, departmentId: `department-${actor.employeeNo}`, directManagerId: null, status: "active", notificationPushEnabled: true, profileCompleteness: { hasEmergencyContact: true, hasBankAccount: false, hasAvatar: false, hasIdNumber: false, score: 40, }, wecomBinding: null, }); return; } if (pathName === "/api/system/navigation") { await fulfillJson(route, { workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "desktop", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null, }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [], }); return; } if (pathName === "/api/notifications") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: false, unreadCount: 0, items: [], }); return; } if (pathName === "/api/portal/home") { await fulfillJson(route, { workspace: { id: `workspace-${actor.employeeNo}`, persisted: true, profile: "small_team_internal" }, greeting: { name: actor.name }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], recentActivities: [], teamStatusBoard: [], }); return; } if (pathName === "/api/payment-batches/candidates") { const items = scenario.paymentCandidates ?? []; scenario.onCandidateQuery?.(requestUrl.searchParams); await fulfillJson(route, { items, page: Number(requestUrl.searchParams.get("page") ?? "1"), pageSize: Number(requestUrl.searchParams.get("pageSize") ?? "20"), total: scenario.paymentCandidateTotal ?? items.length, }); return; } if (pathName === "/api/payment-batches" && request.method() === "POST") { scenario.onCreatePaymentBatch?.(request.postDataJSON()); await fulfillJson(route, { item: { id: CREATED_PAYMENT_BATCH_ID } }); return; } if (pathName === "/api/payment-batches") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/risk-flags") { await fulfillJson(route, { items: [] }); return; } if (pathName === "/api/ap-operations") { await fulfillJson(route, { summary: { pendingPaymentCount: 0, partialMatchCount: 0, exceptionCount: 0, budgetOverspendCount: 0, }, pendingPaymentBatches: [], failedPaymentBatchItems: [], exceptionRiskFlags: [], budgetOverspendRisks: [], partialMatchItems: [], }); return; } if (pathName === "/api/ap-operations/reimbursement-ledger") { await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); return; } if (pathName === "/api/contracts-registry/expiring") { await fulfillJson(route, { days: 30, total: 0, items: [] }); return; } if (pathName === "/api/contracts-registry") { await fulfillJson(route, { items: scenario.contracts ?? [] }); return; } if (pathName === "/api/projects") { await fulfillJson(route, { items: [] }); return; } await fulfillJson(route, { message: "unhandled synthetic endpoint" }, 404); }); } async function login(page: Page, actor: SyntheticActor) { await page.goto("/login"); await page.getByTestId("login-identifier").fill(actor.email); await page.getByTestId("login-password").fill("SyntheticPassword1234"); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/(?:portal|mobile)$/); } test("只有付款复核职责的经理可看应付账款但看不到导出", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, REVIEWER_ACTOR); await login(page, REVIEWER_ACTOR); await page.goto("/ap-operations"); await expect(page).toHaveURL(/\/ap-operations$/); await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible(); await expect(page.getByTestId("ap-export")).toHaveCount(0); expectNoPageErrors(pageErrors); }); test("只有付款复核职责的经理看不到新建批次", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); let candidateQueryCount = 0; await mockSyntheticApis(page, REVIEWER_ACTOR, { onCandidateQuery: () => { candidateQueryCount += 1; }, }); await login(page, REVIEWER_ACTOR); await page.goto("/payment-batches?view=pending"); await expect(page).toHaveURL(/\/payment-batches\?view=batches$/); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /待组批/ })).toHaveCount(0); await expect(page.getByRole("button", { name: /付款批次/ })).toBeVisible(); expect(candidateQueryCount).toBe(0); expectNoPageErrors(pageErrors); }); test("合同只读人员看不到新增编辑续约和归档动作", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, CONTRACT_READER_ACTOR, { contracts: [CONTRACT_ITEM] }); await login(page, CONTRACT_READER_ACTOR); await page.goto("/contracts-registry"); await expect(page).toHaveURL(/\/contracts-registry$/); await expect(page.getByRole("heading", { level: 1, name: "合同", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "新增合同", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0); const contractRow = page .getByTestId("contracts-registry-table") .getByRole("row") .filter({ hasText: CONTRACT_ITEM.contractNo }); await expect(contractRow).toBeVisible(); await contractRow.getByRole("button", { name: "更多", exact: true }).click(); await expect(page.getByRole("menuitem", { name: "查看项目", exact: true })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "续约", exact: true })).toHaveCount(0); await expect(page.getByRole("menuitem", { name: "归档", exact: true })).toHaveCount(0); await expect(page).toHaveURL(/\/contracts-registry$/); expectNoPageErrors(pageErrors); }); test("无票据上传权限的兼容角色直达票据页时失败关闭", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, NO_INVOICE_UPLOAD_ACTOR); await login(page, NO_INVOICE_UPLOAD_ACTOR); await page.goto("/invoice-pool"); await expect(page).toHaveURL(/\/403$/); await expect(page.getByRole("heading", { level: 1, name: "无权限访问", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "上传票据", exact: true })).toHaveCount(0); expectNoPageErrors(pageErrors); }); test("财务从业务候选选择报销并使用所选实体创建付款批次", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); let createPayload: unknown = null; const candidateQueries: string[] = []; await mockSyntheticApis(page, FINANCE_ACTOR, { paymentCandidates: [PAYMENT_CANDIDATE], paymentCandidateTotal: 21, onCandidateQuery: (query) => candidateQueries.push(query.toString()), onCreatePaymentBatch: (payload) => { createPayload = payload; }, }); await login(page, FINANCE_ACTOR); await page.goto("/payment-batches"); await expect(page).toHaveURL(/\/payment-batches\?view=pending$/); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "待组批 21", exact: true })).toHaveAttribute("aria-current", "page"); await expect(page.getByTestId("payment-batch-candidates")).toHaveCount(1); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2); await page.getByRole("button", { name: "下一页", exact: true }).click(); await expect(page).toHaveURL(/view=pending&page=2/); await expect.poll(() => candidateQueries.some((query) => query.includes("page=2") && query.includes("pageSize=20"))).toBe(true); await page.getByRole("button", { name: "上一页", exact: true }).click(); const candidateRow = page.getByTestId("payment-batch-candidate-row"); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name); await expect(candidateRow).toContainText("¥1,280.50"); 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-candidate-search").fill(PAYMENT_CANDIDATE.businessNo); await expect(page).toHaveURL(new RegExp(`view=pending&q=${PAYMENT_CANDIDATE.businessNo}`)); await page.setViewportSize({ width: 375, height: 812 }); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2); await candidateRow.getByRole("checkbox").check(); await page.getByTestId("payment-batch-default-channel").selectOption("cash"); await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible(); await page.getByRole("button", { name: "创建并提交复核", exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/payment-batches\\?view=batches&id=${CREATED_PAYMENT_BATCH_ID}$`)); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible(); expect(createPayload).toEqual({ entityType: "reimbursement", entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], defaultPaymentChannel: "cash", }); expectNoPageErrors(pageErrors); });