import { type APIResponse } from "@playwright/test"; import { createRequire } from "node:module"; import { expect, test } from "./helpers/fixtures"; import { ADMIN, EMPLOYEE, FINANCE, MANAGER, apiGetData, apiPostData, createAuthenticatedApiClient, login, logout, } from "./helpers/auth"; type FeatureFlagItem = { flagKey: string; overrideMode: "inherit" | "force_on" | "force_off"; rolloutType: "global" | "department_allowlist"; departmentIds: string[]; note: string; version: number; effectiveEnabled: boolean; }; type NotificationListResponse = { unreadCount: number; items: Array<{ id: string; title: string; body: string | null; targetType: string | null; targetId: string | null; readAt: string | null; createdAt: string; }>; }; type DepartmentListResponse = { items: Array<{ id: string; name: string; }>; }; type EmployeeSummary = { id: string; employeeNo: string; name: string; email: string; employmentStatus: string | null; status: string; }; type ExpenseTypeListResponse = { items: Array<{ id: string; code: string; enabled: boolean; name: string; }>; }; type InvoiceUploadResponse = { invoicePoolId: string; status: string; fileSha256: string; }; type InvoiceDetailResponse = { id: string; status: string; }; type SubmittedReimbursementSeed = { reimbursementId: string; formNumber: string; currentTaskId: string; }; type AuthedApi = Awaited>["api"]; const requireServer = createRequire(new URL("../../../server/package.json", import.meta.url)); async function readApiData(response: APIResponse) { const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } }; if (!response.ok()) { throw new Error(`${payload.error?.code ?? response.status()}: ${payload.error?.message ?? "request_failed"}`); } return payload.data as T; } async function ensureFeatureFlags(api: Awaited>["api"], keys: string[]) { const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags"); for (const key of keys) { const item = list.items.find((flag) => flag.flagKey === key); if (!item) { throw new Error(`Missing feature flag: ${key}`); } if (item.overrideMode === "force_on" && item.effectiveEnabled) { continue; } await readApiData<{ item: FeatureFlagItem }>( await api.put(`/api/system/feature-flags/${key}`, { data: { overrideMode: "force_on", rolloutType: "global", departmentIds: [], note: "playwright r75", expectedVersion: item.version, }, }), ); } } async function createSubmittedReimbursement(api: AuthedApi, label: string): Promise { const reimbursementDate = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()); const description = `Playwright 通知补盲 ${label}`; const created = await apiPostData<{ id: string }>(api, "/api/reimbursements", { reimbursementDate, description, }); const expenseTypes = await apiGetData(api, "/api/expense-types?enabled=true"); const expenseTypeId = expenseTypes.items.find((item) => item.enabled && item.code === "office_postal")?.id; if (!expenseTypeId) { throw new Error("未找到合成通知用例必需的邮寄快递费用类型"); } const item = await apiPostData<{ id: string }>(api, `/api/reimbursements/${created.id}/items`, { expenseTypeId, expenseDate: reimbursementDate, amount: "10.00", description, }); const document = await requireServer("pdf-lib").PDFDocument.create(); document.addPage().drawText(`Synthetic R75 notification ${label} ${Date.now()}`, { x: 40, y: 700 }); const upload = await readApiData( await api.post("/api/invoice-pool/upload", { multipart: { file: { name: `notify-${label}.pdf`, mimeType: "application/pdf", buffer: Buffer.from(await document.save()), }, ocr_type: "general", }, }), ); await expect .poll( async () => { const invoice = await apiGetData(api, `/api/invoice-pool/${upload.invoicePoolId}`); return invoice.status; }, { message: `等待票据 ${upload.invoicePoolId} 进入可绑定状态`, timeout: 90_000, }, ) .toBe("available"); await apiPostData(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, { reimbursementId: created.id, linkedItemId: item.id, }); const response = await api.post(`/api/reimbursements/${created.id}/submit`, { data: {} }); const payload = await response.json() as { data?: SubmittedReimbursementSeed; error?: { code?: string; details?: { reasonCode?: string } } }; expect(response.ok(), JSON.stringify({ status: response.status(), code: payload.error?.code, reasonCode: payload.error?.details?.reasonCode, fields: Object.keys(payload.error?.details ?? {}) })).toBe(true); expect(payload.data).toBeDefined(); const submitted = payload.data!; return { reimbursementId: submitted.reimbursementId, formNumber: submitted.formNumber, currentTaskId: submitted.currentTaskId, }; } async function settleWorkflowTaskChain(managerApi: AuthedApi, financeApi: AuthedApi, taskId: string | null) { if (!taskId) { return; } try { const approved = await apiPostData<{ nextTaskId: string | null }>(managerApi, `/api/workflow/tasks/${taskId}/approve`, {}); if (approved.nextTaskId) { await apiPostData(financeApi, `/api/workflow/tasks/${approved.nextTaskId}/approve`, {}).catch(() => undefined); } } catch { // 清理阶段不覆盖主断言结果。 } } async function fetchEmployeeByKeyword( api: Awaited>["api"], keyword: string, ) { const encoded = encodeURIComponent(keyword); const payload = await apiGetData<{ items: EmployeeSummary[] }>( api, `/api/employees?page=1&pageSize=20&scope=management&q=${encoded}`, ); return payload.items.find((item) => item.employeeNo === keyword || item.email === keyword) ?? null; } test.describe("R75 自动化补盲", () => { test.describe.configure({ mode: "serial" }); test("E2E R75 考勤补盲:OA 自建打卡关闭 + 统一考勤入口", async ({ page }) => { test.setTimeout(240_000); const adminClient = await createAuthenticatedApiClient(ADMIN); const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); try { await ensureFeatureFlags(adminClient.api, ["attendance_unified_v1"]); const clockInResponse = await employeeClient.api.post("/api/attendance/clock-in", { data: {} }); expect(clockInResponse.status()).toBe(409); const clockInPayload = (await clockInResponse.json()) as { error?: { details?: { featureFlag?: string } } }; expect(clockInPayload.error?.details?.featureFlag).toBe("attendance_v1_disabled"); await login(page, EMPLOYEE); await page.goto("/attendance"); await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible(); await expect(page.getByText("企业微信主办打卡")).toBeVisible(); await expect(page.getByRole("button", { name: /点击打上班卡|点击下班|重新打下班卡/ })).toHaveCount(0); await expect(page.getByText("本月打卡记录")).toHaveCount(0); await page.getByRole("tab", { name: "修正申请" }).click(); await expect(page.getByRole("heading", { name: "企微处理入口" })).toBeVisible(); await expect(page.getByRole("button", { name: "提交应急修正" })).toHaveCount(0); await logout(page, EMPLOYEE.name); await login(page, ADMIN); await page.goto("/attendance"); await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible(); await expect(page.getByText("OA 展示月度摘要、异常解释、同步状态和审计记录")).toBeVisible(); await expect(page.getByRole("tab", { name: "修正申请" })).toBeVisible(); await page.getByRole("tab", { name: "修正申请" }).click(); await expect(page.getByRole("heading", { name: "OA 应急修正" })).toBeVisible(); await expect(page.getByRole("tab", { name: "团队考勤" })).toHaveCount(0); await expect(page.getByRole("button", { name: "保存补卡" })).toHaveCount(0); } finally { await Promise.all([employeeClient.dispose(), adminClient.dispose()]); } }); test("E2E R75 通知补盲:未读筛选、单条已读、全部已读与跳转", async ({ page }) => { test.setTimeout(240_000); const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); const managerClient = await createAuthenticatedApiClient(MANAGER); const financeClient = await createAuthenticatedApiClient(FINANCE); const suffix = Date.now(); let firstTaskId: string | null = null; let secondTaskId: string | null = null; try { await apiPostData<{ updatedCount: number }>(managerClient.api, "/api/notifications/mark-all-read", {}); const firstReimbursement = await createSubmittedReimbursement(employeeClient.api, `A-${suffix}`); const secondReimbursement = await createSubmittedReimbursement(employeeClient.api, `B-${suffix}`); firstTaskId = firstReimbursement.currentTaskId; secondTaskId = secondReimbursement.currentTaskId; await expect .poll( async () => { const payload = await apiGetData( managerClient.api, "/api/notifications?unreadOnly=1&type=workflow.task_assigned&page=1&pageSize=20", ); return payload.items.filter( (item) => item.title.includes(firstReimbursement.formNumber) || item.body?.includes(firstReimbursement.formNumber) || item.title.includes(secondReimbursement.formNumber) || item.body?.includes(secondReimbursement.formNumber), ).length; }, { timeout: 60_000, }, ) .toBeGreaterThanOrEqual(2); await login(page, MANAGER); await page.goto("/notifications?unreadOnly=1&type=workflow.task_assigned&page=1"); await expect(page.getByRole("heading", { name: "通知" })).toBeVisible(); const firstRow = page.getByTestId("notification-row").filter({ hasText: firstReimbursement.formNumber }).first(); const secondRow = page.getByTestId("notification-row").filter({ hasText: secondReimbursement.formNumber }).first(); await expect(firstRow).toContainText("未读"); await expect(secondRow).toContainText("未读"); await firstRow.getByTestId("notification-target-link").click(); await expect(page).toHaveURL(/\/workflow\/tasks\/[0-9a-f-]+/); await page.goto("/notifications?unreadOnly=1&type=workflow.task_assigned&page=1"); await expect(firstRow).toBeVisible(); await firstRow.getByTestId("notification-mark-read").click(); await expect(page.getByTestId("notification-row").filter({ hasText: firstReimbursement.formNumber })).toHaveCount(0); await page.getByTestId("notifications-mark-all-read").click(); await expect(page.getByText("已全部标为已读")).toBeVisible(); await expect(page.getByTestId("notification-row").filter({ hasText: secondReimbursement.formNumber })).toHaveCount(0); await page.getByTestId("notifications-filter-all").click(); await expect(page.getByTestId("notification-row").filter({ hasText: secondReimbursement.formNumber }).first()).toContainText("已读"); } finally { await settleWorkflowTaskChain(managerClient.api, financeClient.api, firstTaskId); await settleWorkflowTaskChain(managerClient.api, financeClient.api, secondTaskId); await Promise.all([employeeClient.dispose(), managerClient.dispose(), financeClient.dispose()]); } }); test("E2E R75 生命周期补盲:创建临时员工后发起入职并转正", async ({ page }) => { test.setTimeout(240_000); const adminClient = await createAuthenticatedApiClient(ADMIN); const suffix = Date.now(); const employeeNo = `PW-R75-${suffix}`; const email = `pw-r75-${suffix}@hyinda.com`; const phone = `139${String(suffix).slice(-8)}`; let createdEmployeeId: string | null = null; try { await ensureFeatureFlags(adminClient.api, ["employee_lifecycle_v1"]); const departments = await apiGetData(adminClient.api, "/api/departments?includeArchived=0"); const departmentId = departments.items[0]?.id; if (!departmentId) { throw new Error("未找到可用部门,无法创建生命周期测试员工"); } const created = await apiPostData(adminClient.api, "/api/users", { employeeNo, name: `Playwright 生命周期 ${suffix}`, phone, email, password: "PlaywrightTemp123!", departmentId, roles: ["employee"], }); createdEmployeeId = created.id; await login(page, ADMIN); await page.goto("/people"); await expect(page.getByRole("heading", { name: "人员管理" })).toBeVisible(); await page.getByPlaceholder("搜索姓名、工号、手机号、邮箱").fill(employeeNo); const row = page.getByRole("row").filter({ hasText: employeeNo }).first(); await expect(row).toBeVisible(); await row.getByRole("button", { name: "发起入职" }).click(); await expect(page.getByText("已发起入职流程")).toBeVisible(); await expect(row).toContainText("待入职"); await row.getByRole("button", { name: "转正" }).click(); await expect(page.getByText("已完成转正")).toBeVisible(); await expect(row).toContainText("在职"); await expect .poll(async () => { const employee = await fetchEmployeeByKeyword(adminClient.api, employeeNo); return employee?.employmentStatus ?? null; }) .toBe("active"); } finally { if (createdEmployeeId) { await apiPostData<{ id: string; status: string }>(adminClient.api, `/api/users/${createdEmployeeId}/archive`, {}); } await adminClient.dispose(); } }); test("E2E R75 治理补盲:功能开关备注可保存并恢复", async ({ page }) => { test.setTimeout(180_000); const adminClient = await createAuthenticatedApiClient(ADMIN); const flagKey = "ai_assist"; const updatedNote = `Playwright R75 配置写回 ${Date.now()}`; let originalFlag: FeatureFlagItem | null = null; try { const list = await apiGetData<{ items: FeatureFlagItem[] }>(adminClient.api, "/api/system/feature-flags"); originalFlag = list.items.find((item) => item.flagKey === flagKey) ?? null; if (!originalFlag) { throw new Error(`未找到功能开关 ${flagKey}`); } await login(page, ADMIN); await page.goto("/system/feature-flags"); await expect(page.getByRole("heading", { name: "功能管理" })).toBeVisible(); const row = page.getByTestId(`feature-flag-row-${flagKey}`); await expect(row).toBeVisible(); await row.locator("textarea").fill(updatedNote); await row.getByTestId(`feature-flag-save-${flagKey}`).click(); await expect(page.getByText("开关已更新")).toBeVisible(); await expect(row.locator("textarea")).toHaveValue(updatedNote); await expect .poll(async () => { const latest = await apiGetData<{ items: FeatureFlagItem[] }>(adminClient.api, "/api/system/feature-flags"); return latest.items.find((item) => item.flagKey === flagKey)?.note ?? ""; }) .toBe(updatedNote); } finally { if (originalFlag) { const latest = await apiGetData<{ items: FeatureFlagItem[] }>(adminClient.api, "/api/system/feature-flags"); const current = latest.items.find((item) => item.flagKey === flagKey); if (current) { await readApiData<{ item: FeatureFlagItem }>( await adminClient.api.put(`/api/system/feature-flags/${flagKey}`, { data: { overrideMode: originalFlag.overrideMode, rolloutType: originalFlag.rolloutType, departmentIds: originalFlag.departmentIds, note: originalFlag.note, expectedVersion: current.version, }, }), ); } } await adminClient.dispose(); } }); });