326 行
9.9 KiB
TypeScript
326 行
9.9 KiB
TypeScript
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,
|
|
};
|
|
}
|