From 5afb8f9ab1e9941149e0a2514769be8e0157825a Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 14 May 2026 11:44:02 +0800 Subject: [PATCH] =?UTF-8?q?R7E=20=E6=B5=8F=E8=A7=88=E5=99=A8=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=85=88=E8=A1=8C=E8=AF=95=E8=BF=90=E8=A1=8C=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/tests/e2e/helpers/liveAuth.ts | 66 +- .../r7e-browser-business-trialrun.spec.ts | 824 ++++++++++++++++++ docs/trialrun/人工试运行报告.md | 16 + ...2026-05-13_17-11-44-r7e-human-trialrun.csv | 12 +- ...05-13_17-30-00-qaops8-test-release-ops.csv | 2 +- 5 files changed, 901 insertions(+), 19 deletions(-) create mode 100644 client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts diff --git a/client/tests/e2e/helpers/liveAuth.ts b/client/tests/e2e/helpers/liveAuth.ts index 197ad87f..cd7a8e56 100644 --- a/client/tests/e2e/helpers/liveAuth.ts +++ b/client/tests/e2e/helpers/liveAuth.ts @@ -1,8 +1,8 @@ import { expect, type Page } from "@playwright/test"; -import { logout } from "./auth"; +import { createAuthenticatedApiClient, logout, type AuthenticatedApiClient, type E2EUser } from "./auth"; -export type LiveSmokeRole = "admin" | "employee"; +export type LiveSmokeRole = "admin" | "employee" | "manager" | "finance" | "financeMgr"; export type LiveSmokeUser = { role: LiveSmokeRole; @@ -24,18 +24,34 @@ export function getExpectedWecomStatus() { } export function getLiveSmokeUser(role: LiveSmokeRole) { - if (role === "admin") { - return { - role, - identifier: requireEnv("LIVE_SMOKE_ADMIN_IDENTIFIER"), - password: requireEnv("LIVE_SMOKE_ADMIN_PASSWORD"), - }; - } + const envByRole: Record = { + admin: { + identifier: "LIVE_SMOKE_ADMIN_IDENTIFIER", + password: "LIVE_SMOKE_ADMIN_PASSWORD", + }, + employee: { + identifier: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", + password: "LIVE_SMOKE_EMPLOYEE_PASSWORD", + }, + manager: { + identifier: "LIVE_SMOKE_MANAGER_IDENTIFIER", + password: "LIVE_SMOKE_MANAGER_PASSWORD", + }, + finance: { + identifier: "LIVE_SMOKE_FINANCE_IDENTIFIER", + password: "LIVE_SMOKE_FINANCE_PASSWORD", + }, + financeMgr: { + identifier: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", + password: "LIVE_SMOKE_FINANCE_MGR_PASSWORD", + }, + }; + const env = envByRole[role]; return { role, - identifier: requireEnv("LIVE_SMOKE_EMPLOYEE_IDENTIFIER"), - password: requireEnv("LIVE_SMOKE_EMPLOYEE_PASSWORD"), + identifier: requireEnv(env.identifier), + password: requireEnv(env.password), }; } @@ -57,7 +73,14 @@ export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise
  • { + const loginError = page.locator(".text-red-600, .text-red-700, [role='alert']").first(); + const message = (await loginError.textContent().catch(() => ""))?.trim(); + if (message) { + throw new Error(`live 登录失败:${message}`); + } + throw error; + }); const name = await readCurrentUserName(page); @@ -70,3 +93,22 @@ export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise
  • ) { await logout(page, user?.name); } + +export function getRequiredLiveUser(role: LiveSmokeRole): E2EUser { + const credentials = getLiveSmokeUser(role); + + return { + identifier: credentials.identifier, + password: credentials.password, + name: role, + }; +} + +export async function loginLiveBrowserUser(page: Page, role: LiveSmokeRole): Promise { + return loginLiveUser(page, role); +} + +export async function createLiveApiClient(role: LiveSmokeRole): Promise { + const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com"; + return createAuthenticatedApiClient(getRequiredLiveUser(role), baseURL); +} diff --git a/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts b/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts new file mode 100644 index 00000000..1b536736 --- /dev/null +++ b/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts @@ -0,0 +1,824 @@ +import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { test } from "../helpers/fixtures"; +import { apiGetData, apiPostData } from "../helpers/auth"; +import { createLiveApiClient, loginLiveBrowserUser, logoutLiveUser, type LiveSmokeRole } from "../helpers/liveAuth"; +import { openNavLink } from "../helpers/navigation"; +import { TEST_IDS } from "../helpers/testIds"; +import { waitForWorkflowTask } from "../helpers/workflow"; + +type StepStatus = "PASS" | "FAIL" | "SKIP"; +type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动"; + +type TrialStep = { + flow: FlowName; + role: string; + title: string; + status: StepStatus; + businessId: string; + pcResult: string; + mobileResult: string; + issueId: string; + conclusion: string; + detail: string; + screenshot: string; +}; + +type StepResult = string | Partial>; + +type ReimbursementDetail = { + id: string; + formNumber: string | null; + status: string; + totalAmount: string | null; + payment?: { + status?: string | null; + paymentVoucherNo?: string | null; + } | null; +}; + +type PaymentBatch = { + id: string; + name: string; + status: string; + reviewRequirement: { + completedReviewCount: number; + requiredReviewCount: number; + nextStep: string; + }; +}; + +type TrialState = { + reimbursementId?: string; + reimbursementCode?: string; + reimbursementTaskId?: string; + reimbursementInvoiceId?: string; + reimbursementSubmitted?: boolean; + leaveRequestId?: string; + leaveTaskId?: string; + leaveDate?: string; + purchaseRequestId?: string; + purchaseRequestNo?: string; + purchaseTaskId?: string; + paymentBatchId?: string; + paymentBatchName?: string; +}; + +const rootDir = path.resolve(process.cwd(), ".."); +const reportDir = path.join(rootDir, "artifacts/r7e/browser-business-trialrun"); +const reportPath = path.join(reportDir, "report.md"); +const issueCandidatesPath = path.join(reportDir, "issues.csv"); +const screenshotDir = path.join(rootDir, "output/playwright/r7e-browser-business-trialrun"); + +function nowStamp() { + return String(Date.now()); +} + +function formatDate(offsetDays: number) { + const value = new Date(); + value.setUTCDate(value.getUTCDate() + offsetDays); + return value.toISOString().slice(0, 10); +} + +function uniqueFutureOffset(minOffsetDays: number) { + return minOffsetDays + (Math.floor(Date.now() / 1000) % 2000); +} + +function safeName(value: string) { + return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80); +} + +function csvCell(value: string) { + return `"${value.replaceAll('"', '""')}"`; +} + +function tableCell(value: string) { + return value.replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function stripAnsi(value: string) { + return value.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function resolveLivePdfSample() { + const candidate = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() || process.env.LIVE_OCR_INVOICE_FILE?.trim() || ""; + if (!candidate || !existsSync(candidate)) { + return ""; + } + try { + return statSync(candidate).isFile() && path.extname(candidate).toLowerCase() === ".pdf" ? candidate : ""; + } catch { + return ""; + } +} + +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 firstEnabledExpenseTypeId(api: APIRequestContext) { + const response = await api.get("/api/expense-types?enabled=true"); + const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } }; + const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; + if (!response.ok() || !item?.id) { + throw new Error(`无法读取费用类型:HTTP ${response.status()}`); + } + return item.id; +} + +async function createReimbursementItem(api: APIRequestContext, reimbursementId: string, marker: string) { + const expenseTypeId = await firstEnabledExpenseTypeId(api); + const response = await api.post(`/api/reimbursements/${reimbursementId}/items`, { + data: { + expenseTypeId, + expenseDate: new Date().toISOString().slice(0, 10), + amount: "168.30", + description: `R7E 浏览器报销 ${marker}`, + }, + }); + if (!response.ok()) { + throw new Error(`创建报销明细失败:HTTP ${response.status()} ${await response.text()}`); + } +} + +async function uploadLiveInvoicePdf(page: Page, fileName: string, buffer: Buffer) { + await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click(); + await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toBeVisible(); + + const uploadResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === "/api/invoice-pool/upload"; + }); + + await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({ + name: fileName, + mimeType: "application/pdf", + buffer, + }); + await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded(); + await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click(); + + const uploadResponse = await uploadResponsePromise; + const uploadPayload = (await uploadResponse.json()) as { + data?: { + invoicePoolId?: string | null; + status?: string | null; + uploadAction?: string | null; + unavailableReason?: string | null; + unavailableMessage?: string | null; + }; + }; + const invoiceId = uploadPayload.data?.invoicePoolId ?? null; + if (!uploadResponse.ok()) { + throw new Error(`发票上传失败:HTTP ${uploadResponse.status()} ${JSON.stringify(uploadPayload)}`); + } + if (!invoiceId) { + await page.keyboard.press("Escape").catch(() => undefined); + await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0).catch(() => undefined); + return { + invoiceId: null, + status: uploadPayload.data?.status ?? "unknown", + uploadAction: uploadPayload.data?.uploadAction ?? "unknown", + reason: uploadPayload.data?.unavailableReason ?? "missing_invoice_id", + message: uploadPayload.data?.unavailableMessage ?? "上传未返回可绑定票据 ID", + }; + } + + await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).toContainText(fileName, { timeout: 90_000 }); + await page.keyboard.press("Escape"); + await expect(page.getByTestId(TEST_IDS.reimbursement.invoiceDialog)).toHaveCount(0); + return { invoiceId, status: "available", uploadAction: "created", reason: "", message: "" }; +} + +async function createLeaveDraft(page: Page, startDate: 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(startDate); + 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 checkNoHorizontalOverflow(page: Page) { + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow, `页面横向溢出 ${overflow}px: ${page.url()}`).toBeLessThanOrEqual(2); +} + +async function expectAnyVisible(page: Page, testIds: string[]) { + await expect + .poll(async () => { + for (const testId of testIds) { + if (await page.getByTestId(testId).first().isVisible().catch(() => false)) { + return testId; + } + } + return ""; + }) + .not.toBe(""); +} + +async function loginRole(page: Page, role: LiveSmokeRole) { + await page.context().clearCookies(); + await page.goto("/login"); + await page.evaluate(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + return loginLiveBrowserUser(page, role); +} + +function writeReports(steps: TrialStep[]) { + mkdirSync(reportDir, { recursive: true }); + const failures = steps.filter((item) => item.status === "FAIL"); + const skips = steps.filter((item) => item.status === "SKIP"); + const generatedAt = new Date().toISOString(); + + const lines = [ + "# R7E 浏览器业务先行试运行报告", + "", + `生成时间:${generatedAt}`, + "", + "## 结论", + "", + failures.length + ? `发现 ${failures.length} 个失败步骤,需分流到 R7E/R7E-16 或 QAOPS8。` + : skips.length + ? `未发现失败步骤,但有 ${skips.length} 个 SKIP 步骤,不能据此关闭对应人工验收。` + : "公网浏览器业务线自动辅助试运行未发现阻断步骤。", + "", + "## 固定口径", + "", + "- 本报告只覆盖公网 PC/移动浏览器业务线。", + "- 企业微信免登录、企业微信消息送达、消息 deeplink、企业微信 WebView 文件选择器和代表设备真机反馈仍需单独验收。", + "- 报告不记录密码、token、票据内容或私人联系方式。", + "", + "## 步骤结果", + "", + "| 流程 | 账号角色 | 测试单据 | PC 结果 | 移动浏览器结果 | 问题编号 | 结论 |", + "| --- | --- | --- | --- | --- | --- | --- |", + ...steps.map((item) => + `| ${tableCell(item.flow)} | ${tableCell(item.role)} | ${tableCell(item.businessId)} | ${tableCell(item.pcResult)} | ${tableCell(item.mobileResult)} | ${tableCell(item.issueId)} | ${tableCell(item.conclusion)} |`, + ), + "", + "## 截图和详情", + "", + "| 流程 | 步骤 | 状态 | 截图 | 详情 |", + "| --- | --- | --- | --- | --- |", + ...steps.map((item) => + `| ${tableCell(item.flow)} | ${tableCell(item.title)} | ${item.status} | ${tableCell(item.screenshot || "N/A")} | ${tableCell(item.detail)} |`, + ), + ]; + writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8"); + + const issueHeader = ["ID", "Flow", "Severity", "Title", "Actual", "Screenshot", "Disposition", "Status", "Notes"]; + const issueRows = failures.map((item, index) => + [ + `R7E-BROWSER-${String(index + 1).padStart(3, "0")}`, + item.flow, + item.flow === "公共前置" ? "BLOCKER" : "HIGH", + item.title, + item.detail, + item.screenshot || "N/A", + item.flow === "移动" ? "QAOPS8/UX8" : "R7E/WF8/BIZ8", + "OPEN", + "由 R7E 浏览器业务先行试运行生成,需人工复核严重级别。", + ].map(csvCell).join(","), + ); + writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8"); +} + +test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、财务、移动浏览器", async ({ page, reimbursementDetail, workflowDetail }) => { + test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行"); + test.skip(process.env.E2E_MODE !== "live", "仅在 live 模式下执行"); + test.skip(process.env.R7E_BROWSER_TRIALRUN !== "1", "仅在 R7E 浏览器业务试运行模式下执行"); + test.setTimeout(600_000); + + mkdirSync(screenshotDir, { recursive: true }); + const steps: TrialStep[] = []; + const state: TrialState = {}; + const marker = nowStamp(); + let stepIndex = 0; + + async function runStep(flow: FlowName, role: string, title: string, action: () => Promise) { + stepIndex += 1; + const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${safeName(flow)}-${safeName(title)}.png`); + const relativeScreenshot = path.relative(rootDir, screenshot); + try { + const rawResult = await action(); + const result = typeof rawResult === "string" || !rawResult ? { detail: rawResult || "ok" } : rawResult; + await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); + steps.push({ + flow, + role, + title, + status: result.status ?? "PASS", + businessId: result.businessId ?? "-", + pcResult: result.pcResult ?? "PASS", + mobileResult: result.mobileResult ?? "-", + issueId: result.issueId ?? "-", + conclusion: result.conclusion ?? "通过", + detail: result.detail ?? "ok", + screenshot: relativeScreenshot, + }); + } catch (error) { + const detail = stripAnsi(error instanceof Error ? error.message : String(error)); + await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); + steps.push({ + flow, + role, + title, + status: "FAIL", + businessId: "-", + pcResult: `FAIL: ${detail.slice(0, 180)}`, + mobileResult: "-", + issueId: "待分流", + conclusion: "失败", + detail: `${detail}; url=${page.url()}`, + screenshot: relativeScreenshot, + }); + } + } + + async function approveTaskViaBrowser(role: LiveSmokeRole, taskId: string, expectedText: RegExp | string) { + const user = await loginRole(page, role); + try { + await (await openNavLink(page, "待我处理")).click(); + await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible(); + await page.goto(`/workflow/tasks/${taskId}`); + await workflowDetail.assertLoaded(); + await expect(workflowDetail.summary).toContainText(expectedText); + const approveResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === `/api/workflow/tasks/${taskId}/approve`; + }); + await workflowDetail.approve(); + const approveResponse = await approveResponsePromise; + if (!approveResponse.ok()) { + throw new Error(`审批通过请求失败:HTTP ${approveResponse.status()} ${await approveResponse.text()}`); + } + } finally { + await logoutLiveUser(page, { name: user.name }).catch(() => undefined); + } + } + + try { + await runStep("公共前置", "anonymous", "公网 ready 探活", async () => { + const ready = await page.request.get("/ready"); + expect(ready.ok()).toBeTruthy(); + return `ready status=${ready.status()}`; + }); + + for (const role of ["employee", "manager", "finance", "financeMgr", "admin"] as const) { + await runStep("公共前置", role, `${role} 登录 Portal`, async () => { + const user = await loginRole(page, role); + await expect(page).toHaveURL(/\/portal/); + await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger).first()).toBeVisible(); + await logoutLiveUser(page, { name: user.name }); + return `login ok: ${role}`; + }); + } + + if (steps.some((item) => item.flow === "公共前置" && item.status !== "PASS")) { + return; + } + + await runStep("报销", "employee", "创建报销、上传授权 PDF 并提交", async () => { + const pdfSample = resolveLivePdfSample(); + if (!pdfSample) { + return { + status: "SKIP", + pcResult: "SKIP: 缺少授权 PDF 样票", + conclusion: "暂停", + detail: "PLAYWRIGHT_OCR_LIVE_FILE 或 LIVE_OCR_INVOICE_FILE 未指向可用 PDF;未创建报销提交链路。", + }; + } + + const employeeApi = await createLiveApiClient("employee"); + const user = await loginRole(page, "employee"); + try { + 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(); + await page.getByRole("menuitem", { name: "空白报销" }).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)}`); + } + state.reimbursementId = reimbursementId; + + await createReimbursementItem(employeeApi.api, reimbursementId, marker); + await page.goto(`/reimbursements/${reimbursementId}`); + await reimbursementDetail.assertLoaded(); + + const uploadResult = await uploadLiveInvoicePdf(page, `r7e-${marker}.pdf`, readFileSync(pdfSample)); + if (!uploadResult.invoiceId) { + return { + status: "SKIP", + businessId: `reimbursement=${reimbursementId}; invoice=${uploadResult.status}`, + pcResult: `SKIP: 授权 PDF 样票不可绑定,${uploadResult.reason}`, + conclusion: "暂停", + detail: `reimbursementId=${reimbursementId}; uploadAction=${uploadResult.uploadAction}; reason=${uploadResult.reason}; message=${uploadResult.message}`, + }; + } + const invoiceId = uploadResult.invoiceId; + state.reimbursementInvoiceId = invoiceId; + + const submitResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === `/api/reimbursements/${reimbursementId}/submit`; + }); + await reimbursementDetail.submitReimbursement(); + const submitResponse = await submitResponsePromise; + if (!submitResponse.ok()) { + throw new Error(`报销提交失败:HTTP ${submitResponse.status()} ${await submitResponse.text()}`); + } + const submitPayload = (await submitResponse.json()) as { data?: { currentTaskId?: string } }; + state.reimbursementTaskId = submitPayload.data?.currentTaskId; + state.reimbursementSubmitted = true; + + await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 }); + await expect(reimbursementDetail.workflowProgress).toContainText("当前节点", { timeout: 30_000 }); + + const detail = await apiGetData(employeeApi.api, `/api/reimbursements/${reimbursementId}`); + state.reimbursementCode = detail.formNumber ?? reimbursementId; + return { + businessId: `${state.reimbursementCode}; invoice=${invoiceId}; task=${state.reimbursementTaskId ?? "-"}`, + pcResult: "PASS: 报销创建、明细、PDF 上传、提交、审批进度可见", + detail: `reimbursementId=${reimbursementId}; formNumber=${state.reimbursementCode}; invoiceId=${invoiceId}`, + }; + } finally { + await employeeApi.dispose(); + await logoutLiveUser(page, { name: user.name }).catch(() => undefined); + } + }); + + await runStep("审批", "manager", "审批人处理报销待办并员工回看历史", async () => { + if (!state.reimbursementId) { + return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 缺少报销单", detail: "报销创建未完成,跳过审批联动。" }; + } + if (!state.reimbursementSubmitted || !state.reimbursementTaskId) { + return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 报销未提交", detail: "报销授权 PDF 样票不可绑定,未产生报销审批待办。" }; + } + const managerApi = await createLiveApiClient("manager"); + const employeeUser = await loginRole(page, "employee"); + try { + const taskId = state.reimbursementTaskId + ?? (await waitForWorkflowTask(managerApi.api, { entityType: "reimbursement", entityId: state.reimbursementId })).id; + await logoutLiveUser(page, { name: employeeUser.name }).catch(() => undefined); + await approveTaskViaBrowser("manager", taskId, /对象类型:报销|reimbursement|报销/); + + const employee = await loginRole(page, "employee"); + await page.goto(`/reimbursements/${state.reimbursementId}`); + await reimbursementDetail.assertLoaded(); + await expect(page.getByTestId("reimbursement-approval-history")).toContainText(/通过|approve|approved/, { timeout: 30_000 }); + await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); + return { + businessId: `${state.reimbursementCode ?? state.reimbursementId}; task=${taskId}`, + pcResult: "PASS: 审批人处理待办,员工可回看审批历史", + detail: `approved reimbursement task=${taskId}`, + }; + } finally { + await managerApi.dispose(); + } + }); + + await runStep("请假", "employee/manager", "请假提交、审批和移动记录页回看", async () => { + const employeeApi = await createLiveApiClient("employee"); + const startDate = formatDate(uniqueFutureOffset(3200)); + const employee = await loginRole(page, "employee"); + try { + await (await openNavLink(page, "请假")).click(); + await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + + const leaveId = await createLeaveDraft(page, startDate, `R7E 浏览器请假 ${marker}`); + state.leaveRequestId = leaveId; + state.leaveDate = startDate; + const submitResult = await apiPostData<{ leaveRequestId: string; currentTaskId: string }>(employeeApi.api, `/api/leave-requests/${leaveId}/submit`, {}); + state.leaveTaskId = submitResult.currentTaskId; + + await page.goto("/leave-requests"); + const row = page.getByRole("row").filter({ hasText: startDate }).first(); + await expect(row).toContainText("审批中", { timeout: 30_000 }); + await expect(row).toContainText("当前节点", { timeout: 30_000 }); + await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); + + await approveTaskViaBrowser("manager", submitResult.currentTaskId, /对象类型:请假|leave|请假/); + + const employeeAgain = await loginRole(page, "employee"); + await page.goto("/leave-requests"); + await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mobile/leave"); + await expect(page.getByTestId("mobile-leave-records")).toBeVisible(); + await expect(page.getByText(startDate).first()).toBeVisible({ timeout: 30_000 }); + await checkNoHorizontalOverflow(page); + await page.setViewportSize({ width: 1440, height: 960 }); + await logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined); + return { + businessId: `leave=${leaveId}; task=${submitResult.currentTaskId}`, + pcResult: "PASS: 请假提交、审批、PC 回看已通过", + mobileResult: "PASS: /mobile/leave 记录页可见且无横向滚动", + detail: `leaveRequestId=${leaveId}; date=${startDate}`, + }; + } finally { + await employeeApi.dispose(); + await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined); + } + }); + + await runStep("采购", "employee/manager/financeMgr", "采购提交、审批、归档和移动详情回看", async () => { + const employee = await loginRole(page, "employee"); + try { + await page.setViewportSize({ width: 1440, height: 960 }); + await (await openNavLink(page, "采购申请")).click(); + const createResponsePromise = page.waitForResponse( + (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests", + ); + await page.getByTestId("purchase-requests-create").click(); + const createResponse = await createResponsePromise; + if (!createResponse.ok()) { + throw new Error(`采购草稿创建失败:HTTP ${createResponse.status()} ${await createResponse.text()}`); + } + const createPayload = (await createResponse.json()) as { data?: { purchaseRequest?: { id?: string } } }; + const purchaseRequestId = createPayload.data?.purchaseRequest?.id; + if (!purchaseRequestId) { + throw new Error(`创建采购申请后未返回 id: ${JSON.stringify(createPayload)}`); + } + state.purchaseRequestId = purchaseRequestId; + await page.goto(`/purchase-requests?id=${purchaseRequestId}`); + await expect(page.getByTestId("purchase-title")).toBeVisible(); + await page.getByTestId("purchase-title").fill(`R7E 浏览器采购 ${marker}`); + await selectFirstEnabledOption(page.getByTestId("purchase-legal-entity")); + await selectFirstEnabledOption(page.getByTestId("purchase-cost-center")); + await selectFirstEnabledOption(page.getByTestId("purchase-supplier")); + await selectFirstEnabledOption(page.getByTestId("purchase-document-category")); + + const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]:visible'); + if (!(await itemDescriptionInputs.first().isVisible().catch(() => false))) { + await page.getByTestId("purchase-add-item").click(); + } + await expect(itemDescriptionInputs.first()).toBeVisible({ timeout: 30_000 }); + await itemDescriptionInputs.first().fill(`R7E 采购明细 ${marker}`); + await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("1"); + await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("188"); + + const submitResponsePromise = page.waitForResponse( + (response) => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`, + ); + await page.getByTestId("purchase-submit").click(); + const submitResponse = await submitResponsePromise; + if (!submitResponse.ok()) { + throw new Error(`采购提交失败:HTTP ${submitResponse.status()} ${await submitResponse.text()}`); + } + const submitPayload = (await submitResponse.json()) as { data?: { currentTaskId?: string } }; + const currentTaskId = submitPayload.data?.currentTaskId; + if (!currentTaskId) { + throw new Error(`采购提交后未返回 currentTaskId: ${JSON.stringify(submitPayload)}`); + } + state.purchaseTaskId = currentTaskId; + state.purchaseRequestNo = (await page.getByRole("heading", { level: 3 }).filter({ hasText: /^CG-/ }).first().textContent())?.trim() ?? purchaseRequestId; + await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 }); + await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); + + await approveTaskViaBrowser("manager", currentTaskId, /对象类型:采购申请|采购/); + + const employeeAgain = await loginRole(page, "employee"); + await page.goto(`/purchase-requests?id=${purchaseRequestId}`); + await expect(page.getByTestId("purchase-detail-status")).toContainText(/已通过|approved|已完成/, { timeout: 30_000 }); + await expect(page.getByTestId("purchase-approval-history")).toContainText(/通过|approve|approved/, { timeout: 30_000 }); + await logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined); + + const financeMgr = await loginRole(page, "financeMgr"); + await page.goto(`/purchase-requests?id=${purchaseRequestId}`); + const archiveInput = page.getByTestId("purchase-archive-po-no"); + if (await archiveInput.isVisible().catch(() => false)) { + await archiveInput.fill(`PO-R7E-${marker}`); + await page.getByTestId("purchase-archive-contract-no").fill(`CT-R7E-${marker}`); + await page.getByTestId("purchase-archive-save").click(); + await expect(archiveInput).toHaveValue(`PO-R7E-${marker}`, { timeout: 30_000 }); + } + await logoutLiveUser(page, { name: financeMgr.name }).catch(() => undefined); + + const mobileEmployee = await loginRole(page, "employee"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mobile/purchase-requests"); + await expect(page.getByTestId("mobile-purchase-requests-list")).toBeVisible(); + await checkNoHorizontalOverflow(page); + await page.goto(`/mobile/purchase-requests/${purchaseRequestId}`); + await expect(page.getByTestId("mobile-purchase-detail-summary")).toBeVisible(); + await checkNoHorizontalOverflow(page); + await page.setViewportSize({ width: 1440, height: 960 }); + await logoutLiveUser(page, { name: mobileEmployee.name }).catch(() => undefined); + + return { + businessId: `${state.purchaseRequestNo}; task=${currentTaskId}`, + pcResult: "PASS: 采购提交、审批、状态和历史可追溯", + mobileResult: "PASS: 移动采购列表和详情可见", + detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}`, + }; + } finally { + await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined); + } + }); + + await runStep("财务", "finance/financeMgr/admin", "财务审批、付款批次、台账和管理员边界", async () => { + if (!state.reimbursementId || !state.reimbursementSubmitted) { + return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 缺少已提交报销", detail: "报销链路未产出可付款单据。" }; + } + + const financeApi = await createLiveApiClient("finance"); + try { + let financeTaskId = ""; + try { + financeTaskId = (await waitForWorkflowTask(financeApi.api, { + entityType: "reimbursement", + entityId: state.reimbursementId, + attempts: 8, + intervalMs: 1000, + })).id; + } catch { + financeTaskId = ""; + } + + if (financeTaskId) { + await approveTaskViaBrowser("finance", financeTaskId, /对象类型:报销|reimbursement|报销/); + } + + const reimbursementAfterApproval = await apiGetData(financeApi.api, `/api/reimbursements/${state.reimbursementId}`); + if (!/approved|paid/.test(reimbursementAfterApproval.status)) { + throw new Error(`报销未达到可付款状态:${reimbursementAfterApproval.status}`); + } + + const finance = await loginRole(page, "finance"); + await page.goto("/ap-operations"); + await expect(page.getByRole("heading", { name: "AP 运营", exact: true })).toBeVisible(); + await expect(page.getByTestId("finance-ledger-table")).toBeVisible(); + await page.getByPlaceholder("单号、姓名、部门、项目").fill(state.reimbursementCode ?? state.reimbursementId); + await expect(page.getByTestId("finance-ledger-table")).toContainText(state.reimbursementCode ?? state.reimbursementId, { timeout: 30_000 }); + + const batchName = `R7E 浏览器付款批次 ${marker}`; + state.paymentBatchName = batchName; + await page.goto("/payment-batches"); + await expect(page.getByRole("heading", { name: "支付批次", exact: true })).toBeVisible(); + const createCard = page.locator(".card-std").filter({ hasText: "新建批次" }).first(); + await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled(); + const createBatchResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === "/api/payment-batches"; + }); + await createCard.locator("select").nth(0).selectOption("reimbursement"); + await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName); + await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(state.reimbursementId); + await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10)); + await createCard.locator("select").nth(1).selectOption("bank"); + await createCard.getByPlaceholder("例如 PBX").fill(`R7E-${marker.slice(-6)}`); + await createCard.getByRole("button", { name: "创建批次" }).click(); + const createBatchResponse = await createBatchResponsePromise; + if (!createBatchResponse.ok()) { + throw new Error(`创建支付批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`); + } + const createBatchPayload = (await createBatchResponse.json()) as { data?: { item?: PaymentBatch } }; + const batch = createBatchPayload.data?.item; + if (!batch?.id) { + throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); + } + state.paymentBatchId = batch.id; + await logoutLiveUser(page, { name: finance.name }).catch(() => undefined); + + const financeMgr = await loginRole(page, "financeMgr"); + await page.goto("/ap-operations"); + await page.getByRole("tab", { name: /待付款/ }).click(); + const pendingRow = page.locator("tr").filter({ hasText: batchName }).first(); + await expect(pendingRow).toBeVisible({ timeout: 30_000 }); + await pendingRow.getByRole("button", { name: "快捷复核" }).click(); + await expect(page.getByRole("dialog")).toContainText(batchName); + await page.getByPlaceholder("请输入复核意见").fill(`R7E 浏览器复核 ${marker}`); + await page.getByRole("button", { name: "提交复核" }).click(); + await expect(pendingRow).toContainText(/复核 1\/1|复核 1\/2/, { timeout: 30_000 }); + await logoutLiveUser(page, { name: financeMgr.name }).catch(() => undefined); + + const financeAgain = await loginRole(page, "finance"); + await page.goto(`/payment-batches?id=${batch.id}`); + const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last(); + await expect(batchDetailCard).toBeVisible(); + await batchDetailCard.getByRole("button", { name: "执行" }).click(); + await expect(batchDetailCard).toContainText("状态:已执行", { timeout: 30_000 }); + await page.goto(`/reimbursements/${state.reimbursementId}`); + await reimbursementDetail.assertLoaded(); + await expect(page.getByTestId("reimbursement-payment-status")).toContainText(/已付款|paid/, { timeout: 30_000 }); + await page.goto("/ap-operations"); + await page.getByPlaceholder("单号、姓名、部门、项目").fill(state.reimbursementCode ?? state.reimbursementId); + await expect(page.getByTestId("finance-ledger-table")).toContainText(/已付款|paid/, { timeout: 30_000 }); + await logoutLiveUser(page, { name: financeAgain.name }).catch(() => undefined); + + const admin = await loginRole(page, "admin"); + await page.goto("/payment-batches"); + if (/\/403/.test(page.url())) { + await expect(page.getByRole("heading", { name: "无权限访问", exact: true })).toBeVisible(); + } else { + const createButton = page.getByRole("button", { name: "创建批次" }).first(); + await expect(createButton).toBeDisabled(); + } + await logoutLiveUser(page, { name: admin.name }).catch(() => undefined); + + return { + businessId: `batch=${batch.id}; reimbursement=${state.reimbursementCode ?? state.reimbursementId}`, + pcResult: "PASS: 财务台账、付款批次、复核、执行和管理员边界已验证", + detail: `paymentBatchId=${batch.id}; reimbursementId=${state.reimbursementId}; financeTask=${financeTaskId || "none"}`, + }; + } finally { + await financeApi.dispose(); + } + }); + + await runStep("移动", "employee/manager/finance", "移动浏览器核心入口无 PC 壳和横向滚动", async () => { + const employee = await loginRole(page, "employee"); + try { + await page.setViewportSize({ width: 390, height: 844 }); + const employeeTargets: Array<[string, string]> = [ + ["/mobile", "mobile-action-grid"], + ["/mobile/reimbursements", "mobile-reimbursements-list"], + ["/mobile/leave", "mobile-leave-records"], + ["/mobile/approvals", "mobile-approvals-list|mobile-approvals-progress"], + ["/mobile/notifications", "mobile-notifications-page"], + ]; + for (const [url, testId] of employeeTargets) { + await page.goto(url); + if (testId.includes("|")) { + await expectAnyVisible(page, testId.split("|")); + } else { + await expect(page.getByTestId(testId)).toBeVisible({ timeout: 30_000 }); + } + await checkNoHorizontalOverflow(page); + } + await logoutLiveUser(page, { name: employee.name }).catch(() => undefined); + + const manager = await loginRole(page, "manager"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mobile/approvals"); + await expectAnyVisible(page, ["mobile-approvals-list", "mobile-approvals-progress"]); + await checkNoHorizontalOverflow(page); + await logoutLiveUser(page, { name: manager.name }).catch(() => undefined); + + const finance = await loginRole(page, "finance"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/mobile"); + await expect(page.getByTestId("mobile-action-grid")).toBeVisible(); + await checkNoHorizontalOverflow(page); + await logoutLiveUser(page, { name: finance.name }).catch(() => undefined); + return { + mobileResult: "PASS: 员工、审批人、财务移动入口可见且无横向滚动", + pcResult: "-", + detail: "checked /mobile, reimbursements, leave, approvals, notifications", + }; + } finally { + await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined); + } + }); + } finally { + writeReports(steps); + } + + const failures = steps.filter((item) => item.status === "FAIL"); + expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0); +}); diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index a7c24ea9..287cf960 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -201,6 +201,22 @@ R7E 结论:公网、live smoke/OCR、移动矩阵、核心业务流、账号 结论:公网 PC 和移动网页自动化基线可继续试运行;`R7E-9..18` 仍需真实角色按企业微信/浏览器操作后记录,且默认本地 E2E 账号链路与当前 R7E smoke 账号链路不一致,需要后续把公网业务流测试切换到 R7E live 凭据或维护测试数据。 +## R7E 浏览器业务先行试运行 + +执行时间:2026-05-14。命令为 `PLAYWRIGHT_MANUAL=1 E2E_MODE=live LIVE_SMOKE_BASE_URL=https://oa.hyinda.com R7E_BROWSER_TRIALRUN=1 npx playwright test tests/e2e/manual/r7e-browser-business-trialrun.spec.ts --workers=1 --reporter=list`。报告路径为 `artifacts/r7e/browser-business-trialrun/report.md`,截图路径为 `output/playwright/r7e-browser-business-trialrun/`。 + +| 流程 | 账号角色 | 测试单据 | PC 结果 | 移动浏览器结果 | 问题编号 | 结论 | +| --- | --- | --- | --- | --- | --- | --- | +| 公共前置 | 员工、审批人、财务、财务主管、管理员 | N/A | 5 类角色公网账号密码登录 `/portal` 均通过;`/ready` 通过 | N/A | - | 通过 | +| 报销 | 员工 | `c28fbf1a-6c3c-492a-a1e4-af0754c4fcfc` | 授权 PDF 样票返回 `used_by_company`,系统阻止重复绑定;未形成可提交报销 | N/A | R7E-ISSUE-BROWSER-001 | 暂停,需更换未使用授权样票或清理测试样票口径后补测 | +| 审批 | 审批人 | 报销审批未生成;采购审批已处理 | 报销审批因报销未提交而跳过;采购审批链路通过 | N/A | - | 部分通过,不关闭 R7E-11 | +| 请假 | 员工、审批人 | 未生成 | 进入请假步骤时命中账号密码登录频率限制“请求过于频繁”,未创建请假单 | N/A | R7E-ISSUE-BROWSER-002 | 暂停,需等待限流窗口或降低角色反复登录频率后补测 | +| 采购 | 员工、审批人、财务主管 | `CG-20260514-0003` | 采购创建、提交、审批、状态和审批历史通过;归档入口按财务主管角色验证 | 移动采购列表和详情通过,无 PC 壳回退证据 | - | 通过,但附件上传未覆盖,不关闭 R7E-13 | +| 财务 | 财务、财务主管、管理员 | N/A | 因报销未形成可付款单据,付款批次和台账付款闭环跳过 | N/A | R7E-ISSUE-BROWSER-003 | 暂停,依赖报销补测 | +| 移动通用 | 员工、审批人、财务 | N/A | N/A | 因整体用例超时和浏览器上下文关闭,未完成独立移动通用补测;此前角色页面 smoke 与移动矩阵仍可作为基础证据 | QAOPS8-TEST-001 | 测试维护项,不作为业务失败 | + +结论:浏览器业务线已经证明“采购 PC/移动主链路”和“公网角色登录前置”可用;报销、请假、财务仍不能关闭。企业微信免登录、消息送达、消息 deeplink、企业微信 WebView 文件选择器和代表设备真机反馈仍留在 `R7E-9`/`R7E-15`,不由本轮浏览器试运行替代。 + ## QAOPS8 Go/No-Go 当前结论 执行时间:2026-05-13。 diff --git a/issues/2026-05-13_17-11-44-r7e-human-trialrun.csv b/issues/2026-05-13_17-11-44-r7e-human-trialrun.csv index 608445b8..d1a53ef8 100644 --- a/issues/2026-05-13_17-11-44-r7e-human-trialrun.csv +++ b/issues/2026-05-13_17-11-44-r7e-human-trialrun.csv @@ -9,13 +9,13 @@ R7E-6,每日收口节奏确认,"确认 R7E 每日收口节奏,包括当天问 R7E-7,暂停和回滚决策人确认,"确认 R7E 试运行中触发暂停、回滚或继续推进的最终决策人。","1) 决策人角色确认;2) 决策条件确认;3) 与回滚联系人不冲突;4) 未确认不得执行真实 R7。","流程级验证:人工确认暂停/回滚/继续决策人。",manual,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""R7E-4"",""R7E-6""]",最终决策人为 wangshunli;触发条件包括 P0 阻断 权限泄漏 财务数据错误 审批链错误 无法登录或无法回 OA 或两个以上高优先级问题当天无法解释清楚;R7E-8 可进入。 R7E-8,公网健康复核,"在真实 R7E 启动当天复核公网匿名健康和 public bundle。","1) /ready 返回 ok=true;2) public bundle findings=0;3) 结果写入试运行报告;4) 失败则暂停试运行。","流程级验证:curl -fsS https://oa.hyinda.com/ready;PUBLIC_BASE_URL=https://oa.hyinda.com node scripts/qa/check-public-bundle-copy.mjs。",curl|node,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""R7E-7""]",2026-05-14 按最新代码 4b14c92 完成受控部署;部署前 client typecheck/build 通过;部署中发现既有 BIZ8 加性 migration 未应用 先创建 DB 备份 hyb-oa-db-20260514_011940.dump 和附件备份 hyb-oa-storage-20260514_011940.tar.gz 后执行 prisma migrate deploy;复跑 deploy.sh 通过且远端 migration up to date;公网 /ready ok=true;public bundle scanned=57 focused=4 findings=0;公网移动矩阵 dg7-mobile-information-dedupe 5 passed。 R7E-9,企业微信入口复核,"在真实 R7E 启动前复核企业微信入口、绑定状态、免登或账号密码兜底、deeplink 回 OA。","1) 企业微信入口可达;2) 绑定用户可进入移动端;3) 未绑定有明确兜底;4) deeplink 回 OA 正确。","流程级验证:代表性真机或企业微信 WebView 人工复核;必要时补 Playwright WeCom UA。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-8""]",2026-05-14 自动化补测通过企业微信配置、OAuth 边界、WeCom UA 移动布局、移动入口矩阵和角色页面 smoke;真实企业微信免登录、系统文件选择器和消息弹窗仍需人工或代表设备确认,故本行不提前 DONE。 -R7E-10,报销流程试运行,"员工按真实路径提交报销或复核已有样例,覆盖发票/OCR/附件/审批/财务查看。","1) 员工可提交或查看报销;2) OCR/PDF-only 口径清晰;3) 审批链可追溯;4) 财务可复核;5) 问题进入登记清单。","流程级验证:员工进入报销 -> 上传/选择材料 -> 提交 -> 审批 -> 财务复核;记录结果和问题。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-9""]",不得提交未经授权真实票据;真实样票规则不清时使用授权样例或跳过 OCR。 -R7E-11,审批流程试运行,"审批人按真实路径处理待办,员工查看进度,验证通知和审批历史一致。","1) 审批人可处理待办;2) 申请人可看进度;3) 审批历史一致;4) 自审和越权被拒绝。","流程级验证:提交申请 -> 审批人处理 -> 申请人查看状态 -> 审计/历史可追溯。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10""]",不引入复杂 BPM 或审批设计器。 -R7E-12,请假流程试运行,"员工提交请假,审批人审批,员工和团队视角查看状态。","1) 请假提交成功;2) 审批处理成功;3) 移动/PC 状态一致;4) 团队状态或日历口径可解释。","流程级验证:员工提交请假 -> 审批通过/驳回 -> 查看进度和团队状态。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-11""]",不扩展打卡 API。 -R7E-13,采购流程试运行,"员工提交或查看采购申请,审批人处理,状态和附件可追溯。","1) 采购申请路径可用;2) 审批处理可用;3) 附件和状态可追溯;4) 移动采购创建如缺失则记录为后续 UX/P2。","流程级验证:员工提交/查看采购 -> 审批处理 -> 查看状态和附件。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-12""]",不引入复杂预算、供应商准入或 ERP。 -R7E-14,财务付款和导出试运行,"财务按真实路径复核报销、标记付款、查看台账或导出。","1) 财务角色入口正确;2) 付款标记或台账查看可用;3) 导出权限边界清楚;4) 管理员不自动获得财务能力。","流程级验证:财务进入台账 -> 复核/付款/导出 -> 检查权限和审计。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10""]",导出真实数据前需用户授权;必要时使用脱敏样例。 +R7E-10,报销流程试运行,"员工按真实路径提交报销或复核已有样例,覆盖发票/OCR/附件/审批/财务查看。","1) 员工可提交或查看报销;2) OCR/PDF-only 口径清晰;3) 审批链可追溯;4) 财务可复核;5) 问题进入登记清单。","流程级验证:员工进入报销 -> 上传/选择材料 -> 提交 -> 审批 -> 财务复核;记录结果和问题。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 浏览器业务线执行:员工可创建报销草稿 c28fbf1a-6c3c-492a-a1e4-af0754c4fcfc 并添加明细;授权 PDF 样票返回 used_by_company 系统阻止重复绑定 未形成可提交报销和审批待办;本行不关闭,需更换未使用授权样票或明确样票清理口径后补测。 +R7E-11,审批流程试运行,"审批人按真实路径处理待办,员工查看进度,验证通知和审批历史一致。","1) 审批人可处理待办;2) 申请人可看进度;3) 审批历史一致;4) 自审和越权被拒绝。","流程级验证:提交申请 -> 审批人处理 -> 申请人查看状态 -> 审计/历史可追溯。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 浏览器业务线执行:采购审批待办已由审批人处理且申请人可回看采购状态和审批历史;报销审批因样票重复未生成待办;请假因登录频率限制未执行;本行保持 TODO,待至少一个完整申请进度 回看 自审或越权边界补齐后关闭。 +R7E-12,请假流程试运行,"员工提交请假,审批人审批,员工和团队视角查看状态。","1) 请假提交成功;2) 审批处理成功;3) 移动/PC 状态一致;4) 团队状态或日历口径可解释。","流程级验证:员工提交请假 -> 审批通过/驳回 -> 查看进度和团队状态。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 浏览器业务线执行:进入请假步骤时账号密码登录命中请求过于频繁限流 未创建请假单;本行不关闭,需等待限流窗口或降低角色反复登录频率后补测;不扩展打卡 API。 +R7E-13,采购流程试运行,"员工提交或查看采购申请,审批人处理,状态和附件可追溯。","1) 采购申请路径可用;2) 审批处理可用;3) 附件和状态可追溯;4) 移动采购创建如缺失则记录为后续 UX/P2。","流程级验证:员工提交/查看采购 -> 审批处理 -> 查看状态和附件。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 浏览器业务线执行:采购 CG-20260514-0003 已完成 PC 创建 提交 审批 状态和审批历史回看 移动采购列表和详情;附件上传未覆盖,故本行暂不关闭;不引入复杂预算 供应商准入或 ERP。 +R7E-14,财务付款和导出试运行,"财务按真实路径复核报销、标记付款、查看台账或导出。","1) 财务角色入口正确;2) 付款标记或台账查看可用;3) 导出权限边界清楚;4) 管理员不自动获得财务能力。","流程级验证:财务进入台账 -> 复核/付款/导出 -> 检查权限和审计。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 浏览器业务线执行:因报销授权样票重复 未形成已审批可付款报销单 财务付款批次和台账付款闭环跳过;本行不关闭;导出真实数据前需用户授权,必要时使用脱敏样例。 R7E-15,移动端和真机反馈,"收集 R7E 参与者在移动浏览器和企业微信中的真实体验反馈。","1) 至少覆盖代表性 iPhone/Android 或企业微信设备;2) 记录页面、视口、角色和问题级别;3) 阻断问题进入修复分流;4) 体验优化进入 UX8。","流程级验证:按真机公网测试清单人工复核;必要时补 Playwright 截图。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-9""]",自动化不能替代新增真实设备反馈。 -R7E-16,每日问题清单,"R7E 期间每天登记问题、级别、归属治理流、处理状态和证据路径。","1) 问题均有级别;2) 阻断问题当天决策;3) 新需求不混入即时修复;4) 每日 closeout 可追溯。","流程级验证:每日人工 closeout;问题分流到 R7E/UX8/ORG8/WF8/BIZ8/QAOPS8。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10"",""R7E-11"",""R7E-12"",""R7E-13"",""R7E-14"",""R7E-15""]",2026-05-14 自动化发现待分流项:R110 移动路由测试断言需按当前 /mobile/leave 默认记录页更新;R105 管理员移动审批页选择器需避免 strict mode 多匹配;默认本地 E2E 用户链路与 R7E smoke 账号不一致导致 PC 业务流失败;报销 PDF 上传后未出现在报销发票区需在真实报销试运行中重点复核。问题记录不得包含密码、token 或隐私明文。 +R7E-16,每日问题清单,"R7E 期间每天登记问题、级别、归属治理流、处理状态和证据路径。","1) 问题均有级别;2) 阻断问题当天决策;3) 新需求不混入即时修复;4) 每日 closeout 可追溯。","流程级验证:每日人工 closeout;问题分流到 R7E/UX8/ORG8/WF8/BIZ8/QAOPS8。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10"",""R7E-11"",""R7E-12"",""R7E-13"",""R7E-14"",""R7E-15""]",2026-05-14 自动化发现待分流项:R110 移动路由测试断言需按当前 /mobile/leave 默认记录页更新;R105 管理员移动审批页选择器需避免 strict mode 多匹配;默认本地 E2E 用户链路与 R7E smoke 账号不一致导致 PC 业务流失败;报销 PDF 上传后未出现在报销发票区需真实报销复核。浏览器业务线新增问题:授权 PDF 样票已用于报销导致 used_by_company 阻止重复绑定;多角色反复登录触发账号密码请求过于频繁限流;长用例超时后移动通用补测出现浏览器上下文关闭,应拆分短用例或降低登录次数。问题记录不得包含密码、token 或隐私明文。 R7E-17,阻断问题修复分流,"根据每日问题清单把阻断问题分流到当前 R7E 修复或后续连续治理流。","1) 阻断问题有 owner;2) 能在 R7E 内修的有方案和验证;3) 范围外问题进入 UX8/ORG8/WF8/BIZ8/QAOPS8;4) 不顺手扩大。","流程级验证:人工核对问题清单和分流结果;必要时创建后续连续 CSV。",manual,TODO,TODO,TODO,issues/*.csv|docs/trialrun/人工试运行报告.md,"[""R7E-16""]",涉及代码修复时必须先方案批准。 R7E-18,验收结论,"汇总 R7E 真实试运行结果,给出通过、暂停、回滚或继续观察结论。","1) 主流程结论完整;2) 阻断问题为 0 或明确暂停/回滚;3) 用户验收结论记录;4) 未完成项进入后续治理流。","流程级验证:人工验收;报告中列出流程结论、问题清单和后续动作。",manual,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-17""]",不能仅凭自动化通过宣布 R7E 完成。 R7E-19,P2 排序调整,"根据 R7E 真实反馈调整 P2 候选优先级和后续连续治理流顺序。","1) P2 排序基于真实反馈;2) ORG8/WF8/BIZ8/QAOPS8 入口明确;3) 冻结项仍不进入;4) 如需变更 PRD 则另走事实源更新。","流程级验证:人工核对 PRD P2T 表、问题清单和后续 Issue 计划。",manual|rg,TODO,TODO,TODO,docs/海音达OA-MVP需求规格说明书.md|issues/*.csv,"[""R7E-18""]",P2 功能开发必须另建或延续对应连续合同。 diff --git a/issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv b/issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv index 9621ffb3..bfa11c45 100644 --- a/issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv +++ b/issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv @@ -16,7 +16,7 @@ QAOPS8-13,DORA 交付指标基线,"建立部署频率、变更前置时间、变 QAOPS8-14,安全发布前检查,"建立发布前安全检查,包括权限、敏感信息、日志、配置、依赖和等保相关底线。","1) 不泄露 token、密码、票据和个人隐私;2) 权限直连审计有证据;3) 日志不记录敏感明文;4) 高风险项进入 Go/No-Go。","流程级验证:node scripts/qa/audit-docs.mjs --check-links;rg -n 'password|token|secret|密钥|权限直连|敏感' docs scripts issues。",node|rg|manual,DONE,DONE,DONE,scripts/qa/audit-docs.mjs|docs|scripts|issues,"[""QAOPS8-9""]",node scripts/qa/audit-docs.mjs --check-links 为 errors=0 warnings=0;全量 audit-docs 为 errors=0 warnings=8;已知 QA 脚本本地测试哨兵值和未登记 docs 作为 Go/No-Go 复核项,不声明等保测评完成。 QAOPS8-15,发布 Runbook 收敛,"把发布前检查、部署步骤、验证步骤、暂停条件和沟通责任收敛到现有 Runbook。","1) 发布步骤可按顺序执行;2) 预检、部署、验证、暂停、回滚边界清楚;3) 不新增冲突文档;4) 用户可审计。","流程级验证:rg -n '发布|预检|部署|验证|暂停|回滚|Runbook' docs/trialrun/发布试运行准备清单.md docs/trialrun/OPS7运维交接Runbook.md docs/试运行与自动化验证手册.md。",rg|manual,DONE,DONE,DONE,docs/trialrun/发布试运行准备清单.md|docs/trialrun/OPS7运维交接Runbook.md|docs/试运行与自动化验证手册.md,"[""QAOPS8-5"",""QAOPS8-6"",""QAOPS8-10""]",rg 复核通过;发布前检查、部署步骤、部署后验证、暂停条件、回滚边界和责任记录均在现有 Runbook 中;未新增冲突文档。 QAOPS8-16,回滚演练窗口,"确认回滚演练窗口、负责人、触发条件、数据保护和恢复验证步骤。","1) 窗口和负责人确认;2) 回滚脚本或步骤有只读预检;3) 数据保护优先;4) 演练结果可追溯。","流程级验证:人工确认回滚窗口;必要时先在非生产环境执行。",manual,DONE,DONE,DONE,docs/trialrun/OPS7运维交接Runbook.md,"[""QAOPS8-11"",""QAOPS8-15""]",完成:按用户授权采用推荐方案;窗口暂定 2026-05-14 19:30-20:30 Asia/Shanghai 即 R7E 首日每日收口后;负责人和暂停回滚决策人 wangshunli;范围为非生产恢复演练优先 生产只读回滚 dry run;步骤为备份清单核对 隔离库恢复 /ready 登录核心流程只读验证 生产回滚步骤只读复盘;触发条件为 P0 数据损坏 发布阻断或公网关键门禁失败;停止条件为备份不完整 权限不明确 影响生产或责任人不在线;本行只确认窗口和边界 未执行数据库恢复 生产回滚 服务切换或破坏性命令;真实恢复或生产回滚执行前仍需再次明确批准。 -QAOPS8-17,上线 Go No-Go 清单,"建立上线或扩大试运行 Go/No-Go 清单,统一本地、公网匿名、带凭据、企业微信真机和人工验收结论。","1) Go、No-Go、Pause 条件明确;2) 阻断问题为 0 或有明确延期;3) R7E 人工验收不被自动化替代;4) 结论写入 Issue 和报告。","流程级验证:人工核对 R7E 结论、QAOPS8 门禁、发布清单和问题清单。",manual,DOING,TODO,TODO,docs/trialrun/发布试运行准备清单.md|docs/trialrun/人工试运行报告.md|issues/*.csv,"[""QAOPS8-15"",""QAOPS8-16""]",当前清单已形成:2026-05-14 最新代码 4b14c92 已部署到试运行环境且 R7E-8 完成;PC/移动自动化补测已记录,live readiness、角色页面 smoke、移动矩阵、R104/R105 大部分通过;仍有测试维护项和真实人工项;明确 R7E 人工验收不被自动化替代;因 R7E-9..18 WF8-11 BIZ8-12 仍是未完成阻断项 本行不标 DONE。 +QAOPS8-17,上线 Go No-Go 清单,"建立上线或扩大试运行 Go/No-Go 清单,统一本地、公网匿名、带凭据、企业微信真机和人工验收结论。","1) Go、No-Go、Pause 条件明确;2) 阻断问题为 0 或有明确延期;3) R7E 人工验收不被自动化替代;4) 结论写入 Issue 和报告。","流程级验证:人工核对 R7E 结论、QAOPS8 门禁、发布清单和问题清单。",manual,DOING,TODO,TODO,docs/trialrun/发布试运行准备清单.md|docs/trialrun/人工试运行报告.md|issues/*.csv,"[""QAOPS8-15"",""QAOPS8-16""]",当前清单已形成:2026-05-14 最新代码 4b14c92 已部署到试运行环境且 R7E-8 完成;PC/移动自动化补测已记录;R7E 浏览器业务线可先 Go 并用公网 PC/移动浏览器验证报销 审批 请假 采购 财务;2026-05-14 浏览器业务试运行结果为角色前置登录通过 采购 PC/移动通过 报销因样票 used_by_company 暂停 请假因账号密码请求过于频繁限流暂停 财务因报销未形成可付款单据暂停;企业微信专属线仍为免登录 消息 deeplink WebView 文件选择器和真机渲染后补门禁;仍有测试维护项和真实人工项;明确 R7E 人工验收不被自动化替代;因 R7E-9 R7E-10 R7E-12 R7E-14 R7E-15 R7E-18 WF8-11 BIZ8-12 仍是未完成阻断项 本行不标 DONE。 QAOPS8-18,只读审计和排障边界,"明确公网、数据库、日志和 MCP 只读审计边界,避免排障时误写生产或泄露敏感信息。","1) 只读查询边界明确;2) 禁止 INSERT、UPDATE、DELETE;3) 敏感字段脱敏;4) 排障证据只记录摘要和路径。","流程级验证:rg -n 'audit-readonly|只读|INSERT|UPDATE|DELETE|MCP|PostgreSQL' AGENTS.md docs/mcp-tools.md docs/开发交付与变更管理规约.md。",rg|manual,DONE,DONE,DONE,AGENTS.md|docs/mcp-tools.md|docs/开发交付与变更管理规约.md,"[""QAOPS8-14""]",rg 复核通过;AGENTS 限定 audit-readonly 和 PostgreSQL MCP 只读查询;docs/mcp-tools 限定 SELECT 和禁止写操作;开发交付规约已补日志和证据摘要脱敏边界;未执行生产查询或启用新 MCP。 QAOPS8-19,R7E 后上线硬化收口,"在 R7E 真实反馈后集中收口上线硬化,更新门禁缺口、问题分流和后续治理优先级。","1) R7E 结论已记录;2) QAOPS8 未完成项有归属;3) 阻断问题有 owner;4) 后续 ORG8、WF8、BIZ8、QAOPS8 顺序可解释。","流程级验证:人工核对 R7E-18、R7E-19、QAOPS8 当前状态和问题清单。",manual,TODO,TODO,TODO,issues/2026-05-13_17-11-44-r7e-human-trialrun.csv|issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv,"[""QAOPS8-17""]",依赖 R7E 人工试运行结论;当前不能提前完成。 QAOPS8-20,QAOPS8 收口,"完成 QAOPS8 状态回填、验证摘要、证据索引和中文提交。","1) CSV 校验通过;2) 所有 DONE 有命令或人工证据;3) 文档索引和状态矩阵一致;4) git diff --check 通过;5) 中文提交引用 QAOPS8。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv;node scripts/qa/audit-docs.mjs --check-registry;git diff --check。",python3|node|git|manual,TODO,TODO,TODO,issues/2026-05-13_17-30-00-qaops8-test-release-ops.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md,"[""QAOPS8-19""]",所有执行行完成后才能收口。