374 行
12 KiB
TypeScript
374 行
12 KiB
TypeScript
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
|
|
function envelope(data: unknown) {
|
|
return { requestId: "mock-r102-reimbursement", data };
|
|
}
|
|
|
|
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(envelope(data)),
|
|
});
|
|
}
|
|
|
|
const me = {
|
|
id: "employee-r102",
|
|
employeeNo: "E102",
|
|
name: "Employee",
|
|
phone: "13800000000",
|
|
email: "[email protected]",
|
|
departmentId: "dept-r102",
|
|
directManagerId: null,
|
|
status: "active",
|
|
notificationPushEnabled: true,
|
|
roleCodes: ["employee"],
|
|
profileCompleteness: {
|
|
hasEmergencyContact: true,
|
|
hasBankAccount: true,
|
|
hasAvatar: false,
|
|
hasIdNumber: false,
|
|
score: 50,
|
|
},
|
|
wecomBinding: null,
|
|
};
|
|
|
|
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
|
const itemId = "22222222-2222-4222-8222-222222222222";
|
|
const invoiceId = "33333333-3333-4333-8333-333333333333";
|
|
const formNumber = "BX-20260506-0001";
|
|
|
|
function navigationItem(key: string, href: string, label: string, icon: string) {
|
|
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
|
}
|
|
|
|
function reimbursementListItem() {
|
|
return {
|
|
id: reimbursementId,
|
|
formNumber,
|
|
status: "draft",
|
|
reimbursementDate: "2026-05-06",
|
|
totalAmount: "168.30",
|
|
description: "客户拜访交通费",
|
|
payment: {
|
|
paymentVoucherNo: null,
|
|
paymentDate: null,
|
|
paymentChannel: null,
|
|
paidAt: null,
|
|
paidBy: null,
|
|
},
|
|
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
|
project: null,
|
|
pendingWorkflow: null,
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
updatedAt: "2026-05-06T09:10:00.000Z",
|
|
};
|
|
}
|
|
|
|
function reimbursementDetail() {
|
|
return {
|
|
id: reimbursementId,
|
|
formNumber,
|
|
submitterId: me.id,
|
|
ownerId: me.id,
|
|
departmentId: me.departmentId,
|
|
department: { id: "dept-r102", name: "研发部" },
|
|
status: "draft",
|
|
createdAt: "2026-05-06T09:00:00.000Z",
|
|
submittedAt: null,
|
|
reimbursementDate: "2026-05-06",
|
|
totalAmount: "168.30",
|
|
description: "客户拜访交通费",
|
|
projectId: null,
|
|
linkedTravelRequestId: null,
|
|
linkedTravelRequest: null,
|
|
project: null,
|
|
owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
|
|
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
|
|
items: [
|
|
{
|
|
id: itemId,
|
|
expenseTypeId: "expense-transport",
|
|
expenseDate: "2026-05-06",
|
|
amount: "168.30",
|
|
description: "客户拜访交通费",
|
|
expenseType: {
|
|
id: "expense-transport",
|
|
code: "transport",
|
|
name: "交通费",
|
|
category: "travel",
|
|
enabled: true,
|
|
},
|
|
},
|
|
],
|
|
invoices: [],
|
|
budgetHint: null,
|
|
pendingWorkflow: null,
|
|
workflowProgress: null,
|
|
approvalHistory: [],
|
|
riskFlags: [],
|
|
};
|
|
}
|
|
|
|
async function mockApis(page: Page) {
|
|
let draftExists = true;
|
|
|
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const path = url.pathname;
|
|
|
|
if (path === "/api/auth/sso/providers") {
|
|
await fulfillJson(route, []);
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/auth/wecom/config") {
|
|
await fulfillJson(route, {
|
|
enabled: false,
|
|
entryEnabled: false,
|
|
oauthEnabled: false,
|
|
corpId: "",
|
|
agentId: "",
|
|
loginUrl: null,
|
|
bindEnabled: false,
|
|
defaultReturnTo: "/portal",
|
|
status: "disabled",
|
|
statusReasons: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
|
body: JSON.stringify(envelope({ accessToken: "mock-r102-token", expiresIn: 900, mustChangePassword: false })),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/me") {
|
|
await fulfillJson(route, me);
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/system/navigation") {
|
|
await fulfillJson(route, {
|
|
workspace: {
|
|
id: "workspace-r102",
|
|
persisted: true,
|
|
profile: "small_team_internal",
|
|
orgMode: "single_company",
|
|
primaryEntryMode: "password",
|
|
notificationStrategy: "in_app",
|
|
devicePriority: "desktop",
|
|
moduleVisibility: {},
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
updatedBy: null,
|
|
},
|
|
sections: {
|
|
main: [
|
|
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
|
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
|
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
|
],
|
|
admin: [],
|
|
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
|
|
},
|
|
recommendedRolePresets: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/portal/home") {
|
|
await fulfillJson(route, {
|
|
workspace: { id: "workspace-r102", persisted: true, profile: "small_team_internal" },
|
|
greeting: { name: me.name },
|
|
cards: [],
|
|
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
|
myApplications: [{ domain: "reimbursement", count: draftExists ? 1 : 0, href: "/reimbursements" }],
|
|
riskSummary: null,
|
|
announcements: [],
|
|
quickLinks: [],
|
|
todayPresence: null,
|
|
teamPresence: null,
|
|
weeklyReport: null,
|
|
weeklySummary: null,
|
|
todoSummary: null,
|
|
projectSummary: null,
|
|
peopleLifecycle: null,
|
|
adoptionSummary: null,
|
|
recentActivities: [],
|
|
teamStatusBoard: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/reimbursement-templates") {
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/reimbursements/linkable-travel-requests") {
|
|
await fulfillJson(route, { ownerEmployeeId: me.id, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/reimbursements" && request.method() === "GET") {
|
|
const items = draftExists ? [reimbursementListItem()] : [];
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: "own", items });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/reimbursements" && request.method() === "POST") {
|
|
draftExists = true;
|
|
await fulfillJson(route, {
|
|
id: reimbursementId,
|
|
formNumber,
|
|
submitterId: me.id,
|
|
ownerId: me.id,
|
|
reimbursementDate: "2026-05-06",
|
|
status: "draft",
|
|
description: null,
|
|
projectId: null,
|
|
project: null,
|
|
totalAmount: "0",
|
|
linkedTravelRequestId: null,
|
|
linkedTravelRequest: null,
|
|
items: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
|
await fulfillJson(route, reimbursementDetail());
|
|
return;
|
|
}
|
|
|
|
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "DELETE") {
|
|
draftExists = false;
|
|
await fulfillJson(route, { reimbursementId, outcome: "deleted", unboundInvoiceCount: 0 });
|
|
return;
|
|
}
|
|
|
|
if (path === `/api/reimbursements/${reimbursementId}/precheck`) {
|
|
await fulfillJson(route, { decision: "pass", standardChecks: [], riskFlags: [] });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/expense-types") {
|
|
await fulfillJson(route, { items: [] });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/projects") {
|
|
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/invoice-pool/bindable") {
|
|
await fulfillJson(route, {
|
|
items: [
|
|
{
|
|
id: invoiceId,
|
|
invoiceCode: "26112000000938423851",
|
|
invoiceNumber: "38423851",
|
|
invoiceDate: "2026-05-06",
|
|
sellerName: "北京悦海堂餐饮管理有限公司",
|
|
totalAmount: "168.30",
|
|
originalName: "发票.pdf",
|
|
createdAt: "2026-05-06T09:30:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path === `/api/invoice-pool/${invoiceId}/bind`) {
|
|
await fulfillJson(route, { outcome: "bound" });
|
|
return;
|
|
}
|
|
|
|
if (path === "/api/notifications") {
|
|
await fulfillJson(route, { items: [], total: 0, page: 1, pageSize: 5, unreadCount: 0 });
|
|
return;
|
|
}
|
|
|
|
await fulfillJson(route, {});
|
|
});
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await page.goto("/login");
|
|
await page.getByTestId("login-identifier").fill("[email protected]");
|
|
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
|
await page.getByTestId("login-submit").click();
|
|
await expect(page).toHaveURL(/\/portal/);
|
|
}
|
|
|
|
test.describe("R102 reimbursement main flow", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockApis(page);
|
|
await login(page);
|
|
});
|
|
|
|
test("PC 列表显示业务编号并可删除草稿", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
await page.goto("/reimbursements?status=draft");
|
|
|
|
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
|
|
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "删除" }).first().click();
|
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
|
|
await page.getByTestId("action-confirm-submit").click();
|
|
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
|
|
await expect(page.getByRole("cell", { name: "暂无数据" })).toBeVisible();
|
|
});
|
|
|
|
test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 960 });
|
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
|
|
|
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
|
|
await expect(page.getByText("报销ID")).toHaveCount(0);
|
|
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
|
|
|
|
await page.getByTestId("reimbursement-invoice-add").click();
|
|
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
|
await expect(page.getByTestId("reimbursement-invoice-dialog")).toContainText("添加后自动生成或匹配报销明细");
|
|
await expect(page.getByText("绑定整单")).toHaveCount(0);
|
|
await expect(page.getByText("不绑定明细")).toHaveCount(0);
|
|
await expect(page.getByTestId(`reimbursement-invoice-linked-item-${invoiceId}`)).toHaveCount(0);
|
|
const uploadInput = page.getByTestId("reimbursement-invoice-upload-file");
|
|
await expect(uploadInput).toHaveAttribute("multiple", "");
|
|
await expect(page.getByRole("button", { name: "上传发票" })).toBeVisible();
|
|
|
|
await page.getByRole("checkbox").first().check();
|
|
await expect(page.getByText("已选 1 张发票")).toBeVisible();
|
|
|
|
const bindResponsePromise = page.waitForResponse((response) => {
|
|
const url = new URL(response.url());
|
|
return response.request().method() === "POST" && url.pathname === `/api/invoice-pool/${invoiceId}/bind` && response.ok();
|
|
});
|
|
await page.getByRole("button", { name: "添加选中发票" }).click();
|
|
await bindResponsePromise;
|
|
await expect(page.getByTestId("reimbursement-invoice-dialog")).toHaveCount(0);
|
|
});
|
|
|
|
test("移动报销卡片显示业务编号", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto("/mobile/reimbursements");
|
|
|
|
await expect(page.getByRole("heading", { name: "报销" })).toBeVisible();
|
|
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
|
|
await expect(page.getByText(formNumber)).toBeVisible();
|
|
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
|
await expect(page.locator("table:visible")).toHaveCount(0);
|
|
});
|
|
});
|