R7E 无人工操作自动化证据回填
这个提交包含在:
@@ -157,6 +157,48 @@ export async function createAuthenticatedApiClient(user: E2EUser, baseURL = E2E_
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAuthenticatedBrowserStorageState(user: E2EUser, baseURL = E2E_BASE_URL) {
|
||||
const deviceFingerprint = buildDeviceFingerprint(user);
|
||||
const loginContext = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
extraHTTPHeaders: {
|
||||
"x-device-fingerprint": deviceFingerprint,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const loginResponse = await loginContext.post("/api/auth/login", {
|
||||
data: {
|
||||
identifier: user.identifier,
|
||||
password: user.password,
|
||||
},
|
||||
});
|
||||
|
||||
await readJson<{ accessToken: string }>(loginResponse);
|
||||
const state = await loginContext.storageState();
|
||||
const origin = new URL(baseURL).origin;
|
||||
const originState = state.origins.find((item) => item.origin === origin);
|
||||
const deviceFingerprintEntry = {
|
||||
name: "hyb_oa_device_fingerprint",
|
||||
value: deviceFingerprint,
|
||||
};
|
||||
|
||||
if (originState) {
|
||||
originState.localStorage = originState.localStorage.filter((item) => item.name !== deviceFingerprintEntry.name);
|
||||
originState.localStorage.push(deviceFingerprintEntry);
|
||||
} else {
|
||||
state.origins.push({
|
||||
origin,
|
||||
localStorage: [deviceFingerprintEntry],
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
} finally {
|
||||
await loginContext.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGetData<T>(api: APIRequestContext, path: string) {
|
||||
const response = await api.get(path);
|
||||
return readJson<T>(response);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import { createAuthenticatedApiClient, logout, type AuthenticatedApiClient, type E2EUser } from "./auth";
|
||||
import { createAuthenticatedApiClient, createAuthenticatedBrowserStorageState, logout, type AuthenticatedApiClient, type E2EUser } from "./auth";
|
||||
|
||||
export type LiveSmokeRole = "admin" | "employee" | "manager" | "finance" | "financeMgr";
|
||||
|
||||
@@ -19,6 +19,11 @@ function requireEnv(key: string) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function liveUrl(pathname: string) {
|
||||
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim();
|
||||
return baseURL ? new URL(pathname, baseURL).toString() : pathname;
|
||||
}
|
||||
|
||||
export function getExpectedWecomStatus() {
|
||||
return process.env.LIVE_EXPECT_WECOM_STATUS?.trim() || "disabled";
|
||||
}
|
||||
@@ -68,7 +73,7 @@ async function readCurrentUserName(page: Page): Promise<string> {
|
||||
export async function loginLiveUser(page: Page, role: LiveSmokeRole): Promise<LiveSmokeUser> {
|
||||
const credentials = getLiveSmokeUser(role);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.goto(liveUrl("/login"));
|
||||
await expect(page.getByTestId("login-identifier")).toBeVisible();
|
||||
await page.getByTestId("login-identifier").fill(credentials.identifier);
|
||||
await page.getByTestId("login-password").fill(credentials.password);
|
||||
@@ -112,3 +117,8 @@ export async function createLiveApiClient(role: LiveSmokeRole): Promise<Authenti
|
||||
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com";
|
||||
return createAuthenticatedApiClient(getRequiredLiveUser(role), baseURL);
|
||||
}
|
||||
|
||||
export async function createLiveBrowserStorageState(role: LiveSmokeRole) {
|
||||
const baseURL = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com";
|
||||
return createAuthenticatedBrowserStorageState(getRequiredLiveUser(role), baseURL);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type APIRequestContext, type Browser, 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 { createLiveApiClient, createLiveBrowserStorageState, loginLiveBrowserUser, logoutLiveUser, type LiveSmokeRole } from "../helpers/liveAuth";
|
||||
import { openNavLink } from "../helpers/navigation";
|
||||
import { TEST_IDS } from "../helpers/testIds";
|
||||
import { waitForWorkflowTask } from "../helpers/workflow";
|
||||
import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "../helpers/workflow";
|
||||
|
||||
type StepStatus = "PASS" | "FAIL" | "SKIP";
|
||||
type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动";
|
||||
type FlowName = "公共前置" | "报销" | "审批" | "请假" | "采购" | "财务" | "移动" | "企业微信UA";
|
||||
type FlowKey = "preflight" | "reimbursement-finance" | "leave" | "purchase-attachment" | "mobile" | "wecom-ua" | "all";
|
||||
|
||||
type TrialStep = {
|
||||
flow: FlowName;
|
||||
@@ -26,7 +27,7 @@ type TrialStep = {
|
||||
screenshot: string;
|
||||
};
|
||||
|
||||
type StepResult = string | Partial<Pick<TrialStep, "status" | "businessId" | "pcResult" | "mobileResult" | "issueId" | "conclusion" | "detail">>;
|
||||
type StepResult = string | Partial<Pick<TrialStep, "status" | "businessId" | "pcResult" | "mobileResult" | "issueId" | "conclusion" | "detail" | "screenshot">>;
|
||||
|
||||
type ReimbursementDetail = {
|
||||
id: string;
|
||||
@@ -71,6 +72,10 @@ 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");
|
||||
const validFlows = new Set<FlowKey>(["preflight", "reimbursement-finance", "leave", "purchase-attachment", "mobile", "wecom-ua", "all"]);
|
||||
const loginRateLimitRetryMs = Number.parseInt(process.env.R7E_LOGIN_RETRY_WAIT_MS ?? "65000", 10);
|
||||
const wecomUa =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
|
||||
function nowStamp() {
|
||||
return String(Date.now());
|
||||
@@ -86,6 +91,18 @@ function uniqueFutureOffset(minOffsetDays: number) {
|
||||
return minOffsetDays + (Math.floor(Date.now() / 1000) % 2000);
|
||||
}
|
||||
|
||||
function futureWeekday(minOffsetDays: number) {
|
||||
for (let offset = minOffsetDays; offset < minOffsetDays + 14; offset += 1) {
|
||||
const value = new Date();
|
||||
value.setUTCDate(value.getUTCDate() + offset);
|
||||
const weekday = value.getUTCDay();
|
||||
if (weekday >= 1 && weekday <= 5) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return formatDate(minOffsetDays);
|
||||
}
|
||||
|
||||
function safeName(value: string) {
|
||||
return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80);
|
||||
}
|
||||
@@ -102,6 +119,33 @@ function stripAnsi(value: string) {
|
||||
return value.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function resolveSelectedFlow(): FlowKey {
|
||||
const rawFlow = (process.env.R7E_BROWSER_FLOW?.trim() || "all") as FlowKey;
|
||||
if (!validFlows.has(rawFlow)) {
|
||||
throw new Error(`不支持的 R7E_BROWSER_FLOW:${rawFlow}`);
|
||||
}
|
||||
return rawFlow;
|
||||
}
|
||||
|
||||
function liveUrl(pathname: string) {
|
||||
return new URL(pathname, process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com").toString();
|
||||
}
|
||||
|
||||
function shouldRunFlow(selectedFlow: FlowKey, flow: FlowKey) {
|
||||
return selectedFlow === "all" || selectedFlow === flow;
|
||||
}
|
||||
|
||||
function looksLikeLoginRateLimit(error: unknown) {
|
||||
const message = stripAnsi(error instanceof Error ? error.message : String(error));
|
||||
return /请求过于频繁|rate.?limit|too many requests|HTTP_429|429/i.test(message);
|
||||
}
|
||||
|
||||
async function waitForLoginRateLimitWindow() {
|
||||
if (Number.isFinite(loginRateLimitRetryMs) && loginRateLimitRetryMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, loginRateLimitRetryMs));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLivePdfSample() {
|
||||
const candidate = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() || process.env.LIVE_OCR_INVOICE_FILE?.trim() || "";
|
||||
if (!candidate || !existsSync(candidate)) {
|
||||
@@ -222,6 +266,7 @@ async function createLeaveDraft(page: Page, startDate: string, reason: string) {
|
||||
await page.locator("#leave-start-session").selectOption("am");
|
||||
await page.locator("#leave-end-session").selectOption("pm");
|
||||
await page.locator("#leave-reason").fill(reason);
|
||||
await expect(page.getByRole("button", { name: "创建草稿" })).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: "创建草稿" }).click();
|
||||
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
@@ -251,13 +296,64 @@ async function expectAnyVisible(page: Page, testIds: string[]) {
|
||||
}
|
||||
|
||||
async function loginRole(page: Page, role: LiveSmokeRole) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto("/login");
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
async function runLoginAttempt() {
|
||||
await page.context().clearCookies();
|
||||
await page.goto(liveUrl("/login"));
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
return loginLiveBrowserUser(page, role);
|
||||
}
|
||||
|
||||
try {
|
||||
return await runLoginAttempt();
|
||||
} catch (error) {
|
||||
if (!looksLikeLoginRateLimit(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForLoginRateLimitWindow();
|
||||
return runLoginAttempt();
|
||||
}
|
||||
}
|
||||
|
||||
async function createLiveApiClientWithRetry(role: LiveSmokeRole) {
|
||||
try {
|
||||
return await createLiveApiClient(role);
|
||||
} catch (error) {
|
||||
if (!looksLikeLoginRateLimit(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForLoginRateLimitWindow();
|
||||
return createLiveApiClient(role);
|
||||
}
|
||||
}
|
||||
|
||||
async function createLiveBrowserStorageStateWithRetry(role: LiveSmokeRole) {
|
||||
try {
|
||||
return await createLiveBrowserStorageState(role);
|
||||
} catch (error) {
|
||||
if (!looksLikeLoginRateLimit(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForLoginRateLimitWindow();
|
||||
return createLiveBrowserStorageState(role);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMobileRolePage(browser: Browser, role: LiveSmokeRole) {
|
||||
const storageState = await createLiveBrowserStorageStateWithRetry(role);
|
||||
const context = await browser.newContext({
|
||||
baseURL: process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com",
|
||||
storageState,
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
return loginLiveBrowserUser(page, role);
|
||||
const mobilePage = await context.newPage();
|
||||
return { context, page: mobilePage };
|
||||
}
|
||||
|
||||
function writeReports(steps: TrialStep[]) {
|
||||
@@ -265,11 +361,15 @@ function writeReports(steps: TrialStep[]) {
|
||||
const failures = steps.filter((item) => item.status === "FAIL");
|
||||
const skips = steps.filter((item) => item.status === "SKIP");
|
||||
const generatedAt = new Date().toISOString();
|
||||
const flowName = process.env.R7E_BROWSER_FLOW?.trim() || "all";
|
||||
const flowReportPath = path.join(reportDir, `report-${safeName(flowName)}.md`);
|
||||
const flowIssueCandidatesPath = path.join(reportDir, `issues-${safeName(flowName)}.csv`);
|
||||
|
||||
const lines = [
|
||||
"# R7E 浏览器业务先行试运行报告",
|
||||
"",
|
||||
`生成时间:${generatedAt}`,
|
||||
`执行 flow:${flowName}`,
|
||||
"",
|
||||
"## 结论",
|
||||
"",
|
||||
@@ -281,7 +381,7 @@ function writeReports(steps: TrialStep[]) {
|
||||
"",
|
||||
"## 固定口径",
|
||||
"",
|
||||
"- 本报告只覆盖公网 PC/移动浏览器业务线。",
|
||||
"- 本报告只覆盖公网 PC/移动浏览器业务线和企业微信 UA 模拟补测。",
|
||||
"- 企业微信免登录、企业微信消息送达、消息 deeplink、企业微信 WebView 文件选择器和代表设备真机反馈仍需单独验收。",
|
||||
"- 报告不记录密码、token、票据内容或私人联系方式。",
|
||||
"",
|
||||
@@ -301,7 +401,9 @@ function writeReports(steps: TrialStep[]) {
|
||||
`| ${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 reportContent = `${lines.join("\n")}\n`;
|
||||
writeFileSync(reportPath, reportContent, "utf-8");
|
||||
writeFileSync(flowReportPath, reportContent, "utf-8");
|
||||
|
||||
const issueHeader = ["ID", "Flow", "Severity", "Title", "Actual", "Screenshot", "Disposition", "Status", "Notes"];
|
||||
const issueRows = failures.map((item, index) =>
|
||||
@@ -312,24 +414,27 @@ function writeReports(steps: TrialStep[]) {
|
||||
item.title,
|
||||
item.detail,
|
||||
item.screenshot || "N/A",
|
||||
item.flow === "移动" ? "QAOPS8/UX8" : "R7E/WF8/BIZ8",
|
||||
item.flow === "移动" || item.flow === "企业微信UA" ? "QAOPS8/UX8/R7E" : "R7E/WF8/BIZ8",
|
||||
"OPEN",
|
||||
"由 R7E 浏览器业务先行试运行生成,需人工复核严重级别。",
|
||||
].map(csvCell).join(","),
|
||||
);
|
||||
writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8");
|
||||
const issueContent = `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`;
|
||||
writeFileSync(issueCandidatesPath, issueContent, "utf-8");
|
||||
writeFileSync(flowIssueCandidatesPath, issueContent, "utf-8");
|
||||
}
|
||||
|
||||
test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、财务、移动浏览器", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||
test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、财务、移动浏览器", async ({ page, browser, 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);
|
||||
test.setTimeout(900_000);
|
||||
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
const steps: TrialStep[] = [];
|
||||
const state: TrialState = {};
|
||||
const marker = nowStamp();
|
||||
const selectedFlow = resolveSelectedFlow();
|
||||
let stepIndex = 0;
|
||||
|
||||
async function runStep(flow: FlowName, role: string, title: string, action: () => Promise<StepResult | void>) {
|
||||
@@ -339,7 +444,9 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
try {
|
||||
const rawResult = await action();
|
||||
const result = typeof rawResult === "string" || !rawResult ? { detail: rawResult || "ok" } : rawResult;
|
||||
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined);
|
||||
if (!result.screenshot) {
|
||||
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined);
|
||||
}
|
||||
steps.push({
|
||||
flow,
|
||||
role,
|
||||
@@ -351,7 +458,7 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
issueId: result.issueId ?? "-",
|
||||
conclusion: result.conclusion ?? "通过",
|
||||
detail: result.detail ?? "ok",
|
||||
screenshot: relativeScreenshot,
|
||||
screenshot: result.screenshot ?? relativeScreenshot,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = stripAnsi(error instanceof Error ? error.message : String(error));
|
||||
@@ -401,21 +508,46 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
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 (shouldRunFlow(selectedFlow, "preflight")) {
|
||||
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;
|
||||
}
|
||||
if (selectedFlow === "preflight") {
|
||||
return;
|
||||
}
|
||||
|
||||
await runStep("报销", "employee", "创建报销、上传授权 PDF 并提交", async () => {
|
||||
if (shouldRunFlow(selectedFlow, "reimbursement-finance")) {
|
||||
const existingReimbursementId = process.env.R7E_EXISTING_REIMBURSEMENT_ID?.trim() || "";
|
||||
if (existingReimbursementId) {
|
||||
await runStep("报销", "employee/finance", "复用已创建报销单进入财务验证", async () => {
|
||||
const employeeApi = await createLiveApiClientWithRetry("employee");
|
||||
try {
|
||||
const detail = await apiGetData<ReimbursementDetail>(employeeApi.api, `/api/reimbursements/${existingReimbursementId}`);
|
||||
state.reimbursementId = existingReimbursementId;
|
||||
state.reimbursementCode = detail.formNumber ?? existingReimbursementId;
|
||||
state.reimbursementSubmitted = true;
|
||||
return {
|
||||
businessId: `${state.reimbursementCode}; status=${detail.status}`,
|
||||
pcResult: "PASS: 复用本轮已创建并提交的报销单进入财务验证",
|
||||
detail: `existing reimbursementId=${existingReimbursementId}; status=${detail.status}`,
|
||||
};
|
||||
} finally {
|
||||
await employeeApi.dispose();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await runStep("报销", "employee", "创建报销、上传授权 PDF 并提交", async () => {
|
||||
const pdfSample = resolveLivePdfSample();
|
||||
if (!pdfSample) {
|
||||
return {
|
||||
@@ -426,7 +558,7 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
};
|
||||
}
|
||||
|
||||
const employeeApi = await createLiveApiClient("employee");
|
||||
const employeeApi = await createLiveApiClientWithRetry("employee");
|
||||
const user = await loginRole(page, "employee");
|
||||
try {
|
||||
await (await openNavLink(page, "我的报销")).click();
|
||||
@@ -499,12 +631,13 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
if (!state.reimbursementSubmitted || !state.reimbursementTaskId) {
|
||||
return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 报销未提交", detail: "报销授权 PDF 样票不可绑定,未产生报销审批待办。" };
|
||||
}
|
||||
const managerApi = await createLiveApiClient("manager");
|
||||
const employeeUser = await loginRole(page, "employee");
|
||||
let managerApi: Awaited<ReturnType<typeof createLiveApiClientWithRetry>> | null = null;
|
||||
try {
|
||||
const taskId = state.reimbursementTaskId
|
||||
?? (await waitForWorkflowTask(managerApi.api, { entityType: "reimbursement", entityId: state.reimbursementId })).id;
|
||||
await logoutLiveUser(page, { name: employeeUser.name }).catch(() => undefined);
|
||||
let taskId = state.reimbursementTaskId;
|
||||
if (!taskId) {
|
||||
managerApi = await createLiveApiClientWithRetry("manager");
|
||||
taskId = (await waitForWorkflowTask(managerApi.api, { entityType: "reimbursement", entityId: state.reimbursementId })).id;
|
||||
}
|
||||
await approveTaskViaBrowser("manager", taskId, /对象类型:报销|reimbursement|报销/);
|
||||
|
||||
const employee = await loginRole(page, "employee");
|
||||
@@ -518,13 +651,16 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
detail: `approved reimbursement task=${taskId}`,
|
||||
};
|
||||
} finally {
|
||||
await managerApi.dispose();
|
||||
await managerApi?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await runStep("请假", "employee/manager", "请假提交、审批和移动记录页回看", async () => {
|
||||
const employeeApi = await createLiveApiClient("employee");
|
||||
const startDate = formatDate(uniqueFutureOffset(3200));
|
||||
if (shouldRunFlow(selectedFlow, "leave")) {
|
||||
await runStep("请假", "employee/manager", "请假提交、审批和移动记录页回看", async () => {
|
||||
const employeeApi = await createLiveApiClientWithRetry("employee");
|
||||
const startDate = futureWeekday(uniqueFutureOffset(3200));
|
||||
const employee = await loginRole(page, "employee");
|
||||
try {
|
||||
await (await openNavLink(page, "请假")).click();
|
||||
@@ -565,8 +701,10 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await runStep("采购", "employee/manager/financeMgr", "采购提交、审批、归档和移动详情回看", async () => {
|
||||
if (shouldRunFlow(selectedFlow, "purchase-attachment")) {
|
||||
await runStep("采购", "employee/manager/financeMgr", "采购提交、审批、归档和移动详情回看", async () => {
|
||||
const employee = await loginRole(page, "employee");
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
@@ -602,6 +740,22 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
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 purchaseAttachmentName = `r7e-purchase-attachment-${marker}.txt`;
|
||||
const uploadAttachmentResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`;
|
||||
});
|
||||
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
|
||||
name: purchaseAttachmentName,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(`R7E purchase attachment ${marker}\n`, "utf-8"),
|
||||
});
|
||||
const uploadAttachmentResponse = await uploadAttachmentResponsePromise;
|
||||
if (!uploadAttachmentResponse.ok()) {
|
||||
throw new Error(`采购附件上传失败:HTTP ${uploadAttachmentResponse.status()} ${await uploadAttachmentResponse.text()}`);
|
||||
}
|
||||
await expect(page.locator("tr").filter({ hasText: purchaseAttachmentName }).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const submitResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`,
|
||||
);
|
||||
@@ -620,53 +774,48 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 });
|
||||
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
|
||||
|
||||
await approveTaskViaBrowser("manager", currentTaskId, /对象类型:采购申请|采购/);
|
||||
const managerApi = await createLiveApiClientWithRetry("manager");
|
||||
try {
|
||||
await approveWorkflowTaskViaApi(managerApi.api, currentTaskId);
|
||||
} finally {
|
||||
await managerApi.dispose();
|
||||
}
|
||||
|
||||
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 expect(page.locator("tr").filter({ hasText: purchaseAttachmentName }).first()).toBeVisible({ timeout: 30_000 });
|
||||
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 expect(page.getByTestId("mobile-purchase-detail-attachments")).toContainText(purchaseAttachmentName, { timeout: 30_000 });
|
||||
await checkNoHorizontalOverflow(page);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await logoutLiveUser(page, { name: mobileEmployee.name }).catch(() => undefined);
|
||||
await logoutLiveUser(page, { name: employeeAgain.name }).catch(() => undefined);
|
||||
|
||||
return {
|
||||
businessId: `${state.purchaseRequestNo}; task=${currentTaskId}`,
|
||||
pcResult: "PASS: 采购提交、审批、状态和历史可追溯",
|
||||
mobileResult: "PASS: 移动采购列表和详情可见",
|
||||
detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}`,
|
||||
pcResult: "PASS: 采购附件、提交、审批、状态和历史可追溯",
|
||||
mobileResult: "PASS: 移动采购列表、详情和附件可见",
|
||||
detail: `purchaseRequestId=${purchaseRequestId}; requestNo=${state.purchaseRequestNo}; attachment=${purchaseAttachmentName}`,
|
||||
};
|
||||
} finally {
|
||||
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await runStep("财务", "finance/financeMgr/admin", "财务审批、付款批次、台账和管理员边界", async () => {
|
||||
if (shouldRunFlow(selectedFlow, "reimbursement-finance")) {
|
||||
await runStep("财务", "finance/financeMgr/admin", "财务审批、付款批次、台账和管理员边界", async () => {
|
||||
if (!state.reimbursementId || !state.reimbursementSubmitted) {
|
||||
return { status: "SKIP", conclusion: "暂停", pcResult: "SKIP: 缺少已提交报销", detail: "报销链路未产出可付款单据。" };
|
||||
}
|
||||
|
||||
const financeApi = await createLiveApiClient("finance");
|
||||
const financeApi = await createLiveApiClientWithRetry("finance");
|
||||
try {
|
||||
let financeTaskId = "";
|
||||
try {
|
||||
@@ -691,15 +840,45 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
|
||||
const finance = await loginRole(page, "finance");
|
||||
await page.goto("/ap-operations");
|
||||
await expect(page.getByRole("heading", { name: "AP 运营", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /AP 运营|财务付款与对账/ }).first()).toBeVisible();
|
||||
if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) {
|
||||
return {
|
||||
status: "SKIP",
|
||||
businessId: state.reimbursementCode ?? state.reimbursementId,
|
||||
pcResult: "SKIP: 财务付款功能开关未启用",
|
||||
conclusion: "暂停",
|
||||
detail: "报销审批已完成;/ap-operations 返回当前功能未启用,未创建付款批次或执行付款。",
|
||||
};
|
||||
}
|
||||
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 ledgerHasReimbursement = await expect(page.getByTestId("finance-ledger-table"))
|
||||
.toContainText(state.reimbursementCode ?? state.reimbursementId, { timeout: 30_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ledgerHasReimbursement) {
|
||||
return {
|
||||
status: "SKIP",
|
||||
businessId: state.reimbursementCode ?? state.reimbursementId,
|
||||
pcResult: "SKIP: 已审批报销未进入 AP 台账筛选结果",
|
||||
conclusion: "暂停",
|
||||
detail: `报销审批已完成;finance-ledger-table 按 ${state.reimbursementCode ?? state.reimbursementId} 筛选为空,未创建付款批次或执行付款。`,
|
||||
};
|
||||
}
|
||||
|
||||
const batchName = `R7E 浏览器付款批次 ${marker}`;
|
||||
state.paymentBatchName = batchName;
|
||||
await page.goto("/payment-batches");
|
||||
await expect(page.getByRole("heading", { name: "支付批次", exact: true })).toBeVisible();
|
||||
if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) {
|
||||
return {
|
||||
status: "SKIP",
|
||||
businessId: state.reimbursementCode ?? state.reimbursementId,
|
||||
pcResult: "SKIP: 支付批次功能开关未启用",
|
||||
conclusion: "暂停",
|
||||
detail: "报销审批已完成;/payment-batches 返回当前功能未启用,未创建付款批次或执行付款。",
|
||||
};
|
||||
}
|
||||
const createCard = page.locator(".card-std").filter({ hasText: "新建批次" }).first();
|
||||
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled();
|
||||
const createBatchResponsePromise = page.waitForResponse((response) => {
|
||||
@@ -770,51 +949,112 @@ test("R7E 浏览器业务先行试运行:报销、审批、请假、采购、
|
||||
await financeApi.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await runStep("移动", "employee/manager/finance", "移动浏览器核心入口无 PC 壳和横向滚动", async () => {
|
||||
const employee = await loginRole(page, "employee");
|
||||
if (shouldRunFlow(selectedFlow, "mobile")) {
|
||||
await runStep("移动", "employee/manager/finance", "移动浏览器核心入口无 PC 壳和横向滚动", async () => {
|
||||
let lastScreenshot = "";
|
||||
const captureMobile = async (mobilePage: Page, role: LiveSmokeRole, url: string) => {
|
||||
const screenshotPath = path.join(screenshotDir, `${safeName(`mobile-${role}-${url}`)}.png`);
|
||||
await mobilePage.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
|
||||
lastScreenshot = path.relative(rootDir, screenshotPath);
|
||||
};
|
||||
|
||||
const employeeContext = await createMobileRolePage(browser, "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"],
|
||||
["/mobile/purchase-requests", "mobile-purchase-requests-list"],
|
||||
];
|
||||
for (const [url, testId] of employeeTargets) {
|
||||
await page.goto(url);
|
||||
await employeeContext.page.goto(url);
|
||||
if (testId.includes("|")) {
|
||||
await expectAnyVisible(page, testId.split("|"));
|
||||
await expectAnyVisible(employeeContext.page, testId.split("|"));
|
||||
} else {
|
||||
await expect(page.getByTestId(testId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(employeeContext.page.getByTestId(testId)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
await checkNoHorizontalOverflow(page);
|
||||
await checkNoHorizontalOverflow(employeeContext.page);
|
||||
await captureMobile(employeeContext.page, "employee", url);
|
||||
}
|
||||
await logoutLiveUser(page, { name: employee.name }).catch(() => undefined);
|
||||
} finally {
|
||||
await employeeContext.context.close();
|
||||
}
|
||||
|
||||
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 managerContext = await createMobileRolePage(browser, "manager");
|
||||
try {
|
||||
await managerContext.page.goto("/mobile/approvals");
|
||||
await expectAnyVisible(managerContext.page, ["mobile-approvals-list", "mobile-approvals-progress"]);
|
||||
await checkNoHorizontalOverflow(managerContext.page);
|
||||
await captureMobile(managerContext.page, "manager", "/mobile/approvals");
|
||||
} finally {
|
||||
await managerContext.context.close();
|
||||
}
|
||||
|
||||
const financeContext = await createMobileRolePage(browser, "finance");
|
||||
try {
|
||||
await financeContext.page.goto("/mobile");
|
||||
await expect(financeContext.page.getByTestId("mobile-action-grid")).toBeVisible({ timeout: 30_000 });
|
||||
await checkNoHorizontalOverflow(financeContext.page);
|
||||
await captureMobile(financeContext.page, "finance", "/mobile");
|
||||
} finally {
|
||||
await financeContext.context.close();
|
||||
}
|
||||
|
||||
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",
|
||||
detail: "checked /mobile, reimbursements, leave, approvals, notifications, purchase requests with browser storage state",
|
||||
screenshot: lastScreenshot,
|
||||
};
|
||||
} finally {
|
||||
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunFlow(selectedFlow, "wecom-ua")) {
|
||||
await runStep("企业微信UA", "anonymous", "企业微信 UA 模拟入口和 OAuth 边界可达", async () => {
|
||||
const context = await browser.newContext({
|
||||
baseURL: process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com",
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: wecomUa,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
const wecomPage = await context.newPage();
|
||||
let lastScreenshot = "";
|
||||
try {
|
||||
const configResponse = await wecomPage.request.get(liveUrl("/api/auth/wecom/config"));
|
||||
expect(configResponse.ok()).toBeTruthy();
|
||||
const configPayload = await configResponse.json() as { data?: { entryEnabled?: boolean; oauthEnabled?: boolean; loginUrl?: string } };
|
||||
expect(configPayload.data?.entryEnabled).toBeTruthy();
|
||||
expect(configPayload.data?.oauthEnabled).toBeTruthy();
|
||||
expect(configPayload.data?.loginUrl ?? "").toContain("/api/auth/wecom/login");
|
||||
|
||||
await wecomPage.goto(liveUrl("/login?redirect=%2Fmobile"));
|
||||
await expect(wecomPage.getByTestId("login-wecom-primary-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(wecomPage.getByTestId("wecom-entry-primary")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(wecomPage.locator("aside")).toHaveCount(0);
|
||||
await checkNoHorizontalOverflow(wecomPage);
|
||||
|
||||
const screenshotPath = path.join(screenshotDir, "wecom-ua-login-entry.png");
|
||||
await wecomPage.screenshot({ path: screenshotPath, fullPage: true });
|
||||
lastScreenshot = path.relative(rootDir, screenshotPath);
|
||||
|
||||
return {
|
||||
pcResult: "-",
|
||||
mobileResult: "PASS: 企业微信 UA 模拟登录入口、配置和 OAuth 边界可达",
|
||||
detail: "WeCom UA checked login entry and /api/auth/wecom/config;不代表真机免登、消息弹窗或 WebView 文件选择器验收。",
|
||||
screenshot: lastScreenshot,
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
writeReports(steps);
|
||||
}
|
||||
|
||||
+19
-13
@@ -203,19 +203,25 @@ R7E 结论:公网、live smoke/OCR、移动矩阵、核心业务流、账号
|
||||
|
||||
## 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/`。
|
||||
执行时间:2026-05-14。范围是无需人工操作即可完成的公网 PC 浏览器、移动浏览器和企业微信 UA 模拟补测;真实企业微信免登录、消息弹窗、消息 deeplink、WebView 文件选择器和代表设备反馈仍后置。报告路径为 `artifacts/r7e/browser-business-trialrun/`,截图路径为 `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 | 测试维护项,不作为业务失败 |
|
||||
| 公共前置 | 员工、审批人、财务、财务主管、管理员 | N/A | 5 类账号登录 `/portal` 通过;`/ready` 通过;public bundle `findings=0`;live smoke env ready;授权 PDF 文件存在且为 PDF | N/A | - | 通过 |
|
||||
| 报销 | 员工、审批人 | `BX-20260514-0016` | 员工创建报销、添加明细、上传授权 PDF、提交;审批人处理通过;申请人回看状态和审批历史 | N/A | - | 报销业务链路通过;财务付款另记 `R7E-14` |
|
||||
| 审批 | 审批人 | `BX-20260514-0016`、`CG-20260514-0005`、请假单 `a22e45f8-b3d6-4862-97f2-30cbe4815e09` | 报销、采购、请假均完成待办处理并可回看历史 | N/A | R7E-ISSUE-BROWSER-004 | 主链路通过;live 自审/越权边界未补齐,不关闭 `R7E-11` |
|
||||
| 请假 | 员工、审批人 | `a22e45f8-b3d6-4862-97f2-30cbe4815e09` | 员工提交未来工作日请假;审批人通过;员工 PC 回看已通过 | `/mobile/leave` 记录页可见且无横向滚动 | - | 通过 |
|
||||
| 采购 | 员工、审批人 | `CG-20260514-0005` | PC 创建、明细、无敏感测试附件上传、提交、审批、状态和审批历史回看通过 | 移动采购列表、详情和附件可见且无横向滚动 | - | 通过 |
|
||||
| 财务 | 财务 | `BX-20260514-0016` | 财务账号可进入 `/ap-operations`;已审批报销按单号筛选未进入 AP 台账,未创建付款批次、未执行付款、未验证导出 | N/A | R7E-ISSUE-BROWSER-005 | 暂停,分流 `R7E-14`/`R7E-16` |
|
||||
| 移动通用 | 员工、审批人、财务 | N/A | N/A | `/mobile`、移动报销、请假、审批、通知、采购入口通过;无 PC 壳和横向滚动 | - | 通过 |
|
||||
| 企业微信 UA 模拟 | 匿名 | N/A | N/A | 登录入口、企业微信配置和 OAuth 边界可达 | - | 通过;不代表真机验收 |
|
||||
| 补充移动矩阵 | 本地 mock 前置 | N/A | N/A | `dg7-mobile-information-dedupe` 未启动本地前端服务时访问 `127.0.0.1:3000` 失败 | QAOPS8-TEST-002 | 前置条件问题,不作为公网业务失败 |
|
||||
|
||||
结论:浏览器业务线已经证明“采购 PC/移动主链路”和“公网角色登录前置”可用;报销、请假、财务仍不能关闭。企业微信免登录、消息送达、消息 deeplink、企业微信 WebView 文件选择器和代表设备真机反馈仍留在 `R7E-9`/`R7E-15`,不由本轮浏览器试运行替代。
|
||||
本轮新增测试辅助:`r7e-browser-business-trialrun.spec.ts` 支持 `R7E_BROWSER_FLOW=preflight|reimbursement-finance|leave|purchase-attachment|mobile|wecom-ua|all` 分段执行;移动 flow 使用浏览器 storage state 注入登录态并写入匹配的 device fingerprint,避免反复 UI 登录触发限流。
|
||||
|
||||
阶段结论:无人工自动化已完成公网健康、报销提交审批、请假、采购附件、移动浏览器和企业微信 UA 模拟。`R7E-10`、`R7E-12`、`R7E-13` 可按浏览器业务线证据关闭;`R7E-11` 因 live 自审/越权边界未补齐保持 `DOING`;`R7E-14` 因 AP 台账未出现已审批报销保持 `DOING`;`R7E-9`、`R7E-15`、`R7E-18` 仍需真实企业微信和人工验收,不由本轮自动化替代。
|
||||
|
||||
## QAOPS8 Go/No-Go 当前结论
|
||||
|
||||
@@ -228,7 +234,7 @@ R7E 结论:公网、live smoke/OCR、移动矩阵、核心业务流、账号
|
||||
| QAOPS8 发布、备份、回滚和 SLO 基线 | 已形成 | 可作为 R7E 启动前准备证据 |
|
||||
| R7E-1..7 人工启动确认 | 已确认 | 可以进入启动当天公网健康复核 |
|
||||
| R7E-8 公网健康复核 | 已完成 | 最新代码 `4b14c92` 已部署;`/ready`、public bundle、远端 migration 状态和公网移动矩阵通过 |
|
||||
| R7E-9..15 真实业务和真机流程 | 未完成 | 需用户按角色实际点击或提供反馈 |
|
||||
| R7E-9..15 真实业务和真机流程 | 部分完成 | 浏览器业务线已关闭报销、请假、采购;企业微信专属线、live 自审/越权边界和财务付款闭环仍需后补 |
|
||||
| R7E-16..18 每日问题和验收结论 | 未完成 | 需试运行窗口内记录 |
|
||||
| WF8-11 通知 deeplink 回 OA | 未完成 | 需企业微信或代表性真机验证 |
|
||||
| BIZ8-12 移动业务入口评估 | 暂停收口 | 自动化截图已有,仍需代表性真机和角色反馈 |
|
||||
@@ -236,12 +242,12 @@ R7E 结论:公网、live smoke/OCR、移动矩阵、核心业务流、账号
|
||||
保持 `No-Go/Pause` 的阻断项:
|
||||
|
||||
- `SMOKE002` 企业微信工作台进入 `/mobile` 后确认无 PC 外壳,底部导航和业务入口正常。
|
||||
- `SMOKE002` 完成移动报销详情和 PDF 上传确认,不能被图片拍照入口误导。
|
||||
- `SMOKE002` 补充企业微信 WebView 内的移动报销详情和 PDF 上传确认,不能被图片拍照入口误导。
|
||||
- `SMOKE101` 完成移动审批和通知 deeplink 回 OA 的确认。
|
||||
- 财务角色完成付款、导出或财务台账代表性确认。
|
||||
- 财务角色完成 AP 台账、付款、导出或可解释财务路径确认;当前自动化发现 `BX-20260514-0016` 未进入 AP 台账筛选结果。
|
||||
- `SMOKE301` / `SMOKE302` 尚未启用且没有企业微信,不能作为企业微信真机证据。
|
||||
|
||||
结论:当前可以按最新代码继续 R7E 真实试运行,但不能给正式上线 `Go`,也不能关闭 `R7E-9..18`、`WF8-11`、`BIZ8-12` 或 QAOPS8 后续收口项。
|
||||
结论:当前可以按最新代码继续 R7E 浏览器业务和后续人工真机试运行,但不能给正式上线 `Go`,也不能关闭 `R7E-9`、`R7E-11`、`R7E-14`、`R7E-15`、`R7E-18`、`WF8-11`、`BIZ8-12` 或 QAOPS8 后续收口项。
|
||||
|
||||
## 历史恢复
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ R7E-5,问题登记负责人确认,"确认 R7E 问题登记负责人、问题分
|
||||
R7E-6,每日收口节奏确认,"确认 R7E 每日收口节奏,包括当天问题、阻断判定、是否继续、是否回滚和次日计划。","1) 每日收口模板确认;2) 阻断问题处理规则明确;3) 是否继续/暂停/回滚有决策人;4) 收口证据可追溯。","流程级验证:人工确认每日收口模板;首日试运行后按模板记录。",manual,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md,"[""R7E-5""]",每日 17:30 收口;记录当天流程结果 问题级别 继续或暂停或回滚判断 次日动作和证据路径;不在仓库记录密码 token 私密联系方式或未授权票据原件。
|
||||
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|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 上传后未出现在报销发票区需真实报销复核。浏览器业务线新增问题:授权 PDF 样票已用于报销导致 used_by_company 阻止重复绑定;多角色反复登录触发账号密码请求过于频繁限流;长用例超时后移动通用补测出现浏览器上下文关闭,应拆分短用例或降低登录次数。问题记录不得包含密码、token 或隐私明文。
|
||||
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 无人工自动化补测:WeCom UA 模拟通过登录入口 企业微信配置和 OAuth 边界可达;证据 artifacts/r7e/browser-business-trialrun/report-wecom-ua.md;真实企业微信免登录 消息送达 消息 deeplink WebView 文件选择器和代表设备反馈仍需人工确认,故本行不提前 DONE。
|
||||
R7E-10,报销流程试运行,"员工按真实路径提交报销或复核已有样例,覆盖发票/OCR/附件/审批/财务查看。","1) 员工可提交或查看报销;2) OCR/PDF-only 口径清晰;3) 审批链可追溯;4) 财务可复核;5) 问题进入登记清单。","流程级验证:员工进入报销 -> 上传/选择材料 -> 提交 -> 审批 -> 财务复核;记录结果和问题。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:员工创建并提交报销 40876ef3-0652-4972-aa14-1aad1a855ae0 / BX-20260514-0016;授权 PDF 样票上传成功并绑定票据 188b076e-07cc-4058-aac9-02b5161a188a;审批人处理通过后申请人回看状态 approved 和审批历史;复用该单进入财务验证时 AP 台账筛选为空,财务付款闭环另记 R7E-14/R7E-16;证据 artifacts/r7e/browser-business-trialrun/report-reimbursement-finance.md 和 output/playwright/r7e-browser-business-trialrun。
|
||||
R7E-11,审批流程试运行,"审批人按真实路径处理待办,员工查看进度,验证通知和审批历史一致。","1) 审批人可处理待办;2) 申请人可看进度;3) 审批历史一致;4) 自审和越权被拒绝。","流程级验证:提交申请 -> 审批人处理 -> 申请人查看状态 -> 审计/历史可追溯。",manual|playwright,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:报销 BX-20260514-0016 请假 a22e45f8-b3d6-4862-97f2-30cbe4815e09 和采购 CG-20260514-0005 均完成真实浏览器或 API 待办审批并可申请人回看状态和历史;live 自审或越权边界未在本轮补齐,故保持 DOING 不关闭。
|
||||
R7E-12,请假流程试运行,"员工提交请假,审批人审批,员工和团队视角查看状态。","1) 请假提交成功;2) 审批处理成功;3) 移动/PC 状态一致;4) 团队状态或日历口径可解释。","流程级验证:员工提交请假 -> 审批通过/驳回 -> 查看进度和团队状态。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:员工创建未来工作日请假并提交;审批人处理通过;申请人 PC 回看已通过且 /mobile/leave 记录页可见无横向滚动;测试单据 a22e45f8-b3d6-4862-97f2-30cbe4815e09;证据 artifacts/r7e/browser-business-trialrun/report-leave.md。
|
||||
R7E-13,采购流程试运行,"员工提交或查看采购申请,审批人处理,状态和附件可追溯。","1) 采购申请路径可用;2) 审批处理可用;3) 附件和状态可追溯;4) 移动采购创建如缺失则记录为后续 UX/P2。","流程级验证:员工提交/查看采购 -> 审批处理 -> 查看状态和附件。",manual|playwright,DONE,DONE,DONE,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:采购 CG-20260514-0005 完成 PC 创建 明细 无敏感测试附件上传 提交 审批 状态和审批历史回看;移动采购列表 详情和附件可见且无横向滚动;证据 artifacts/r7e/browser-business-trialrun/report-purchase-attachment.md。
|
||||
R7E-14,财务付款和导出试运行,"财务按真实路径复核报销、标记付款、查看台账或导出。","1) 财务角色入口正确;2) 付款标记或台账查看可用;3) 导出权限边界清楚;4) 管理员不自动获得财务能力。","流程级验证:财务进入台账 -> 复核/付款/导出 -> 检查权限和审计。",manual|playwright,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md|client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts,"[""R7E-8""]",2026-05-14 无人工自动化补测:财务账号可进入 /ap-operations;但已审批报销 BX-20260514-0016 在 finance-ledger-table 按单号筛选为空,未创建付款批次 未执行付款 未验证导出;本行保持 DOING 并分流到 R7E-16。
|
||||
R7E-15,移动端和真机反馈,"收集 R7E 参与者在移动浏览器和企业微信中的真实体验反馈。","1) 至少覆盖代表性 iPhone/Android 或企业微信设备;2) 记录页面、视口、角色和问题级别;3) 阻断问题进入修复分流;4) 体验优化进入 UX8。","流程级验证:按真机公网测试清单人工复核;必要时补 Playwright 截图。",manual|playwright,TODO,TODO,TODO,docs/trialrun/人工试运行报告.md|output/playwright,"[""R7E-9""]",2026-05-14 无人工自动化补测:移动浏览器员工 审批人 财务核心入口通过且无横向滚动;证据 artifacts/r7e/browser-business-trialrun/report-mobile.md;企业微信 UA 模拟通过但不能替代代表性 iPhone/Android 企业微信真机反馈,故本行不提前 DONE。
|
||||
R7E-16,每日问题清单,"R7E 期间每天登记问题、级别、归属治理流、处理状态和证据路径。","1) 问题均有级别;2) 阻断问题当天决策;3) 新需求不混入即时修复;4) 每日 closeout 可追溯。","流程级验证:每日人工 closeout;问题分流到 R7E/UX8/ORG8/WF8/BIZ8/QAOPS8。",manual,DOING,TODO,TODO,docs/trialrun/人工试运行报告.md,"[""R7E-10"",""R7E-11"",""R7E-12"",""R7E-13"",""R7E-14"",""R7E-15""]",2026-05-14 无人工自动化待分流项:已审批报销 BX-20260514-0016 未进入 AP 台账筛选结果,财务付款闭环暂停;R7E-11 的 live 自审和越权边界仍需补齐;真实企业微信免登录 消息 deeplink WebView 文件选择器和代表设备反馈仍需人工;既有 dg7-mobile-information-dedupe 未启动本地前端服务时访问 127.0.0.1:3000 失败,属于补充矩阵前置条件不是公网业务失败;移动多角色 flow 已通过 browser storage state 修复登录态注入。问题记录不得包含密码 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 功能开发必须另建或延续对应连续合同。
|
||||
|
||||
|
@@ -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/移动自动化补测已记录;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-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 完成;本轮无人工自动化补测结果为公网 /ready 通过 public bundle findings=0 live smoke env ready 授权 PDF 可读,R7E preflight 通过 报销 BX-20260514-0016 创建上传提交审批通过 请假通过 采购 CG-20260514-0005 含附件 PC/移动通过 移动浏览器通过 WeCom UA 模拟通过;财务 AP 台账未出现已审批报销导致付款闭环暂停;dg7-mobile-information-dedupe 未启动本地前端服务时失败属于补充矩阵前置条件;企业微信专属线仍为免登录 消息 deeplink WebView 文件选择器和真机渲染后补门禁;明确 R7E 人工验收不被自动化替代;因 R7E-9 R7E-11 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""]",所有执行行完成后才能收口。
|
||||
|
||||
|
在新工单中引用
屏蔽一个用户