R73-A5 固化 OCR Playwright 回归与试运行清单
这个提交包含在:
@@ -99,3 +99,24 @@
|
|||||||
- employee:无发票时,报销详情页在提交按钮附近即时提示“提交前需先绑定至少 1 张发票,可在‘关联票据’区域添加。”,通过。
|
- employee:无发票时,报销详情页在提交按钮附近即时提示“提交前需先绑定至少 1 张发票,可在‘关联票据’区域添加。”,通过。
|
||||||
- employee:上传 `/home/shunli/Projects/hyb-oa/artifacts/北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf` -> 绑定 -> 再次提交后,报销单 `4931211b-34b4-43b8-9e9f-917569d47c33` 进入 `reviewing`,编号 `BX-20260428-0001`,通过。
|
- employee:上传 `/home/shunli/Projects/hyb-oa/artifacts/北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf` -> 绑定 -> 再次提交后,报销单 `4931211b-34b4-43b8-9e9f-917569d47c33` 进入 `reviewing`,编号 `BX-20260428-0001`,通过。
|
||||||
- 结论:R70 初始两条问题已闭环,可恢复下一轮本地人工试运行。
|
- 结论:R70 初始两条问题已闭环,可恢复下一轮本地人工试运行。
|
||||||
|
|
||||||
|
## R73 自动化覆盖回填
|
||||||
|
|
||||||
|
- 自动化环境:`http://127.0.0.1:3001`
|
||||||
|
- 自动化数据库:`hyb_oa_manual_trialrun_20260428172231`
|
||||||
|
- 执行时间:`2026-04-28 20:49 - 21:08 CST`
|
||||||
|
- 已自动化覆盖:
|
||||||
|
- `client/tests/e2e/reimbursement-workflow-progress.spec.ts`
|
||||||
|
- 报销提交后详情页展示当前审批进度。
|
||||||
|
- `client/tests/e2e/invoice-ocr-flow.spec.ts`
|
||||||
|
- 报销详情:上传发票 -> OCR 完成 -> 自动进入可绑定候选 -> 绑定 -> 提交报销。
|
||||||
|
- 票据中心:OCR 缺关键字段 -> 当前页提示原因 -> 补齐字段 -> 标记 available。
|
||||||
|
- 显式开关的辅助自动化:
|
||||||
|
- `client/tests/e2e/manual/live-invoice-ocr.spec.ts`
|
||||||
|
- 用途:接入真实腾讯 OCR + 真实 VAT 发票时做本地辅助复核,不进入常规本地回归。
|
||||||
|
- 启动示例:`PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_LIVE_FILE=/home/shunli/Projects/hyb-oa/artifacts/北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf npm run test:e2e -- tests/e2e/manual/live-invoice-ocr.spec.ts`
|
||||||
|
- 仍需人工体验判断:
|
||||||
|
- Portal 信息架构是否易懂。
|
||||||
|
- 审批上下文是否足够。
|
||||||
|
- 移动入口与管理员入口的认知负担。
|
||||||
|
- 真实腾讯 OCR 在不同票样下的识别质量与耗时体感。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
const e2eMode = process.env.E2E_MODE === "live" ? "live" : "local";
|
const e2eMode = process.env.E2E_MODE === "live" ? "live" : "local";
|
||||||
|
const manualE2e = process.env.PLAYWRIGHT_MANUAL === "1";
|
||||||
const r70TrialrunAssist = process.env.R70_TRIALRUN_ASSIST === "1";
|
const r70TrialrunAssist = process.env.R70_TRIALRUN_ASSIST === "1";
|
||||||
const baseURL = e2eMode === "live"
|
const baseURL = e2eMode === "live"
|
||||||
? process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com"
|
? process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com"
|
||||||
@@ -8,8 +9,8 @@ const baseURL = e2eMode === "live"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: r70TrialrunAssist ? [] : ["**/manual/**"],
|
testIgnore: r70TrialrunAssist || manualE2e ? [] : ["**/manual/**"],
|
||||||
timeout: 120_000,
|
timeout: 180_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from "./helpers/fixtures";
|
||||||
|
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||||
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
import { openNavLink } from "./helpers/navigation";
|
||||||
|
|
||||||
|
async function createBlankReimbursement(page: import("@playwright/test").Page) {
|
||||||
|
const createResponsePromise = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
await (await openNavLink(page, "我的报销")).click();
|
||||||
|
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reimbursementId;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("E2E 轻交互 OCR:报销详情上传后自动可绑定并提交", async ({ page, reimbursementDetail }) => {
|
||||||
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
|
const reimbursementId = await createBlankReimbursement(page);
|
||||||
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
await reimbursementDetail.assertLoaded();
|
||||||
|
|
||||||
|
await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "添加发票" })).toBeVisible();
|
||||||
|
|
||||||
|
const marker = `MOCK_R73_SUCCESS_${Date.now()}`;
|
||||||
|
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadFile).setInputFiles({
|
||||||
|
name: `r73-success-${Date.now()}.png`,
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(marker, "utf8"),
|
||||||
|
});
|
||||||
|
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).scrollIntoViewIfNeeded();
|
||||||
|
await page.getByTestId(TEST_IDS.reimbursement.invoiceUploadSubmit).click();
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const text = await page.getByText(/已选 \d+ 张发票/).first().textContent().catch(() => "");
|
||||||
|
return text ?? "";
|
||||||
|
}, { timeout: 90_000 }).toContain("已选 1 张发票");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "绑定选中发票" }).click();
|
||||||
|
await expect(page.getByTestId(TEST_IDS.reimbursement.invoices)).not.toContainText("暂无关联票据", { timeout: 30_000 });
|
||||||
|
|
||||||
|
await reimbursementDetail.submitReimbursement();
|
||||||
|
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await logout(page, EMPLOYEE.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并标记可用", async ({ page }) => {
|
||||||
|
test.skip(process.env.PLAYWRIGHT_OCR_MOCK !== "1", "需要 mock OCR 环境和 OCR 作业开启");
|
||||||
|
|
||||||
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
|
await (await openNavLink(page, "票据中心")).click();
|
||||||
|
await expect(page.getByTestId("invoice-pool-table")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("invoice-upload-file").setInputFiles({
|
||||||
|
name: `r73-missing-${Date.now()}.png`,
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from("MOCK_OCR_MISSING:invoiceCode,sellerName", "utf8"),
|
||||||
|
});
|
||||||
|
await page.getByTestId("invoice-upload-submit").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:ocr_failed", { timeout: 90_000 });
|
||||||
|
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。");
|
||||||
|
|
||||||
|
await page.getByTestId("invoice-edit-open").click();
|
||||||
|
await page.getByTestId("invoice-edit-set-available").check();
|
||||||
|
await page.getByTestId("invoice-edit-save").click();
|
||||||
|
await expect(page.locator("main").getByText(/票据仍缺少关键字段/)).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.getByTestId("invoice-edit-code").fill("044003204111");
|
||||||
|
await page.getByTestId("invoice-edit-seller-name").fill("北京悦海堂餐饮管理有限公司");
|
||||||
|
await page.getByTestId("invoice-edit-save").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:available", { timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("invoice-detail-panel")).toContainText("北京悦海堂餐饮管理有限公司");
|
||||||
|
|
||||||
|
await logout(page, EMPLOYEE.name);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
import { test, expect } from "../helpers/fixtures";
|
||||||
|
import { EMPLOYEE, login, logout } from "../helpers/auth";
|
||||||
|
import { openNavLink } from "../helpers/navigation";
|
||||||
|
|
||||||
|
test("手工 live 腾讯 OCR 辅助复核:真实 VAT 发票上传后进入可处理状态", async ({ page }) => {
|
||||||
|
test.skip(process.env.PLAYWRIGHT_MANUAL !== "1", "仅在显式手工 Playwright 模式下执行");
|
||||||
|
test.skip(process.env.PLAYWRIGHT_OCR_LIVE !== "1", "仅在显式 live OCR 辅助复核模式下执行");
|
||||||
|
|
||||||
|
const invoiceFilePath = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() ?? "";
|
||||||
|
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 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByTestId("invoice-detail-panel")).not.toContainText("发票号:---");
|
||||||
|
await expect(page.getByTestId("invoice-detail-panel")).not.toContainText("总金额:-");
|
||||||
|
|
||||||
|
await logout(page, EMPLOYEE.name);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||||
|
"A1","R73 执行合同建档与范围冻结","创建并校验 R73 合同,仅承接“轻交互 OCR 闭环 + Playwright 自动回归增强”;明确借鉴旧项目的腾讯 OCR 归一化与低置信度处理思路,但不照搬其多弹窗、多确认的重交互流程;不承接新 OCR provider、票池大改版、费用类型智能推荐或 Prisma 迁移。","issues/2026-04-28_19-59-15-r73-lightweight-ocr-and-playwright.csv 列完整且通过校验;A2-A5 依赖关系清晰;Notes 明确本轮坚持上传后异步 OCR、只在异常路径人工介入。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-28_19-59-15-r73-lightweight-ocr-and-playwright.csv。检查:12 列完整、Dependencies 为 JSON 数组字符串、Files 为实际路径或哨兵值。","manual","DONE","DONE","DONE","issues/2026-04-28_19-59-15-r73-lightweight-ocr-and-playwright.csv","[]","回滚路径:删除本 CSV;不影响业务运行。2026-04-28:合同已写入并通过 validate_issues_csv.py 校验。"
|
||||||
|
"A2","腾讯 VAT OCR 归一化与结果质量门槛收敛","基于现有 server 侧 `mock/tencent` OCR provider 机制,补齐腾讯 VAT OCR 结果归一化与质量门槛:保留上传后 `pending_ocr -> available/ocr_failed` 的异步链路;识别出关键字段完整时直接入 `available`;关键字段缺失或格式异常时进入 `ocr_failed` 并写入稳定错误原因,供前端内联处理;不新增 provider,不新增二次确认保存。","腾讯 VAT OCR 成功时能稳定归一化 invoiceCode/invoiceNumber/invoiceDate/totalAmount/sellerName 等核心字段;关键字段不完整时不会误标记 `available`;现有 mock provider 行为保持兼容;不改数据库 schema。","命令:cd server && npm run prisma:validate && node --check src/services/ocr/tencent.js && node --check src/jobs/ocrJob.js && npm test -- tests/unit/services/ocr.tencent.test.js tests/unit/jobs/ocrJob.test.js tests/unit/routes/invoicePool.upload.test.js tests/unit/routes/invoicePool.patch.test.js。业务:上传 VAT 发票 -> OCR 成功时自动转 available;OCR 返回缺关键字段时进入 ocr_failed 并保留人工修正入口。","manual","DONE","DONE","DONE","server/src/services/ocr/tencent.js|server/src/services/ocr/index.js|server/src/services/ocr/mock.js|server/src/jobs/ocrJob.js|server/src/routes/invoicePool.js|server/tests/unit/services/ocr.tencent.test.js|server/tests/unit/jobs/ocrJob.test.js|server/tests/unit/routes/invoicePool.upload.test.js|server/tests/unit/routes/invoicePool.patch.test.js","[""A1""]","只借鉴旧项目的字段归一化经验,不引入旧项目的前端 OCR 预览确认流。回滚路径:撤回腾讯归一化/质量门槛调整及新增测试。2026-04-28:已通过 `prisma validate`、目标文件 `node --check` 与 4 组后端单测;补修 `invoicePool PATCH` 中日期/金额正则误写导致的人工修正拦截问题。"
|
||||||
|
"A3","票据中心与报销详情的轻交互 OCR 异常处理","保持当前“上传后等待 OCR”的主路径不变,强化前端轻交互:识别成功后当前页面自动刷新并可绑定;仅在 `ocr_failed` 或关键字段不足时,在现有详情面板/当前页面内提供简洁说明、重试 OCR、人工修正和标记可用入口;不新增 OCR 预览弹窗,不增加额外保存确认。","用户从报销详情或票据中心上传后,不需要额外确认弹窗即可等待结果;成功路径能自动进入可绑定状态;失败路径在当前页面可直接看到原因和操作入口;报销详情页对 `pending_ocr/ocr_failed` 的提示清晰且不打断。","命令:cd client && npm run typecheck && npm run build。业务:1) 报销详情上传发票 -> 显示识别中 -> 成功后候选列表自动可绑定;2) 票据中心遇到 ocr_failed -> 当前详情区可重试/人工修正,不出现额外 OCR 预览弹窗。","manual","DONE","DONE","DONE","client/src/components/ui/dialog.tsx|client/src/lib/invoiceOcr.ts|client/src/lib/testIds.ts|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/invoice-pool/InvoiceDetailPanel.tsx|client/src/pages/invoice-pool/InvoiceListCard.tsx|client/src/api/errorMessages.ts","[""A2""]","坚持轻交互:异常内联处理,正常路径零打断。回滚路径:撤回前端状态提示与内联操作调整。2026-04-28:已补齐报销详情上传入口的正确 VAT 上传参数、绑定成功后自动关闭弹层、票据详情对卖方/买方展示、OCR 缺字段友好文案与通用 Dialog 居中修复。"
|
||||||
|
"A4","OCR 轻交互与状态流 reviewer 复核","对 A2/A3 做独立 review,重点检查 OCR 状态流是否仍然简单可解释:成功路径无额外确认,失败路径不吞错,人工修正只在异常路径开放,且不会把无关键字段票据误放行到正常绑定链路。","review 结论覆盖:`pending_ocr -> available/ocr_failed` 转换边界、腾讯 provider 与 mock provider 契约一致性、前端是否引入新的重弹窗、人工标记 available 的权限边界与审计面。","人工 review:对照 server/src/services/ocr/tencent.js、server/src/jobs/ocrJob.js、client/src/pages/ReimbursementDetailPage.tsx、client/src/pages/InvoicePoolPage.tsx、client/src/pages/invoice-pool/InvoiceDetailPanel.tsx 输出结论;若发现状态流变重、错误原因不可见或放宽人工放行边界,则 A5 前必须先修复。","manual","DONE","DONE","DONE","server/src/services/ocr/tencent.js|server/src/jobs/ocrJob.js|server/src/routes/invoicePool.js|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/invoice-pool/InvoiceDetailPanel.tsx","[""A2"",""A3""]","review 结论:1) 成功路径仍为 `upload -> pending_ocr -> available -> bind`,未引入额外确认弹窗;2) 失败路径统一沉到 `ocr_failed`,原因通过 `ocr_last_error/details.missingFields` 前后端一致透出;3) 人工 `ocr_failed -> available` 仍受关键字段门槛与审计日志约束,不存在无关键字段放行;4) 试运行中暴露的弹层定位与关闭时机问题已在 A3 内修复。"
|
||||||
|
"A5","Playwright 自动回归固化 OCR 主链路","尽量把人工试运行转成 Playwright:新增/补强本地 mock 稳定用例,覆盖“上传 -> OCR -> 绑定 -> 提交”主链路;增加 opt-in 的 live/tencent 辅助用例,使用真实发票与真实 provider 做本地人工辅助回归;同步回填试运行清单中哪些场景已被 Playwright 覆盖。","`local/mock` 用例可稳定跑通并适合作为常规回归;`live/tencent` 用例默认不进常规 CI,仅在显式环境变量开启时运行;试运行清单可区分“已自动化”与“仍需人工体验判断”的步骤。","命令:cd client && npm run typecheck && npm run test:e2e -- tests/e2e/reimbursement-workflow-progress.spec.ts tests/e2e/invoice-ocr-flow.spec.ts。可选 live:cd client && E2E_MODE=local PLAYWRIGHT_OCR_LIVE=1 playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1。业务:mock 路径验证上传后自动完成 OCR、进入可绑定、绑定后提交报销;live 路径验证真实 VAT 发票在当前环境能完成腾讯 OCR 并进入可处理状态。","manual|playwright","DONE","DONE","DONE","client/playwright.config.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/invoice-ocr-flow.spec.ts|client/tests/e2e/manual/live-invoice-ocr.spec.ts|artifacts/r70/manual-trialrun-checklist.md","[""A2"",""A3"",""A4""]","live/tencent 用例必须显式开关,不阻断日常本地/CI;若真实腾讯 OCR 环境不可用,至少保证 mock 主链路自动化完整。2026-04-28:本地 trialrun DB `hyb_oa_manual_trialrun_20260428172231` 上以 `E2E_BASE_URL=http://127.0.0.1:3001 PLAYWRIGHT_OCR_MOCK=1` 跑通 `reimbursement-workflow-progress.spec.ts + invoice-ocr-flow.spec.ts` 共 3 条用例;live spec 已落地为显式开关辅助项,未纳入本次常规回归。"
|
||||||
|
在新工单中引用
屏蔽一个用户