R32-1 R32-4 R33-2: 收口工作台交互与报销链路
这个提交包含在:
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { ADMIN, login } from "./helpers/auth";
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return {
|
||||
requestId: "mock-request",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
test("E2E 全局搜索:按钮打开后按分组展示结果并支持点击跳转", async ({ page }) => {
|
||||
await page.route("**/api/search?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
envelope({
|
||||
items: [
|
||||
{ entityType: "employee", entityId: "employee-1", title: "王小明", subtitle: "E001 · 财务", status: "active", href: "/people/employee-1", matchedBy: "prefix", updatedAt: null },
|
||||
{ entityType: "reimbursement", entityId: "reimbursement-1", title: "BX-001", subtitle: "办公室报销", status: "reviewing", href: "/reimbursements/reimbursement-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "leave", entityId: "leave-1", title: "请假 2026-03-24", subtitle: "年假", status: "approved", href: "/leave-requests", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "purchase_request", entityId: "purchase-1", title: "CG-001", subtitle: "办公椅采购", status: "reviewing", href: "/purchase-requests/purchase-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "travel_request", entityId: "travel-1", title: "CL-001", subtitle: "上海出差", status: "draft", href: "/travel-requests/travel-1", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "project", entityId: "project-1", title: "海音达试点", subtitle: "实施项目", status: "active", href: "/projects/project-1", matchedBy: "exact", updatedAt: null },
|
||||
{ entityType: "announcement", entityId: "announcement-1", title: "本周通知", subtitle: "统一回填周报", status: null, href: "/announcements", matchedBy: "contains", updatedAt: null },
|
||||
{ entityType: "asset", entityId: "asset-1", title: "ZC-001", subtitle: "MacBook Pro", status: "in_use", href: "/assets", matchedBy: "contains", updatedAt: null },
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
|
||||
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
|
||||
await page.getByTestId("global-search-trigger").click();
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||
const searchResponse = page.waitForResponse((response) => response.url().includes("/api/search?"));
|
||||
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill("海音");
|
||||
await searchResponse;
|
||||
|
||||
await expect(page.getByRole("heading", { name: "人员" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "业务单据" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "项目与公告" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "其他" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /BX-001/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /请假 2026-03-24/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /CG-001/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /CL-001/ })).toHaveCount(0);
|
||||
await expect(page.getByText("查看更多结果")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /BX-001/ }).click();
|
||||
await expect(page).toHaveURL(/\/reimbursements\/reimbursement-1/);
|
||||
});
|
||||
|
||||
test("E2E 全局搜索:Ctrl+K 和 Cmd+K 可打开搜索弹窗", async ({ page }) => {
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByTestId("global-search-trigger")).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭" }).click();
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).not.toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
|
||||
});
|
||||
@@ -29,6 +29,10 @@ export class ReimbursementDetailPageObject {
|
||||
return this.page.getByTestId(TEST_IDS.reimbursement.budgetHint);
|
||||
}
|
||||
|
||||
get workflowProgress() {
|
||||
return this.page.getByTestId(TEST_IDS.reimbursement.workflowProgress);
|
||||
}
|
||||
|
||||
get erpExport() {
|
||||
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ function extractId(text: string, label: string) {
|
||||
}
|
||||
|
||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
|
||||
test.setTimeout(240_000);
|
||||
|
||||
// 1) employee: create reimbursement
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
@@ -42,7 +45,6 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
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();
|
||||
|
||||
@@ -95,6 +95,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
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);
|
||||
|
||||
@@ -100,7 +100,8 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
|
||||
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByRole("heading", { name: "今日管理重点" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /待跟进 \/ 待收口项目/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible();
|
||||
|
||||
await adminClient.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await (await openNavLink(page, "我的报销")).click();
|
||||
await expect(page).toHaveURL(/\/reimbursements/);
|
||||
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
|
||||
|
||||
const createResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||
});
|
||||
await page.getByTestId("reimbursements-create").click();
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const reimbursementId = createPayload.data?.id;
|
||||
if (!reimbursementId) {
|
||||
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||
name: `progress-${Date.now()}.png`,
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(marker, "utf8"),
|
||||
});
|
||||
await page.getByTestId("invoice-upload-submit").click();
|
||||
|
||||
await expect(page.getByTestId("invoice-detail-panel")).toBeVisible();
|
||||
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");
|
||||
await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId);
|
||||
await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId);
|
||||
await page.getByTestId("invoice-bind-submit").click();
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
await reimbursementDetail.submitReimbursement();
|
||||
|
||||
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
|
||||
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
|
||||
await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function formatDate(offsetDays: number) {
|
||||
const value = new Date();
|
||||
value.setUTCDate(value.getUTCDate() + offsetDays);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function selectFirstEnabledOption(locator: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const optionValues = 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 optionValues[0] ?? "";
|
||||
})
|
||||
.not.toBe("");
|
||||
|
||||
const [firstValue] = 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(firstValue);
|
||||
}
|
||||
|
||||
async function createLeaveDraft(page: Page, startDate: string, endDate: string, reason: string) {
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
|
||||
);
|
||||
|
||||
await selectFirstEnabledOption(page.locator("#leave-type"));
|
||||
await page.locator("#leave-start-date").fill(startDate);
|
||||
await page.locator("#leave-end-date").fill(endDate);
|
||||
await page.locator("#leave-start-session").selectOption("am");
|
||||
await page.locator("#leave-end-session").selectOption("pm");
|
||||
await page.locator("#leave-reason").fill(reason);
|
||||
await page.getByRole("button", { name: "创建草稿" }).click();
|
||||
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const leaveRequestId = createPayload.data?.id;
|
||||
if (!leaveRequestId) {
|
||||
throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||
}
|
||||
|
||||
return leaveRequestId;
|
||||
}
|
||||
|
||||
async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
|
||||
return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
|
||||
}
|
||||
|
||||
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const startDate = formatDate(45 + Number(String(Date.now()).slice(-2)));
|
||||
const reason = `Playwright 快捷通过 ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "请假")).click();
|
||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||
|
||||
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
|
||||
const submitResult = await submitLeave(employeeClient.api, leaveRequestId);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await (await openNavLink(page, "待我处理")).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
|
||||
const taskRows = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: submitResult.currentTaskId });
|
||||
await expect(taskRows).toHaveCount(1, { timeout: 30_000 });
|
||||
const taskRow = taskRows.first();
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toBeVisible();
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(taskRows).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户