test(P22PROD1-5): 差旅支付与对账E2E补齐及稳态修复
这个提交包含在:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -62,7 +62,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
const popupPromise = page.waitForEvent("popup");
|
const popupPromise = page.waitForEvent("popup");
|
||||||
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceId }).first();
|
const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceId }).first();
|
||||||
await expect(invoiceRow).toBeVisible();
|
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;
|
const popup = await popupPromise;
|
||||||
await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`));
|
await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`));
|
||||||
await popup.close();
|
await popup.close();
|
||||||
@@ -75,7 +76,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
// 4) submit reimbursement
|
// 4) submit reimbursement
|
||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await page.getByTestId("reimbursement-submit").click();
|
await page.getByTestId("reimbursement-submit").click();
|
||||||
await expect(page.getByText("状态:reviewing")).toBeVisible();
|
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
|
||||||
|
|
||||||
// 5) manager approve
|
// 5) manager approve
|
||||||
await logout(page, EMPLOYEE.name);
|
await logout(page, EMPLOYEE.name);
|
||||||
@@ -87,7 +88,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
await expect(managerTaskRow).toBeVisible();
|
await expect(managerTaskRow).toBeVisible();
|
||||||
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
|
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
|
||||||
await page.getByTestId("workflow-task-approve").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);
|
await logout(page, MANAGER.name);
|
||||||
|
|
||||||
@@ -98,13 +99,13 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
await expect(financeTaskRow).toBeVisible();
|
await expect(financeTaskRow).toBeVisible();
|
||||||
await financeTaskRow.getByTestId("workflow-task-detail-link").click();
|
await financeTaskRow.getByTestId("workflow-task-detail-link").click();
|
||||||
await page.getByTestId("workflow-task-approve").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 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-voucher-no").fill(`E2E-${Date.now()}`);
|
||||||
await page.getByTestId("reimbursement-pay").click();
|
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);
|
await logout(page, FINANCE.name);
|
||||||
|
|
||||||
|
|||||||
@@ -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<IDBDatabase>((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<unknown>((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()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<ReconciliationBatchDetailResponse>(
|
||||||
|
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()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户