import { test, expect } from "./helpers/fixtures"; import { ADMIN, EMPLOYEE, FINANCE, MANAGER, apiGetData, 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(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); await (await openNavLink(page, "我的报销")).click(); await expect(page).toHaveURL(/\/reimbursements/); await expect(page.getByTestId("reimbursements-create")).toBeVisible(); const createResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok(); }); await page.getByTestId("reimbursements-create").click(); await page.getByRole("menuitem", { name: "空白报销" }).click(); const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } }; const reimbursementId = createPayload.data?.id; if (!reimbursementId) { throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`); } await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); // 2) employee: upload invoice and wait OCR await (await openNavLink(page, "票据")).click(); const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`; const invoiceFileName = `e2e-${Date.now()}.pdf`; const uploadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload" && response.ok(); }); await page.getByRole("button", { name: "上传票据" }).click(); await page.getByTestId("invoice-upload-file").setInputFiles({ name: invoiceFileName, mimeType: "application/pdf", buffer: Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"), }); await page.getByTestId("invoice-upload-submit").click(); const uploadPayload = (await (await uploadResponsePromise).json()) as { data?: { invoicePoolId?: string | null } }; const invoiceId = uploadPayload.data?.invoicePoolId; if (!invoiceId) { throw new Error(`上传发票后未返回票据 ID: ${JSON.stringify(uploadPayload)}`); } await expect(page.getByTestId("invoice-detail-panel")).toBeVisible(); await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:可用", { timeout: 90_000 }); // 预览(popup 存在即可) const popupPromise = page.waitForEvent("popup"); await page.getByTestId("invoice-preview-button").click(); const popup = await popupPromise; await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`)); await popup.close(); // 3) bind invoice to reimbursement await page.goto("/invoice-pool"); await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); await page.getByRole("button", { name: "绑定" }).click(); await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId); await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId); await page.getByTestId("invoice-bind-submit").click(); // 4) submit reimbursement await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); await reimbursementDetail.submitReimbursement(); await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible(); // 5) manager approve await logout(page, EMPLOYEE.name); await login(page, MANAGER); await (await openNavLink(page, "待我审批")).click(); await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first(); await expect(managerTaskRow).toBeVisible(); await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click(); await workflowDetail.assertLoaded(); await workflowDetail.approve(); await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/); await logout(page, MANAGER.name); // 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(); await expect(financeTaskRow).toBeVisible(); await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click(); await workflowDetail.assertLoaded(); await workflowDetail.approve(); await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0); const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`); const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId; await page.goto("/payment-batches"); const createCard = page.getByTestId("payment-batch-create-panel"); await expect(createCard).toBeVisible(); 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-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.getByRole("button", { name: "创建并提交复核" }).click(); const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string; name?: string } } }; const batchId = createBatchPayload.data?.item?.id; const batchName = createBatchPayload.data?.item?.name; if (!batchId || !batchName) { throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } await logout(page, FINANCE.name); await login(page, MANAGER); 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, MANAGER.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(); 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.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 page.getByTestId("payment-batch-action-panel").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); // 7) guard: unauth -> login await page.goto("/dashboard"); await expect(page).toHaveURL(/\/login/); });