test(P22PROD1-5): 差旅支付与对账E2E补齐及稳态修复

这个提交包含在:
2026-03-12 21:48:47 +08:00
父节点 c069bd5ad8
当前提交 956f77f8d4
共修改 8 个文件,包含 926 行新增和 6 行删除
+150
查看文件
@@ -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: "[email protected]",
password: "EmployeePassword1234",
name: "Employee",
};
export const MANAGER: E2EUser = {
identifier: "[email protected]",
password: "ManagerPassword1234",
name: "Manager",
};
export const FINANCE: E2EUser = {
identifier: "[email protected]",
password: "FinancePassword1234",
name: "Finance",
};
export const FINANCE_MGR: E2EUser = {
identifier: "[email protected]",
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<void>;
};
type Envelope<T> = {
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<T>(response: { ok(): boolean; status(): number; text(): Promise<string> }) {
const rawText = await response.text();
const parsed = rawText ? (JSON.parse(rawText) as Envelope<T>) : ({ data: null } as Envelope<T>);
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<AuthenticatedApiClient> {
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>(meResponse);
return {
api,
accessToken,
me,
dispose: async () => {
await Promise.all([api.dispose(), loginContext.dispose()]);
},
};
}
export async function apiGetData<T>(api: APIRequestContext, path: string) {
const response = await api.get(path);
return readJson<T>(response);
}
export async function apiPostData<T>(api: APIRequestContext, path: string, data: unknown) {
const response = await api.post(path, { data });
return readJson<T>(response);
}
export async function apiPatchData<T>(api: APIRequestContext, path: string, data: unknown) {
const response = await api.patch(path, { data });
return readJson<T>(response);
}
+62
查看文件
@@ -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]);
}
+325
查看文件
@@ -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<T> = {
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<MasterDataListResponse<LegalEntity>>(api, "/api/master-data/legal-entities?enabled=true");
const legalEntity = legalEntities.items[0];
if (!legalEntity) {
throw new Error("缺少可用法体,无法创建差旅申请");
}
const costCenters = await apiGetData<MasterDataListResponse<CostCenter>>(
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<TravelRequestDetail>(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<TravelSubmitResponse>(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<TravelOrderImportResponse>(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<TravelAuthorizationImportResponse>(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<TravelTransactionImportResponse>(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<Record<string, unknown>>(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<TravelTransactionImportResponse>(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,
};
}
+49
查看文件
@@ -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<WorkflowTaskListResponse>(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<Record<string, unknown>>(api, `/api/workflow/tasks/${taskId}/approve`, {});
}
export async function rejectWorkflowTaskViaApi(api: APIRequestContext, taskId: string, comment: string) {
return apiPostData<Record<string, unknown>>(api, `/api/workflow/tasks/${taskId}/reject`, { comment });
}