import { test, expect } from "./helpers/fixtures"; import { apiGetData, createAuthenticatedApiClient, EMPLOYEE, FINANCE, FINANCE_MGR, login, logout, MANAGER, } from "./helpers/auth"; import { openNavLink } from "./helpers/navigation"; import { TEST_IDS } from "./helpers/testIds"; function extractId(text: string, label: string) { const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`)); if (!match?.[1]) { throw new Error(`Failed to parse ${label} from text: ${text}`); } return match[1]; } test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ page, reimbursementDetail, workflowDetail, }) => { test.setTimeout(300_000); const employeeApi = await createAuthenticatedApiClient(EMPLOYEE); const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); const paymentDate = new Date().toISOString().slice(0, 10); try { await login(page, EMPLOYEE); await (await openNavLink(page, "我的报销")).click(); await expect(page).toHaveURL(/\/reimbursements/); 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(); await (await openNavLink(page, "票据中心")).click(); const marker = `R78_BATCH_${Date.now()}_${Math.random().toString(16).slice(2)}`; await page.getByTestId("invoice-upload-file").setInputFiles({ name: `r78-${Date.now()}.png`, mimeType: "image/png", buffer: Buffer.from(marker, "utf8"), }); await page.getByTestId("invoice-upload-submit").click(); await expect(page.getByTestId("invoice-detail-panel")).toBeVisible(); await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 90_000 }); const invoiceDetailText = await page.getByTestId("invoice-detail-panel").innerText(); const invoiceId = extractId(invoiceDetailText, "票据ID"); await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId); await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId); await page.getByTestId("invoice-bind-submit").click(); await expect(page.getByText("绑定成功")).toBeVisible({ timeout: 30_000 }); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); await reimbursementDetail.submitReimbursement(); await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible(); 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); await login(page, FINANCE); await (await openNavLink(page, "待我处理")).click(); await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); 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|已完成)/); const batchName = `R78-报销付款批次-${Date.now()}`; await page.goto("/payment-batches"); await expect(page).toHaveURL(/\/payment-batches/); const createCard = page.locator(".card-std").filter({ hasText: "新建批次" }).first(); 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.locator("select").nth(0).selectOption("reimbursement"); await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName); await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(reimbursementId); await createCard.locator('input[type="date"]').fill(paymentDate); await createCard.getByPlaceholder("例如 PBX").fill("R78PB"); await createCard.getByRole("button", { name: "创建批次" }).click(); const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string } }; }; const batchId = createBatchPayload.data?.item?.id; if (!batchId) { throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } await page.goto(`/payment-batches?id=${batchId}`); const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last(); await expect(batchDetailCard).toBeVisible(); await expect(batchDetailCard).toContainText("状态:draft"); await expect(batchDetailCard).toContainText("复核进度:0/1"); await logout(page, FINANCE.name); await login(page, FINANCE_MGR); await page.goto("/ap-operations"); await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible(); 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("R78 focused regression review"); await page.getByRole("button", { name: "提交复核" }).click(); await expect(pendingRow).toContainText("复核 1/1"); await logout(page, FINANCE_MGR.name); await login(page, FINANCE); await page.goto(`/payment-batches?id=${batchId}`); const executedBatchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last(); await expect(executedBatchDetailCard).toBeVisible(); await expect(executedBatchDetailCard).toContainText("复核进度:1/1"); await executedBatchDetailCard.getByRole("button", { name: "执行" }).click(); await expect(executedBatchDetailCard).toContainText("状态:executed", { timeout: 30_000 }); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 }); await page.goto("/ap-operations"); await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible(); await expect(page.getByText(batchName)).toHaveCount(0); await logout(page, FINANCE.name); const invoiceDetail = await apiGetData<{ status: string; archivedAt: string | null; linkedReimbursementId: string | null; }>( employeeApi.api, `/api/invoice-pool/${invoiceId}`, ); expect(invoiceDetail.status).toBe("archived"); expect(invoiceDetail.archivedAt).toBeTruthy(); expect(invoiceDetail.linkedReimbursementId).toBe(reimbursementId); const erpPreview = await apiGetData<{ exportState: { lastOutboxId: string | null; lastOutboxStatus: string | null; }; }>( financeMgrApi.api, `/api/integrations/erp/journals/reimbursements/${reimbursementId}/preview`, ); expect(erpPreview.exportState.lastOutboxId).toBeTruthy(); expect(erpPreview.exportState.lastOutboxStatus).toBe("pending"); } finally { await Promise.all([employeeApi.dispose(), financeMgrApi.dispose()]); } });