diff --git a/client/tests/e2e/helpers/auth.ts b/client/tests/e2e/helpers/auth.ts new file mode 100644 index 00000000..7cfe40b1 --- /dev/null +++ b/client/tests/e2e/helpers/auth.ts @@ -0,0 +1,150 @@ +import { expect, request as playwrightRequest, type APIRequestContext, type Page } from "@playwright/test"; + +export const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000"; + +export type E2EUser = { + identifier: string; + password: string; + name: string; +}; + +export const EMPLOYEE: E2EUser = { + identifier: "employee@hyinda.com", + password: "EmployeePassword1234", + name: "Employee", +}; + +export const MANAGER: E2EUser = { + identifier: "manager@hyinda.com", + password: "ManagerPassword1234", + name: "Manager", +}; + +export const FINANCE: E2EUser = { + identifier: "finance@hyinda.com", + password: "FinancePassword1234", + name: "Finance", +}; + +export const FINANCE_MGR: E2EUser = { + identifier: "finance_mgr@hyinda.com", + password: "FinanceMgrPassword1234", + name: "Finance Manager", +}; + +export type MeResponse = { + id: string; + employeeNo: string; + name: string; + email: string; + roleCodes: string[]; + permissions: string[]; +}; + +export type AuthenticatedApiClient = { + api: APIRequestContext; + accessToken: string; + me: MeResponse; + dispose: () => Promise; +}; + +type Envelope = { + data: T; + error?: { + code?: string; + message?: string; + details?: unknown; + }; +}; + +function buildDeviceFingerprint(user: E2EUser) { + return `pw-e2e-${user.identifier.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`; +} + +async function readJson(response: { ok(): boolean; status(): number; text(): Promise }) { + const rawText = await response.text(); + const parsed = rawText ? (JSON.parse(rawText) as Envelope) : ({ data: null } as Envelope); + if (!response.ok()) { + const errorCode = parsed.error?.code ?? `HTTP_${response.status()}`; + const errorMessage = parsed.error?.message ?? rawText; + throw new Error(`${errorCode}: ${errorMessage}`); + } + return parsed.data; +} + +export async function login(page: Page, user: E2EUser) { + await page.goto("/login"); + await page.getByTestId("login-identifier").fill(user.identifier); + await page.getByTestId("login-password").fill(user.password); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/dashboard/); +} + +export async function logout(page: Page, userDisplayName: string) { + try { + await page.getByRole("button", { name: userDisplayName, exact: true }).first().click({ force: true }); + await expect(page.getByRole("menuitem", { name: "退出登录" })).toBeVisible({ timeout: 5_000 }); + await page.getByRole("menuitem", { name: "退出登录" }).click(); + } catch { + await page.context().clearCookies(); + await page.evaluate(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + await page.goto("/login"); + } + await expect(page).toHaveURL(/\/login/); +} + +export async function createAuthenticatedApiClient(user: E2EUser, baseURL = E2E_BASE_URL): Promise { + const loginContext = await playwrightRequest.newContext({ + baseURL, + extraHTTPHeaders: { + "x-device-fingerprint": buildDeviceFingerprint(user), + }, + }); + + const loginResponse = await loginContext.post("/api/auth/login", { + data: { + identifier: user.identifier, + password: user.password, + }, + }); + + const loginData = await readJson<{ accessToken: string }>(loginResponse); + const accessToken = loginData.accessToken; + + const api = await playwrightRequest.newContext({ + baseURL, + extraHTTPHeaders: { + authorization: `Bearer ${accessToken}`, + }, + }); + + const meResponse = await api.get("/api/me"); + const me = await readJson(meResponse); + + return { + api, + accessToken, + me, + dispose: async () => { + await Promise.all([api.dispose(), loginContext.dispose()]); + }, + }; +} + +export async function apiGetData(api: APIRequestContext, path: string) { + const response = await api.get(path); + return readJson(response); +} + +export async function apiPostData(api: APIRequestContext, path: string, data: unknown) { + const response = await api.post(path, { data }); + return readJson(response); +} + +export async function apiPatchData(api: APIRequestContext, path: string, data: unknown) { + const response = await api.patch(path, { data }); + return readJson(response); +} diff --git a/client/tests/e2e/helpers/select.ts b/client/tests/e2e/helpers/select.ts new file mode 100644 index 00000000..7553b45b --- /dev/null +++ b/client/tests/e2e/helpers/select.ts @@ -0,0 +1,62 @@ +import { expect, type Locator } from "@playwright/test"; + +export async function selectOptionByText(locator: Locator, pattern: RegExp) { + await expect + .poll(async () => { + return locator.locator("option").evaluateAll((options, rawPattern) => { + const regexp = new RegExp(rawPattern as string); + return options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + label: (option as HTMLOptionElement).label, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled && regexp.test(option.label)) + .map((option) => option.value)[0] ?? ""; + }, pattern.source); + }) + .not.toBe(""); + + const optionValue = await locator.locator("option").evaluateAll((options, rawPattern) => { + const regexp = new RegExp(rawPattern as string); + return options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + label: (option as HTMLOptionElement).label, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled && regexp.test(option.label)) + .map((option) => option.value)[0] ?? ""; + }, pattern.source); + + await locator.selectOption(optionValue); +} + +export async function selectFirstBusinessOption(locator: Locator) { + await expect + .poll(async () => { + const values = await locator.locator("option").evaluateAll((options) => + options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled) + .map((option) => option.value), + ); + return values[0] ?? ""; + }) + .not.toBe(""); + + const values = await locator.locator("option").evaluateAll((options) => + options + .map((option) => ({ + value: (option as HTMLOptionElement).value, + disabled: (option as HTMLOptionElement).disabled, + })) + .filter((option) => option.value && !option.disabled) + .map((option) => option.value), + ); + + await locator.selectOption(values[0]); +} diff --git a/client/tests/e2e/helpers/travel.ts b/client/tests/e2e/helpers/travel.ts new file mode 100644 index 00000000..12bbcec2 --- /dev/null +++ b/client/tests/e2e/helpers/travel.ts @@ -0,0 +1,325 @@ +import { expect, type APIRequestContext, type Page } from "@playwright/test"; +import { apiGetData, apiPostData, createAuthenticatedApiClient, EMPLOYEE, type AuthenticatedApiClient, type E2EUser } from "./auth"; +import { selectFirstBusinessOption } from "./select"; +import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./workflow"; + +type MasterDataListResponse = { + items: T[]; +}; + +type LegalEntity = { + id: string; + name: string; +}; + +type CostCenter = { + id: string; + name: string; + code: string; +}; + +type TravelRequestDetail = { + id: string; + requestNo: string | null; +}; + +type TravelSubmitResponse = { + requestNo: string; +}; + +type TravelOrderImportResponse = { + items: Array<{ id: string; externalOrderNo: string }>; +}; + +type TravelAuthorizationImportResponse = { + items: Array<{ id: string; externalAuthNo: string }>; +}; + +type TravelTransactionImportResponse = { + items: Array<{ id: string; externalTxnId: string }>; + confirmedCount: number; +}; + +export type PreparedTravelRequest = { + requestId: string; + requestNo: string; + employeeId: string; + legalEntityId: string; + costCenterId: string; + purpose: string; + startDate: string; + endDate: string; +}; + +export type ImportedTravelPaymentSeed = { + orderId: string; + orderNo: string; + authorizationId: string; + authorizationNo: string; + transactionId: string; + transactionNo: string; +}; + +function todayDateString(offsetDays = 0) { + const date = new Date(); + date.setDate(date.getDate() + offsetDays); + return date.toISOString().slice(0, 10); +} + +function toDateTime(date: string, hour: number) { + return `${date}T${String(hour).padStart(2, "0")}:00:00.000Z`; +} + +export async function loadTravelMasterData(api: APIRequestContext) { + const legalEntities = await apiGetData>(api, "/api/master-data/legal-entities?enabled=true"); + const legalEntity = legalEntities.items[0]; + if (!legalEntity) { + throw new Error("缺少可用法体,无法创建差旅申请"); + } + + const costCenters = await apiGetData>( + api, + `/api/master-data/cost-centers?enabled=true&legalEntityId=${encodeURIComponent(legalEntity.id)}`, + ); + const costCenter = costCenters.items[0]; + if (!costCenter) { + throw new Error(`法体 ${legalEntity.name} 下缺少可用成本中心`); + } + + return { legalEntity, costCenter }; +} + +export async function createSubmittedTravelRequestViaApi(input: { + user?: E2EUser; + purposePrefix?: string; + amount?: string; + startOffsetDays?: number; + endOffsetDays?: number; +}) { + const client = await createAuthenticatedApiClient(input.user ?? EMPLOYEE); + const { legalEntity, costCenter } = await loadTravelMasterData(client.api); + const suffix = Date.now(); + const purpose = `${input.purposePrefix ?? "Playwright移动审批差旅"}-${suffix}`; + const startOffsetDays = input.startOffsetDays ?? 1; + const endOffsetDays = input.endOffsetDays ?? Math.max(startOffsetDays + 1, 2); + const startDate = todayDateString(startOffsetDays); + const endDate = todayDateString(endOffsetDays); + + const created = await apiPostData(client.api, "/api/travel-requests", { + purpose, + tripType: "domestic", + startDate, + endDate, + legalEntityId: legalEntity.id, + costCenterId: costCenter.id, + estimatedAmount: input.amount ?? "1288.00", + segments: [ + { + segmentRole: "outbound", + transportType: "flight", + fromCity: "深圳", + toCity: "上海", + departAt: toDateTime(startDate, 9), + arriveAt: toDateTime(startDate, 12), + }, + ], + }); + + const submitted = await apiPostData(client.api, `/api/travel-requests/${created.id}/submit`, {}); + + return { + client, + travelRequest: { + requestId: created.id, + requestNo: submitted.requestNo, + employeeId: client.me.id, + legalEntityId: legalEntity.id, + costCenterId: costCenter.id, + purpose, + startDate, + endDate, + } satisfies PreparedTravelRequest, + }; +} + +export async function approveTravelRequestViaApi(managerClient: AuthenticatedApiClient, requestId: string) { + const task = await waitForWorkflowTask(managerClient.api, { + entityId: requestId, + entityType: "travel_request", + }); + await approveWorkflowTaskViaApi(managerClient.api, task.id); + return task.id; +} + +export async function importTravelPaymentSeed(api: APIRequestContext, input: { + requestId: string; + employeeId: string; + amount: string; + orderNo?: string; + authorizationNo?: string; + transactionNo?: string; + occurredAtDate?: string; +}) { + const suffix = Date.now(); + const occurredAtDate = input.occurredAtDate ?? todayDateString(); + const orderNo = input.orderNo ?? `PW-ORDER-${suffix}`; + const authorizationNo = input.authorizationNo ?? `PW-AUTH-${suffix}`; + const transactionNo = input.transactionNo ?? `PW-TXN-${suffix}`; + + const orderImport = await apiPostData(api, "/api/travel/orders/import", { + orders: [ + { + source: "ctrip", + externalOrderNo: orderNo, + employeeId: input.employeeId, + travelRequestId: input.requestId, + amount: input.amount, + currency: "CNY", + occurredAt: occurredAtDate, + }, + ], + }); + + const order = orderImport.items[0]; + if (!order) { + throw new Error("差旅订单导入失败,未返回订单记录"); + } + + const authorizationImport = await apiPostData(api, "/api/travel/authorizations/import", { + authorizations: [ + { + source: "corp-card", + externalAuthNo: authorizationNo, + employeeId: input.employeeId, + travelRequestId: input.requestId, + authorizedAmount: input.amount, + currency: "CNY", + occurredAt: toDateTime(occurredAtDate, 8), + }, + ], + }); + + const authorization = authorizationImport.items[0]; + if (!authorization) { + throw new Error("企业卡授权导入失败,未返回授权记录"); + } + + const transactionImport = await apiPostData(api, "/api/travel/transactions/import", { + transactions: [ + { + source: "corp-card", + externalTxnId: transactionNo, + employeeId: input.employeeId, + travelRequestId: input.requestId, + cardAuthorizationId: authorization.id, + travelOrderId: order.id, + amount: input.amount, + currency: "CNY", + txnType: "charge", + status: "settled", + occurredAt: toDateTime(occurredAtDate, 9), + }, + ], + }); + + if (transactionImport.confirmedCount < 1) { + throw new Error("结算交易导入后未触发订单 confirmed 状态"); + } + + const transaction = transactionImport.items[0]; + if (!transaction) { + throw new Error("差旅交易导入失败,未返回交易记录"); + } + + return { + orderId: order.id, + orderNo, + authorizationId: authorization.id, + authorizationNo, + transactionId: transaction.id, + transactionNo, + } satisfies ImportedTravelPaymentSeed; +} + +export async function markTravelOrderPaidViaApi(api: APIRequestContext, input: { + orderId: string; + voucherNo: string; + paymentDate: string; + paymentChannel?: string; +}) { + return apiPostData>(api, `/api/travel-orders/${input.orderId}/mark-paid`, { + paymentVoucherNo: input.voucherNo, + paymentDate: input.paymentDate, + paymentChannel: input.paymentChannel ?? "bank_transfer", + }); +} + +export async function importOrphanTravelTransaction(api: APIRequestContext, input: { + requestId: string; + employeeId: string; + externalTxnId: string; + amount: string; + occurredAtDate?: string; +}) { + const occurredAtDate = input.occurredAtDate ?? todayDateString(); + return apiPostData(api, "/api/travel/transactions/import", { + transactions: [ + { + source: "corp-card", + externalTxnId: input.externalTxnId, + employeeId: input.employeeId, + travelRequestId: input.requestId, + amount: input.amount, + currency: "CNY", + txnType: "charge", + status: "settled", + occurredAt: toDateTime(occurredAtDate, 18), + }, + ], + }); +} + +export async function createAndSubmitTravelRequestViaUi(page: Page, input: { + purposePrefix: string; + amount: string; +}) { + const suffix = Date.now(); + const purpose = `${input.purposePrefix}-${suffix}`; + const startDate = todayDateString(1); + const endDate = todayDateString(2); + + await page.goto("/travel-requests?tab=requests"); + await expect(page.getByRole("heading", { name: "差旅管理" })).toBeVisible(); + + await page.locator("#travel-purpose").fill(purpose); + await page.locator("#travel-amount").fill(input.amount); + await page.locator("#travel-start-date").fill(startDate); + await page.locator("#travel-end-date").fill(endDate); + await selectFirstBusinessOption(page.locator("#travel-legal-entity")); + await selectFirstBusinessOption(page.locator("#travel-cost-center")); + const firstSegmentCard = page.locator(".rounded-xl.border.border-zinc-200").filter({ hasText: "第 1 段" }).first(); + await firstSegmentCard.locator("input").nth(0).fill("深圳"); + await firstSegmentCard.locator("input").nth(1).fill("上海"); + await firstSegmentCard.locator("input").nth(2).fill(`${startDate}T09:00`); + await firstSegmentCard.locator("input").nth(3).fill(`${startDate}T12:00`); + await page.getByTestId("travel-request-save").click(); + + await expect(page).toHaveURL(/\/travel-requests\?tab=requests&id=/); + const url = new URL(page.url()); + const requestId = url.searchParams.get("id"); + if (!requestId) { + throw new Error(`保存差旅草稿后未拿到 requestId: ${page.url()}`); + } + + await page.getByTestId("travel-request-submit").click(); + await expect(page.getByTestId(`travel-request-item-${requestId}`)).toContainText("审批中"); + const requestNo = (await page.locator(".card-std h3").first().textContent())?.trim() ?? requestId; + + return { + requestId, + requestNo, + purpose, + startDate, + endDate, + }; +} diff --git a/client/tests/e2e/helpers/workflow.ts b/client/tests/e2e/helpers/workflow.ts new file mode 100644 index 00000000..f4637348 --- /dev/null +++ b/client/tests/e2e/helpers/workflow.ts @@ -0,0 +1,49 @@ +import type { APIRequestContext } from "@playwright/test"; +import { apiGetData, apiPostData } from "./auth"; + +type WorkflowTaskSummary = { + id: string; + workflowInstance: { + entityType: string; + entityId: string; + } | null; +}; + +type WorkflowTaskListResponse = { + items: WorkflowTaskSummary[]; +}; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForWorkflowTask(api: APIRequestContext, input: { + entityId: string; + entityType: string; + attempts?: number; + intervalMs?: number; +}) { + const attempts = input.attempts ?? 20; + const intervalMs = input.intervalMs ?? 1000; + + for (let index = 0; index < attempts; index += 1) { + const response = await apiGetData(api, "/api/workflow/tasks?status=pending&page=1&pageSize=50"); + const matched = response.items.find( + (item) => item.workflowInstance?.entityType === input.entityType && item.workflowInstance.entityId === input.entityId, + ); + if (matched) { + return matched; + } + await sleep(intervalMs); + } + + throw new Error(`未在待办列表中找到 ${input.entityType}:${input.entityId}`); +} + +export async function approveWorkflowTaskViaApi(api: APIRequestContext, taskId: string) { + return apiPostData>(api, `/api/workflow/tasks/${taskId}/approve`, {}); +} + +export async function rejectWorkflowTaskViaApi(api: APIRequestContext, taskId: string, comment: string) { + return apiPostData>(api, `/api/workflow/tasks/${taskId}/reject`, { comment }); +} diff --git a/client/tests/e2e/main-flow.spec.ts b/client/tests/e2e/main-flow.spec.ts index 388d5393..561573b6 100644 --- a/client/tests/e2e/main-flow.spec.ts +++ b/client/tests/e2e/main-flow.spec.ts @@ -62,7 +62,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 const popupPromise = page.waitForEvent("popup"); const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceId }).first(); await expect(invoiceRow).toBeVisible(); - await invoiceRow.getByTestId("invoice-preview-button").click(); + await invoiceRow.getByRole("button").click(); + await page.getByTestId("invoice-preview-button").click(); const popup = await popupPromise; await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`)); await popup.close(); @@ -75,7 +76,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 // 4) submit reimbursement await page.goto(`/reimbursements/${reimbursementId}`); await page.getByTestId("reimbursement-submit").click(); - await expect(page.getByText("状态:reviewing")).toBeVisible(); + await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible(); // 5) manager approve await logout(page, EMPLOYEE.name); @@ -87,7 +88,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 await expect(managerTaskRow).toBeVisible(); await managerTaskRow.getByTestId("workflow-task-detail-link").click(); await page.getByTestId("workflow-task-approve").click(); - await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:completed"); + await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/); await logout(page, MANAGER.name); @@ -98,13 +99,13 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 await expect(financeTaskRow).toBeVisible(); await financeTaskRow.getByTestId("workflow-task-detail-link").click(); await page.getByTestId("workflow-task-approve").click(); - await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:completed"); + await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/); await page.goto(`/reimbursements/${reimbursementId}`); - await expect(page.getByText("状态:approved")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 }); await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`); await page.getByTestId("reimbursement-pay").click(); - await expect(page.getByText("状态:paid")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 }); await logout(page, FINANCE.name); diff --git a/client/tests/e2e/mobile-approvals-offline.spec.ts b/client/tests/e2e/mobile-approvals-offline.spec.ts new file mode 100644 index 00000000..0d10fa5e --- /dev/null +++ b/client/tests/e2e/mobile-approvals-offline.spec.ts @@ -0,0 +1,110 @@ +import { devices, expect, test } from "@playwright/test"; +import { createAuthenticatedApiClient, login, MANAGER } from "./helpers/auth"; +import { createSubmittedTravelRequestViaApi } from "./helpers/travel"; +import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "./helpers/workflow"; + +async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, taskId: string) { + await expect + .poll( + async () => { + return page.evaluate(async (currentTaskId) => { + const request = window.indexedDB.open("hyb-oa-mobile-workflow", 1); + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed")); + }); + + const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache"); + const detail = await new Promise((resolve, reject) => { + const query = store.get(currentTaskId); + query.onsuccess = () => resolve(query.result ?? null); + query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed")); + }); + + db.close(); + return Boolean(detail); + }, taskId); + }, + { timeout: 15_000 }, + ) + .toBe(true); +} + +test.use({ + ...devices["iPhone 13"], + serviceWorkers: "block", + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1", +}); + +test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }) => { + const created = await createSubmittedTravelRequestViaApi({ + purposePrefix: "Playwright移动审批通过", + amount: "999.00", + }); + const managerApi = await createAuthenticatedApiClient(MANAGER); + + try { + const task = await waitForWorkflowTask(managerApi.api, { + entityId: created.travelRequest.requestId, + entityType: "travel_request", + }); + + await login(page, MANAGER); + await page.goto(`/workflow/tasks/${task.id}`); + await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`)); + await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("mobile-approvals-detail")).toContainText(created.travelRequest.requestNo); + await waitForWorkflowTaskCache(page, task.id); + + await page.context().setOffline(true); + await expect(page.getByText("当前离线")).toBeVisible(); + await expect(page.getByTestId("mobile-approvals-approve")).toBeEnabled({ timeout: 15_000 }); + await page.getByTestId("mobile-approvals-approve").click(); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待处理 1 项"); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("通过"); + + await page.context().setOffline(false); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无待处理项", { timeout: 30_000 }); + await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 }); + } finally { + await Promise.all([created.client.dispose(), managerApi.dispose()]); + } +}); + +test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => { + const created = await createSubmittedTravelRequestViaApi({ + purposePrefix: "Playwright移动审批冲突", + amount: "1099.00", + }); + const managerApi = await createAuthenticatedApiClient(MANAGER); + + try { + const task = await waitForWorkflowTask(managerApi.api, { + entityId: created.travelRequest.requestId, + entityType: "travel_request", + }); + + await login(page, MANAGER); + await page.goto(`/workflow/tasks/${task.id}`); + await expect(page).toHaveURL(new RegExp(`/mobile/approvals\\?taskId=${task.id}`)); + await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible({ timeout: 30_000 }); + await waitForWorkflowTaskCache(page, task.id); + + await page.context().setOffline(true); + await expect(page.getByTestId("mobile-approvals-reject")).toBeEnabled({ timeout: 15_000 }); + await page.getByTestId("mobile-approvals-comment").fill("离线驳回意见:请补充差旅安排说明"); + await page.getByTestId("mobile-approvals-reject").click(); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("待处理 1 项"); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("驳回"); + + await approveWorkflowTaskViaApi(managerApi.api, task.id); + + await page.context().setOffline(false); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 }); + await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误:", { timeout: 30_000 }); + await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible(); + await expect(page.getByRole("button", { name: "丢弃草稿" })).toBeVisible(); + } finally { + await Promise.all([created.client.dispose(), managerApi.dispose()]); + } +}); diff --git a/client/tests/e2e/travel-payment-flow.spec.ts b/client/tests/e2e/travel-payment-flow.spec.ts new file mode 100644 index 00000000..4590404c --- /dev/null +++ b/client/tests/e2e/travel-payment-flow.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; +import { createAuthenticatedApiClient, EMPLOYEE, FINANCE_MGR, login, logout, MANAGER } from "./helpers/auth"; +import { createAndSubmitTravelRequestViaUi, importTravelPaymentSeed } from "./helpers/travel"; + +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]; +} + +function extractInvoiceCode(text: string) { + const match = text.match(/发票号:([A-Z0-9]+)-/); + if (!match?.[1]) { + throw new Error(`Failed to parse invoice code from text: ${text}`); + } + return match[1]; +} + +test("E2E 差旅支付主链路:申请→审批→支付→绑票", async ({ page }) => { + const employeeApi = await createAuthenticatedApiClient(EMPLOYEE); + const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); + + try { + await login(page, EMPLOYEE); + const draft = await createAndSubmitTravelRequestViaUi(page, { + purposePrefix: "Playwright差旅支付", + amount: "1888.00", + }); + + await logout(page, EMPLOYEE.name); + await login(page, MANAGER); + await page.goto("/workflow/tasks?status=pending&page=1"); + const taskRow = page.getByTestId("workflow-task-row").filter({ hasText: draft.requestId }).first(); + await expect(taskRow).toBeVisible({ timeout: 30_000 }); + await taskRow.getByTestId("workflow-task-detail-link").click(); + await page.getByTestId("workflow-task-approve").click(); + await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:"); + await expect(page.getByTestId("workflow-task-summary")).toContainText("已完成"); + await logout(page, MANAGER.name); + + const paymentSeed = await importTravelPaymentSeed(financeMgrApi.api, { + requestId: draft.requestId, + employeeId: employeeApi.me.id, + amount: "1888.00", + occurredAtDate: draft.startDate, + }); + + await login(page, FINANCE_MGR); + await page.goto(`/travel-requests?tab=requests&id=${draft.requestId}`); + const orderCard = page.locator(".rounded-xl.border.border-zinc-200").filter({ hasText: paymentSeed.orderNo }).last(); + await expect(orderCard).toBeVisible({ timeout: 30_000 }); + await expect(orderCard).toContainText("已匹配交易"); + await orderCard.getByRole("button", { name: "标记已支付" }).click(); + await page.locator("#travel-order-payment-voucher").fill(`BANK-${Date.now()}`); + await page.locator("#travel-order-payment-date").fill(draft.startDate); + await page.getByRole("button", { name: "确认已支付" }).click(); + await expect(orderCard).toContainText("已支付", { timeout: 30_000 }); + await expect(orderCard).not.toContainText("标记已支付"); + + await page.goto("/invoice-pool"); + const invoiceFileName = `travel-payment-${Date.now()}.png`; + await page.getByTestId("invoice-upload-file").setInputFiles({ + name: invoiceFileName, + mimeType: "image/png", + buffer: Buffer.from(`TRAVEL_PAYMENT_${Date.now()}`, "utf8"), + }); + await page.getByTestId("invoice-upload-submit").click(); + await expect(page.getByTestId("invoice-detail-panel")).toBeVisible(); + await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:pending_ocr"); + 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"); + const invoiceCode = extractInvoiceCode(invoiceDetailText); + + await page.reload(); + await expect(page.getByTestId("invoice-pool-table")).toBeVisible(); + const bindCard = page.locator(".card-std").filter({ hasText: "绑定业务单据" }).first(); + await bindCard.getByRole("combobox").first().selectOption("travelOrder"); + await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId); + await page.getByTestId("invoice-bind-travel-request").selectOption(draft.requestId); + await page.getByTestId("invoice-bind-travel-order").selectOption(paymentSeed.orderId); + const bindResponsePromise = page.waitForResponse((response) => response.url().includes(`/api/invoice-pool/${invoiceId}/bind`) && response.request().method() === "POST"); + await page.getByTestId("invoice-bind-submit").click(); + const bindResponse = await bindResponsePromise; + expect(bindResponse.ok()).toBeTruthy(); + await expect(page.getByText("绑定成功")).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/travel-requests?tab=requests&id=${draft.requestId}`); + const paidOrderCard = page.locator(".rounded-xl.border.border-zinc-200").filter({ hasText: paymentSeed.orderNo }).last(); + await expect(paidOrderCard).toContainText("已支付"); + await expect(paidOrderCard).not.toContainText("当前没有已绑定发票。", { timeout: 30_000 }); + await expect(paidOrderCard).toContainText(invoiceCode); + await expect(paidOrderCard).toContainText("ERP 状态:"); + + await logout(page, FINANCE_MGR.name); + } finally { + await Promise.all([employeeApi.dispose(), financeMgrApi.dispose()]); + } +}); diff --git a/client/tests/e2e/travel-reconciliation-flow.spec.ts b/client/tests/e2e/travel-reconciliation-flow.spec.ts new file mode 100644 index 00000000..f2245051 --- /dev/null +++ b/client/tests/e2e/travel-reconciliation-flow.spec.ts @@ -0,0 +1,122 @@ +import { expect, test } from "@playwright/test"; +import { apiGetData, createAuthenticatedApiClient, FINANCE_MGR, MANAGER, login } from "./helpers/auth"; +import { + approveTravelRequestViaApi, + createSubmittedTravelRequestViaApi, + importOrphanTravelTransaction, + importTravelPaymentSeed, + markTravelOrderPaidViaApi, +} from "./helpers/travel"; + +type ReconciliationBatchDetailResponse = { + summary: { + pendingItems: number; + }; +}; + +test("E2E 差旅对账批次:创建→确认异常→收口", async ({ page }) => { + const managerApi = await createAuthenticatedApiClient(MANAGER); + const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); + const isolatedStartOffsetDays = 45 + Math.floor(Math.random() * 120); + const created = await createSubmittedTravelRequestViaApi({ + purposePrefix: "Playwright差旅对账", + amount: "1666.00", + startOffsetDays: isolatedStartOffsetDays, + endOffsetDays: isolatedStartOffsetDays + 1, + }); + + try { + await approveTravelRequestViaApi(managerApi, created.travelRequest.requestId); + const paymentSeed = await importTravelPaymentSeed(financeMgrApi.api, { + requestId: created.travelRequest.requestId, + employeeId: created.travelRequest.employeeId, + amount: "1666.00", + occurredAtDate: created.travelRequest.startDate, + }); + await markTravelOrderPaidViaApi(financeMgrApi.api, { + orderId: paymentSeed.orderId, + voucherNo: `RECON-${Date.now()}`, + paymentDate: created.travelRequest.startDate, + }); + const orphanTxnNo = `PW-ORPHAN-${Date.now()}`; + await importOrphanTravelTransaction(financeMgrApi.api, { + requestId: created.travelRequest.requestId, + employeeId: created.travelRequest.employeeId, + externalTxnId: orphanTxnNo, + amount: "88.00", + occurredAtDate: created.travelRequest.startDate, + }); + + await login(page, FINANCE_MGR); + await page.goto("/travel-requests?tab=reconciliation"); + await expect(page.getByRole("heading", { name: "差旅管理" })).toBeVisible(); + await page.locator("#travel-recon-create-legal-entity").selectOption(created.travelRequest.legalEntityId); + await page.locator("#travel-recon-create-cost-center").selectOption(created.travelRequest.costCenterId); + await page.locator("#travel-recon-create-period-start").fill(created.travelRequest.startDate); + await page.locator("#travel-recon-create-period-end").fill(created.travelRequest.endDate); + const createBatchResponsePromise = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/travel/reconciliation/batches" && + response.ok(), + ); + await page.getByRole("button", { name: "创建批次" }).click(); + const createBatchResponse = await createBatchResponsePromise; + const createBatchPayload = (await createBatchResponse.json()) as { data?: { id?: string } }; + const batchId = createBatchPayload.data?.id; + if (!batchId) { + throw new Error(`创建批次后未返回 batchId: ${JSON.stringify(createBatchPayload)}`); + } + + await expect(page).toHaveURL(new RegExp(`batchId=${batchId}`), { timeout: 30_000 }); + + const detailSection = () => page.locator(".card-std").filter({ hasText: "异常与明细" }).first(); + const orphanItemCard = () => page.locator(".rounded-xl.border.border-zinc-200").filter({ hasText: orphanTxnNo }).first(); + const fetchBatchDetail = () => + apiGetData( + financeMgrApi.api, + `/api/travel/reconciliation/batches/${batchId}`, + ); + + await expect(page.getByText("ERP 摘要桥")).toBeVisible(); + await expect(page.getByText("异常与明细")).toBeVisible(); + await expect(orphanItemCard()).toBeVisible({ timeout: 30_000 }); + await expect(orphanItemCard()).toContainText("待确认"); + + let pendingConfirmCount = (await fetchBatchDetail()).summary.pendingItems; + while (pendingConfirmCount > 0) { + page.once("dialog", async (dialog) => { + await dialog.accept("Playwright 已确认孤儿交易"); + }); + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/travel\/reconciliation\/items\/[^/]+\/confirm$/.test(new URL(response.url()).pathname) && + response.ok(), + ), + detailSection().getByRole("button", { name: "确认差异" }).first().click(), + ]); + await expect + .poll(async () => (await fetchBatchDetail()).summary.pendingItems, { + timeout: 30_000, + }) + .toBe(pendingConfirmCount - 1); + pendingConfirmCount -= 1; + await page.reload(); + await expect(page).toHaveURL(new RegExp(`batchId=${batchId}`)); + await expect(page.getByText("异常与明细")).toBeVisible(); + } + + await expect(orphanItemCard()).toContainText("已确认", { timeout: 30_000 }); + + await page.getByRole("button", { name: "Finalize" }).click(); + await expect(page.getByText("阶段状态").locator("..")).toContainText(/已入队|已发送|已跳过|未触发/, { + timeout: 30_000, + }); + await expect(page.getByText("阶段状态")).toBeVisible(); + await expect(page.getByText("应付摘要")).toBeVisible(); + } finally { + await Promise.all([created.client.dispose(), managerApi.dispose(), financeMgrApi.dispose()]); + } +});