From fc50ffc8089dc1ff7a0dad21b6151700a9e2ca2f Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 01:13:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=20+=20E2E=20=E9=97=A8=E7=A6=81=EF=BC=88E15/E?= =?UTF-8?q?16/E18=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增系统用户管理页(创建/禁用启用/重置密码/角色) - 引入 Playwright E2E:覆盖登录-报销-待办-票据-OCR-支付-登出 - 新增 ci-e2e 工作流(PR + nightly) --- .github/workflows/ci-e2e.yml | 122 +++++ client/README.md | 27 + client/package-lock.json | 64 +++ client/package.json | 2 + client/playwright.config.ts | 21 + client/src/api/users.ts | 83 +++ client/src/components/navigation/NavLinks.tsx | 5 + client/src/pages/UserManagementPage.tsx | 503 ++++++++++++++++++ client/src/router/index.tsx | 10 + client/tests/e2e/main-flow.spec.ts | 114 ++++ client/tests/fixtures/mock_vat.png | 2 + 11 files changed, 953 insertions(+) create mode 100644 .github/workflows/ci-e2e.yml create mode 100644 client/playwright.config.ts create mode 100644 client/src/api/users.ts create mode 100644 client/src/pages/UserManagementPage.tsx create mode 100644 client/tests/e2e/main-flow.spec.ts create mode 100644 client/tests/fixtures/mock_vat.png diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml new file mode 100644 index 00000000..d653c2e8 --- /dev/null +++ b/.github/workflows/ci-e2e.yml @@ -0,0 +1,122 @@ +name: ci-e2e + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "10 19 * * *" + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: hyb_oa + POSTGRES_USER: hyboa_user + POSTGRES_PASSWORD: hyboa_password + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U hyboa_user -d hyb_oa" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + NODE_ENV: test + PORT: 3000 + HOST: 127.0.0.1 + APP_ORIGIN: http://127.0.0.1:3000 + DATABASE_URL: postgresql://hyboa_user:hyboa_password@127.0.0.1:5432/hyb_oa?schema=public + JWT_SECRET: ci_e2e_secret_key + OCR_PROVIDER: mock + OCR_MAX_ATTEMPTS: 3 + OCR_POLL_CRON: "*/5 * * * * *" + DELEGATION_REVOKE_CRON: "0 * * * *" + SERVE_CLIENT_DIST: "true" + CLIENT_DIST_DIR: "../client/dist" + E2E_BASE_URL: http://127.0.0.1:3000 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: | + server/package-lock.json + client/package-lock.json + + - name: Install server dependencies + working-directory: server + run: npm ci + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Migrate database + working-directory: server + run: npm run prisma:migrate:deploy + + - name: Seed database + working-directory: server + run: npm run seed:dev + + - name: Build client + working-directory: client + run: npm run build + + - name: Install Playwright browsers + working-directory: client + run: npx playwright install --with-deps + + - name: Start server + working-directory: server + run: | + nohup npm run dev > /tmp/hyb-oa-server.log 2>&1 & + echo $! > /tmp/hyb-oa-server.pid + + - name: Wait for ready + run: | + for i in {1..45}; do + if curl -fsS "http://127.0.0.1:3000/ready" >/dev/null; then + exit 0 + fi + sleep 2 + done + echo "Server readiness check timed out" >&2 + exit 1 + + - name: Run Playwright E2E + working-directory: client + run: npm run test:e2e + + - name: Stop server + if: always() + run: | + if [[ -f /tmp/hyb-oa-server.pid ]]; then + kill "$(cat /tmp/hyb-oa-server.pid)" || true + fi + + - name: Dump server log on failure + if: failure() + run: | + echo "==== /tmp/hyb-oa-server.log ====" + cat /tmp/hyb-oa-server.log || true + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: client/playwright-report + if-no-files-found: ignore + diff --git a/client/README.md b/client/README.md index 23092e26..c6578185 100644 --- a/client/README.md +++ b/client/README.md @@ -28,6 +28,11 @@ cd /home/shunli/Projects/hyb-oa/client npm run dev ``` +## 3.1 试运行/单域托管部署(推荐) +试运行/上线演练建议使用仓库内置部署包(server 托管 `client/dist`,同域 cookie 更稳定): +- `deploy/trialrun/runbook.md` +- `deploy/trialrun/checklist.md` + ## 4. 鉴权与会话说明 - Access Token 仅保存在内存,不写入 `localStorage/sessionStorage`。 - Refresh Token 由后端通过 HttpOnly Cookie 维护。 @@ -54,6 +59,7 @@ npm run dev - 待我处理:`expense.approve`。 - 票据中心:`invoice.upload` / `invoice.view_own` / `invoice.manage`。 - 审计工作台:`system.audit.view`(finance_mgr/admin)。 +- 用户管理:`system.users`(hr/admin)。 - 个人设置:登录即可访问。 ## 6. 页面现状(P3) @@ -78,6 +84,27 @@ npm run dev npm run typecheck npm run build npm run preview +npm run test:e2e +``` + +## 8.1 Playwright E2E(E15) +前置:本地启动 DB + server(建议 `SERVE_CLIENT_DIST=true` 单域托管),并启用 mock OCR: +```bash +cd /home/shunli/Projects/hyb-oa +docker compose up -d db + +cd /home/shunli/Projects/hyb-oa/server +OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" npm run prisma:migrate:deploy +OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" npm run seed:dev +OCR_PROVIDER=mock OCR_POLL_CRON="*/5 * * * * *" SERVE_CLIENT_DIST=true npm run dev +``` + +执行: +```bash +cd /home/shunli/Projects/hyb-oa/client +npm install +npx playwright install --with-deps +npm run test:e2e ``` ## 9. 仓库级最小门禁 diff --git a/client/package-lock.json b/client/package-lock.json index 54c97ff7..e71e128c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,6 +21,7 @@ "tailwind-merge": "^2.6.1" }, "devDependencies": { + "@playwright/test": "^1.50.0", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", @@ -843,6 +844,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2804,6 +2821,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/client/package.json b/client/package.json index 184c097c..0de4aa2f 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", "preview": "vite preview" }, "dependencies": { @@ -23,6 +24,7 @@ "tailwind-merge": "^2.6.1" }, "devDependencies": { + "@playwright/test": "^1.50.0", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 00000000..989fb828 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@playwright/test"; + +const baseURL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 120_000, + expect: { + timeout: 30_000, + }, + retries: process.env.CI ? 1 : 0, + use: { + baseURL, + headless: true, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : [["list"], ["html"]], +}); + diff --git a/client/src/api/users.ts b/client/src/api/users.ts new file mode 100644 index 00000000..3b2d6ed6 --- /dev/null +++ b/client/src/api/users.ts @@ -0,0 +1,83 @@ +import { apiRequestData } from "./http"; + +export type UserStatus = "active" | "archived"; + +export type UserSummary = { + id: string; + employeeNo: string; + name: string; + phone: string; + email: string; + status: UserStatus; + departmentId: string; + title: string; + roleCodes: string[]; + createdAt: string | null; + updatedAt: string | null; +}; + +export type UserListResponse = { + page: number; + pageSize: number; + total: number; + items: UserSummary[]; +}; + +export async function fetchUsers(params: { + page: number; + pageSize: number; + status?: UserStatus; + q?: string; +}) { + const search = new URLSearchParams(); + search.set("page", String(params.page)); + search.set("pageSize", String(params.pageSize)); + if (params.status) search.set("status", params.status); + if (params.q) search.set("q", params.q); + + return apiRequestData(`/api/users?${search.toString()}`); +} + +export async function createUser(payload: { + employeeNo: string; + name: string; + phone: string; + email: string; + password: string; + roles: string[]; +}) { + return apiRequestData("/api/users", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function disableUser(userId: string) { + return apiRequestData<{ id: string; status: UserStatus }>(`/api/users/${userId}/disable`, { + method: "POST", + }); +} + +export async function enableUser(userId: string) { + return apiRequestData<{ id: string; status: UserStatus }>(`/api/users/${userId}/enable`, { + method: "POST", + }); +} + +export async function resetUserPassword(userId: string) { + return apiRequestData<{ id: string; oneTimePassword: string }>( + `/api/users/${userId}/reset-password`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + +export async function setUserRoles(userId: string, roles: string[]) { + return apiRequestData<{ id: string; roleCodes: string[] }>(`/api/users/${userId}/roles`, { + method: "POST", + body: JSON.stringify({ roles }), + }); +} + diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index 4d65e2ab..1b4033c3 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -36,6 +36,11 @@ export const NAV_ITEMS: NavItem[] = [ label: "委派管理", anyOf: ["expense.proxy_manage"], }, + { + to: "/system/users", + label: "用户管理", + anyOf: ["system.users"], + }, { to: "/settings/profile", label: "个人设置" }, ]; diff --git a/client/src/pages/UserManagementPage.tsx b/client/src/pages/UserManagementPage.tsx new file mode 100644 index 00000000..2db69ee7 --- /dev/null +++ b/client/src/pages/UserManagementPage.tsx @@ -0,0 +1,503 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; +import { + createUser, + disableUser, + enableUser, + fetchUsers, + resetUserPassword, + setUserRoles, + type UserStatus, + type UserSummary, +} from "../api/users"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; +import { Card } from "../components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../components/ui/dialog"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; + +const PAGE_SIZE = 20; + +const ROLE_OPTIONS: Array<{ code: string; label: string }> = [ + { code: "employee", label: "employee" }, + { code: "manager", label: "manager" }, + { code: "ceo", label: "ceo" }, + { code: "finance", label: "finance" }, + { code: "finance_mgr", label: "finance_mgr" }, + { code: "administration", label: "administration" }, + { code: "hr", label: "hr" }, + { code: "admin", label: "admin" }, +]; + +function formatRoleCodes(roleCodes: string[]) { + return roleCodes.length ? roleCodes.join(", ") : "-"; +} + +export function UserManagementPage() { + const queryClient = useQueryClient(); + const [status, setStatus] = useState(""); + const [q, setQ] = useState(""); + const [page, setPage] = useState(1); + const [error, setError] = useState(""); + const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null); + + const [createOpen, setCreateOpen] = useState(false); + const [employeeNo, setEmployeeNo] = useState(""); + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [createRoles, setCreateRoles] = useState>(() => new Set(["employee"])); + + const [roleDialogUser, setRoleDialogUser] = useState(null); + const [roleDraft, setRoleDraft] = useState>(() => new Set()); + + const listQuery = useQuery({ + queryKey: ["users", "list", status, q, page], + queryFn: () => fetchUsers({ page, pageSize: PAGE_SIZE, status: status || undefined, q: q || undefined }), + }); + + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); + + const createPayload = useMemo(() => { + return { + employeeNo: employeeNo.trim(), + name: name.trim(), + phone: phone.trim(), + email: email.trim(), + password: password, + roles: Array.from(createRoles), + }; + }, [createRoles, email, employeeNo, name, password, phone]); + + const createMutation = useMutation({ + mutationFn: () => createUser(createPayload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["users"] }); + setCreateOpen(false); + setEmployeeNo(""); + setName(""); + setPhone(""); + setEmail(""); + setPassword(""); + setCreateRoles(new Set(["employee"])); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "创建用户失败"); + }, + }); + + const toggleMutation = useMutation({ + mutationFn: async (item: UserSummary) => { + if (item.status === "active") { + return disableUser(item.id); + } + return enableUser(item.id); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["users"] }); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "更新用户状态失败"); + }, + }); + + const resetMutation = useMutation({ + mutationFn: async (item: UserSummary) => resetUserPassword(item.id), + onSuccess: async (data) => { + setResetResult({ userId: data.id, password: data.oneTimePassword }); + await queryClient.invalidateQueries({ queryKey: ["users"] }); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "重置密码失败"); + }, + }); + + const rolesMutation = useMutation({ + mutationFn: async () => { + if (!roleDialogUser) { + throw new Error("missing user"); + } + return setUserRoles(roleDialogUser.id, Array.from(roleDraft)); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["users"] }); + setRoleDialogUser(null); + setRoleDraft(new Set()); + setError(""); + }, + onError: (err) => { + if (isApiError(err)) { + setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); + return; + } + setError(err instanceof Error ? err.message : "更新角色失败"); + }, + }); + + return ( +
+ + + + + + + 新建用户 + 请填写基础信息与初始密码(满足密码策略)。 + + +
+
+ + setEmployeeNo(e.target.value)} + placeholder="E1001" + /> +
+
+ + setName(e.target.value)} + placeholder="张三" + /> +
+
+ + setPhone(e.target.value)} + placeholder="13800000001" + /> +
+
+ + setEmail(e.target.value)} + placeholder="user@hyinda.com" + /> +
+
+ + setPassword(e.target.value)} + placeholder="至少12位,含大小写与数字" + /> +
+ +
+ +
+ {ROLE_OPTIONS.map((item) => ( + + ))} +
+
+ +
+ +
+
+
+ + } + /> + + {error ? ( +

+ {error} +

+ ) : null} + + {resetResult ? ( +

+ 已重置密码(user={resetResult.userId}),一次性口令:{resetResult.password} +

+ ) : null} + + +
+ { + setQ(e.target.value); + setPage(1); + }} + placeholder="搜索工号/姓名/手机号/邮箱" + /> + +
+ + {listQuery.isLoading ?

加载中...

: null} + {listQuery.isError ? ( +

+ {isApiError(listQuery.error) + ? `${listQuery.error.code}: ${mapApiErrorMessage( + listQuery.error.code, + listQuery.error.details, + listQuery.error.message, + )}` + : "加载用户列表失败"} +

+ ) : null} + +
+ + + + 工号/姓名 + 联系方式 + 状态 + 角色 + 操作 + + + + {(listQuery.data?.items ?? []).length ? ( + listQuery.data?.items.map((item) => ( + + +
+ {item.employeeNo} / {item.name} +
+
{item.id}
+
+ +
{item.email}
+
{item.phone}
+
+ {item.status} + {formatRoleCodes(item.roleCodes)} + +
+ + + + + +
+
+
+ )) + ) : ( + + + 暂无数据 + + + )} +
+
+
+ +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+
+ + { + if (!open) { + setRoleDialogUser(null); + setRoleDraft(new Set()); + } + }} + > + + + 设置角色 + + 当前用户:{roleDialogUser?.employeeNo} / {roleDialogUser?.name} + + + +
+ {ROLE_OPTIONS.map((item) => ( + + ))} +
+ +
+ + +
+
+
+
+ ); +} + diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 64bc9fc7..9d9dce65 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -11,6 +11,7 @@ import { NotFoundPage } from "../pages/NotFoundPage"; import { ProfilePage } from "../pages/ProfilePage"; import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage"; import { ReimbursementsPage } from "../pages/ReimbursementsPage"; +import { UserManagementPage } from "../pages/UserManagementPage"; import { WorkflowTaskDetailPage } from "../pages/WorkflowTaskDetailPage"; import { WorkflowTasksPage } from "../pages/WorkflowTasksPage"; import { GuestOnly, RequireAuth, RequirePermission } from "./guards"; @@ -103,6 +104,15 @@ export function AppRouter() { } /> + + + + } + /> + } /> diff --git a/client/tests/e2e/main-flow.spec.ts b/client/tests/e2e/main-flow.spec.ts new file mode 100644 index 00000000..388d5393 --- /dev/null +++ b/client/tests/e2e/main-flow.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page } from "@playwright/test"; + +const EMPLOYEE = { identifier: "employee@hyinda.com", password: "EmployeePassword1234", name: "Employee" }; +const MANAGER = { identifier: "manager@hyinda.com", password: "ManagerPassword1234", name: "Manager" }; +const FINANCE = { identifier: "finance@hyinda.com", password: "FinancePassword1234", name: "Finance" }; + +async function login(page: Page, user: { identifier: string; password: string }) { + 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/); +} + +async function logout(page: Page, userDisplayName: string) { + await page.getByRole("button", { name: userDisplayName }).click(); + await page.getByRole("menuitem", { name: "退出登录" }).click(); + await expect(page).toHaveURL(/\/login/); +} + +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 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page }) => { + // 1) employee: create reimbursement + await login(page, EMPLOYEE); + + await page.getByRole("link", { name: "我的报销" }).click(); + await page.getByTestId("reimbursements-create").click(); + await expect(page.getByTestId("reimbursements-table")).toBeVisible(); + const firstRow = page.getByTestId("reimbursements-table").getByTestId("reimbursement-row").first(); + await expect(firstRow).toBeVisible(); + await firstRow.getByTestId("reimbursement-detail-link").click(); + await expect(page.getByTestId("reimbursement-summary")).toBeVisible(); + + const reimbursementSummaryText = await page.getByTestId("reimbursement-summary").innerText(); + const reimbursementId = extractId(reimbursementSummaryText, "报销ID"); + + // 2) employee: upload invoice and wait OCR + await page.getByRole("link", { name: "票据中心" }).click(); + const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`; + await page.getByTestId("invoice-upload-file").setInputFiles({ + name: `e2e-${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("状态: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"); + + // 预览(popup 存在即可) + const popupPromise = page.waitForEvent("popup"); + const invoiceRow = page.getByTestId("invoice-row").filter({ hasText: invoiceId }).first(); + await expect(invoiceRow).toBeVisible(); + await invoiceRow.getByTestId("invoice-preview-button").click(); + const popup = await popupPromise; + await expect(popup).toHaveURL(new RegExp(`/api/invoice-pool/${invoiceId}`)); + await popup.close(); + + // 3) bind invoice to reimbursement + await page.getByTestId("invoice-bind-invoice").selectOption(invoiceId); + await page.getByTestId("invoice-bind-reimbursement").selectOption(reimbursementId); + await page.getByTestId("invoice-bind-submit").click(); + + // 4) submit reimbursement + await page.goto(`/reimbursements/${reimbursementId}`); + await page.getByTestId("reimbursement-submit").click(); + await expect(page.getByText("状态:reviewing")).toBeVisible(); + + // 5) manager approve + await logout(page, EMPLOYEE.name); + await login(page, MANAGER); + + await page.getByRole("link", { name: "待我处理" }).click(); + await expect(page.getByTestId("workflow-tasks-table")).toBeVisible(); + const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first(); + await expect(managerTaskRow).toBeVisible(); + await managerTaskRow.getByTestId("workflow-task-detail-link").click(); + await page.getByTestId("workflow-task-approve").click(); + await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:completed"); + + await logout(page, MANAGER.name); + + // 6) finance approve + pay + await login(page, FINANCE); + await page.getByRole("link", { name: "待我处理" }).click(); + const financeTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first(); + await expect(financeTaskRow).toBeVisible(); + await financeTaskRow.getByTestId("workflow-task-detail-link").click(); + await page.getByTestId("workflow-task-approve").click(); + await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:completed"); + + await page.goto(`/reimbursements/${reimbursementId}`); + 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").click(); + await expect(page.getByText("状态:paid")).toBeVisible({ timeout: 30_000 }); + + await logout(page, FINANCE.name); + + // 7) guard: unauth -> login + await page.goto("/dashboard"); + await expect(page).toHaveURL(/\/login/); +}); diff --git a/client/tests/fixtures/mock_vat.png b/client/tests/fixtures/mock_vat.png new file mode 100644 index 00000000..b6b09fbb --- /dev/null +++ b/client/tests/fixtures/mock_vat.png @@ -0,0 +1,2 @@ +MOCK_INVOICE_E2E_1 +