R74-A2 A3 A4 压榨试运行自动化空间

这个提交包含在:
2026-04-28 22:06:15 +08:00
父节点 2370a7745e
当前提交 5eeeebcdf7
共修改 13 个文件,包含 611 行新增和 40 行删除
@@ -0,0 +1,67 @@
import { expect, type Page } from "@playwright/test";
export type LiveInvoiceOcrStatus = "available" | "ocr_failed";
export type LiveInvoiceOcrResult = {
status: LiveInvoiceOcrStatus;
detailText: string;
bannerText: string;
durationMs: number;
invoiceId: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
sellerName: string | null;
totalAmount: string | null;
invoiceDate: string | null;
};
function extractField(detailText: string, label: string) {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = detailText.match(new RegExp(`${escaped}([^\\n]+)`));
return match?.[1]?.trim() || null;
}
export async function openInvoicePool(page: Page) {
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
}
export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: string, timeout = 180_000): Promise<LiveInvoiceOcrResult> {
const startedAt = Date.now();
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
await page.getByTestId("invoice-upload-submit").click();
const detailPanel = page.getByTestId("invoice-detail-panel");
await expect(detailPanel).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => {
const text = await detailPanel.innerText().catch(() => "");
if (text.includes("状态:available")) return "available";
if (text.includes("状态:ocr_failed")) return "ocr_failed";
return "pending_ocr";
}, { timeout })
.not.toBe("pending_ocr");
const detailText = await detailPanel.innerText();
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "");
const status = detailText.includes("状态:ocr_failed") ? "ocr_failed" : "available";
const invoiceNo = extractField(detailText, "发票号:");
const [invoiceCode, invoiceNumber] = invoiceNo && invoiceNo.includes("-")
? invoiceNo.split("-", 2).map((item) => item.trim() || null)
: [invoiceNo, null];
return {
status,
detailText,
bannerText,
durationMs: Date.now() - startedAt,
invoiceId: extractField(detailText, "票据ID:"),
invoiceCode,
invoiceNumber,
sellerName: extractField(detailText, "销售方:"),
totalAmount: extractField(detailText, "总金额:"),
invoiceDate: extractField(detailText, "开票日期:"),
};
}
@@ -0,0 +1,103 @@
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { expect, test } from "../helpers/fixtures";
import { EMPLOYEE, login, logout } from "../helpers/auth";
import { openInvoicePool, uploadLiveInvoiceAndWait, type LiveInvoiceOcrResult } from "../helpers/liveInvoiceOcr";
type BatchResult = LiveInvoiceOcrResult & {
filePath: string;
fileName: string;
};
const rootDir = path.resolve(process.cwd(), "..");
const reportPath = path.join(rootDir, "artifacts/r70/live-ocr-batch-report.md");
function resolveInvoiceFiles() {
const files = new Set<string>();
const directFile = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim();
const batchFiles = process.env.PLAYWRIGHT_OCR_LIVE_FILES?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean) ?? [];
const batchDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim() ?? "";
if (directFile) {
files.add(path.resolve(directFile));
}
for (const file of batchFiles) {
files.add(path.resolve(file));
}
if (batchDir && existsSync(batchDir) && statSync(batchDir).isDirectory()) {
for (const entry of readdirSync(batchDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
const fullPath = path.join(batchDir, entry);
if (!statSync(fullPath).isFile()) continue;
if (!/\.(pdf|png|jpg|jpeg|webp)$/i.test(entry)) continue;
files.add(path.resolve(fullPath));
}
}
return [...files].filter((filePath) => existsSync(filePath));
}
function writeReport(results: BatchResult[]) {
mkdirSync(path.dirname(reportPath), { recursive: true });
const lines = [
"# R70 批量 live OCR 辅助复核报告",
"",
`生成时间:${new Date().toISOString()}`,
"",
"## 结果",
"",
`- 总文件数:${results.length}`,
`- 成功:${results.filter((item) => item.status === "available").length}`,
`- 失败:${results.filter((item) => item.status === "ocr_failed").length}`,
"",
"## 明细",
"",
"| 文件 | 状态 | 耗时(ms) | 票据ID | 发票代码 | 发票号码 | 销售方 | 开票日期 | 备注 |",
"| --- | --- | ---: | --- | --- | --- | --- | --- | --- |",
...results.map((item) => {
const remark = item.status === "ocr_failed" ? (item.bannerText || item.detailText).replaceAll("|", "\\|") : "ok";
return `| ${item.fileName} | ${item.status} | ${item.durationMs} | ${item.invoiceId ?? "-"} | ${item.invoiceCode ?? "-"} | ${item.invoiceNumber ?? "-"} | ${item.sellerName ?? "-"} | ${item.invoiceDate ?? "-"} | ${remark} |`;
}),
"",
"## 说明",
"",
"- 本报告用于真实腾讯 OCR 的本地辅助复核,不进入常规本地回归。",
"- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。",
];
writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8");
}
test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输出报告", async ({ page }) => {
test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行");
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
const invoiceFiles = resolveInvoiceFiles();
test.skip(!invoiceFiles.length, "需要提供 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR");
const results: BatchResult[] = [];
await login(page, EMPLOYEE);
try {
await openInvoicePool(page);
for (const invoiceFilePath of invoiceFiles) {
await openInvoicePool(page);
const result = await uploadLiveInvoiceAndWait(page, invoiceFilePath);
results.push({
...result,
filePath: invoiceFilePath,
fileName: path.basename(invoiceFilePath),
});
}
} finally {
writeReport(results);
await logout(page, EMPLOYEE.name).catch(() => undefined);
}
const failures = results.filter((item) => item.status === "ocr_failed");
expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0);
});
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import { test, expect } from "../helpers/fixtures";
import { EMPLOYEE, login, logout } from "../helpers/auth";
import { openNavLink } from "../helpers/navigation";
import { openInvoicePool, uploadLiveInvoiceAndWait } from "../helpers/liveInvoiceOcr";
test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可处理状态", async ({ page }) => {
test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行");
@@ -12,23 +12,11 @@ test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可
test.skip(!invoiceFilePath || !existsSync(invoiceFilePath), "需要提供存在的 PLAYWRIGHT_OCR_LIVE_FILE");
await login(page, EMPLOYEE);
await (await openNavLink(page, "票据中心")).click();
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
await openInvoicePool(page);
await page.getByTestId("invoice-upload-file").setInputFiles(invoiceFilePath);
await page.getByTestId("invoice-upload-submit").click();
await expect.poll(async () => {
const text = await page.getByTestId("invoice-detail-panel").innerText().catch(() => "");
if (text.includes("状态:available")) return "available";
if (text.includes("状态:ocr_failed")) return "ocr_failed";
return "pending_ocr";
}, { timeout: 180_000 }).not.toBe("pending_ocr");
const detailText = await page.getByTestId("invoice-detail-panel").innerText();
if (detailText.includes("状态:ocr_failed")) {
const bannerText = await page.getByTestId("invoice-ocr-status-banner").innerText().catch(() => "OCR 失败");
throw new Error(`live 腾讯 OCR 失败:${bannerText}`);
const result = await uploadLiveInvoiceAndWait(page, invoiceFilePath);
if (result.status === "ocr_failed") {
throw new Error(`live 腾讯 OCR 失败:${result.bannerText || result.detailText}`);
}
await expect(page.getByTestId("invoice-detail-panel")).not.toContainText("发票号:---");
@@ -0,0 +1,231 @@
import { expect, test } from "@playwright/test";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { ADMIN, EMPLOYEE, apiPostData, createAuthenticatedApiClient, login, logout } from "../helpers/auth";
import { openNavLink } from "../helpers/navigation";
import { TEST_IDS } from "../helpers/testIds";
type StepStatus = "PASS" | "FAIL";
type TrialStep = {
title: string;
status: StepStatus;
detail: string;
screenshot: string;
};
const rootDir = path.resolve(process.cwd(), "..");
const reportPath = path.join(rootDir, "artifacts/r70/auto-trialrun-deep-report.md");
const issueCandidatesPath = path.join(rootDir, "artifacts/r70/auto-trialrun-deep-issues.csv");
const screenshotDir = path.join(rootDir, "deploy/trialrun/screenshots/r70-auto-trialrun-deep");
function formatWeekStart(offsetDays: number) {
const value = new Date();
value.setUTCDate(value.getUTCDate() + offsetDays);
return value.toISOString().slice(0, 10);
}
function csvCell(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}
function safeName(value: string) {
return value.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-").replace(/^-|-$/g, "").slice(0, 80);
}
function writeReports(steps: TrialStep[]) {
mkdirSync(path.dirname(reportPath), { recursive: true });
const failures = steps.filter((item) => item.status === "FAIL");
const now = new Date().toISOString();
const lines = [
"# R70 深层自动辅助试运行报告",
"",
`生成时间:${now}`,
"",
"## 结论",
"",
failures.length
? `深层自动辅助试运行发现 ${failures.length} 个失败步骤,需继续人工确认。`
: "深层自动辅助试运行未发现阻断步骤;仍需人工判断整体体验是否顺手。",
"",
"## 步骤结果",
"",
"| Step | Status | Screenshot | Detail |",
"| --- | --- | --- | --- |",
...steps.map((item) => `| ${item.title} | ${item.status} | ${item.screenshot || "N/A"} | ${item.detail.replaceAll("|", "\\|")} |`),
];
writeFileSync(reportPath, `${lines.join("\n")}\n`, "utf-8");
const issueHeader = [
"ID",
"Role",
"Severity",
"Module",
"Title",
"Steps",
"Expected",
"Actual",
"Screenshot",
"Owner",
"Disposition",
"Status",
"Notes",
];
const issueRows = failures.map((item, index) =>
[
`R70-DEEP-${String(index + 1).padStart(3, "0")}`,
"employee",
"P1",
item.title,
`深层自动辅助试运行失败:${item.title}`,
item.title,
"真实环境业务链路可执行且关键断言通过",
item.detail,
item.screenshot || "N/A",
"TBD",
"needs review",
"OPEN",
"由 R70 深层自动辅助试运行生成,需人工复核严重级别。",
].map(csvCell).join(","),
);
writeFileSync(issueCandidatesPath, `${issueHeader.map(csvCell).join(",")}\n${issueRows.join("\n")}${issueRows.length ? "\n" : ""}`, "utf-8");
}
test("R70 深层自动辅助试运行:周报待办、公告团队状态、真实搜索", async ({ page }) => {
test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行");
test.skip(process.env.R70_TRIALRUN_DEEP_ASSIST !== "1", "仅在显式 deep assist 模式下执行");
test.setTimeout(240_000);
mkdirSync(screenshotDir, { recursive: true });
const steps: TrialStep[] = [];
let stepIndex = 0;
const suffix = Date.now();
const uniqueToken = `Playwright 深层联动 ${suffix}`;
const weekStart = formatWeekStart(280 + (suffix % 70));
const announcementTitle = `Playwright 深层公告 ${suffix}`;
const presenceNote = `深层辅助复核 ${suffix}`;
const adminClient = await createAuthenticatedApiClient(ADMIN);
async function runStep(title: string, action: () => Promise<string | void>) {
stepIndex += 1;
const screenshot = path.join(screenshotDir, `${String(stepIndex).padStart(2, "0")}-${safeName(title)}.png`);
const relativeScreenshot = path.relative(rootDir, screenshot);
try {
const detail = (await action()) ?? "ok";
await page.screenshot({ path: screenshot, fullPage: true });
steps.push({ title, status: "PASS", detail, screenshot: relativeScreenshot });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined);
steps.push({ title, status: "FAIL", detail, screenshot: relativeScreenshot });
}
}
try {
await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/announcements", {
title: announcementTitle,
summary: "用于深层自动辅助试运行验证真实公告、Portal 与搜索链路。",
enabled: true,
priority: 1,
});
await login(page, EMPLOYEE);
await runStep("周报保存-生成待办-提交-完成闭环", async () => {
await (await openNavLink(page, "周报")).click();
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
const weeklyLoadResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname === "/api/weekly-reports/me" && url.searchParams.get("weekStart") === weekStart && response.ok();
}, { timeout: 30_000 });
await page.getByLabel("周报归档周").fill(weekStart);
await weeklyLoadResponse;
await page.getByLabel("本周完成").fill(`${uniqueToken} 已完成`);
await page.getByLabel("当前阻塞").fill(`${uniqueToken} 阻塞`);
await page.getByLabel("下周计划").fill(`${uniqueToken} 下周计划`);
const saveResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "PUT" && url.pathname === "/api/weekly-reports/me" && response.ok();
}, { timeout: 30_000 });
await page.getByTestId(TEST_IDS.weeklyReport.save).click();
await saveResponse;
await expect(page.getByText("周报已保存")).toBeVisible();
const createTodoResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/todos" && response.ok();
}, { timeout: 30_000 });
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
await createTodoResponse;
await expect(page.getByText("已从周报生成待办")).toBeVisible();
const submitResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/weekly-reports/me/submit" && response.ok();
}, { timeout: 30_000 });
await page.getByTestId(TEST_IDS.weeklyReport.submit).click();
await submitResponse;
await expect(page.getByText("周报已提交")).toBeVisible();
await (await openNavLink(page, "待办")).click();
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
await expect(todoRow).toBeVisible({ timeout: 30_000 });
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
await expect(todoRow).toContainText("done", { timeout: 30_000 });
return `weekStart=${weekStart}; token=${uniqueToken}`;
});
await runStep("公告-团队状态-Portal 汇总联动", async () => {
await page.goto("/announcements");
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 });
await page.goto("/team");
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
await page.locator("select.select-std").first().selectOption("remote");
await page.getByPlaceholder("可选备注,如下午外出办事").fill(presenceNote);
await page.getByRole("button", { name: "保存状态" }).click();
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
await page.goto("/portal");
await page.getByRole("tab", { name: "公告" }).click();
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
return announcementTitle;
});
await runStep("真实全局搜索命中公告并跳转", async () => {
await page.goto("/portal");
await page.getByTestId("global-search-trigger").click();
await expect(page.getByRole("heading", { name: "全局搜索" })).toBeVisible();
const searchResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname === "/api/search" && response.ok();
}, { timeout: 30_000 });
await page.getByPlaceholder("搜索人员、项目、合同、公告、资产、请假、报销、周报、待办").fill(String(suffix));
await searchResponse;
const resultButton = page.getByRole("button", { name: new RegExp(announcementTitle) }).first();
await expect(resultButton).toBeVisible({ timeout: 30_000 });
await resultButton.click();
await expect(page).toHaveURL(/\/announcements$/);
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 });
return announcementTitle;
});
} finally {
writeReports(steps);
await adminClient.dispose();
await logout(page, EMPLOYEE.name).catch(() => undefined);
}
const failures = steps.filter((item) => item.status === "FAIL");
expect(failures, JSON.stringify(failures, null, 2)).toHaveLength(0);
});