diff --git a/artifacts/r70/manual-trialrun-checklist.md b/artifacts/r70/manual-trialrun-checklist.md index d9bf949c..4d86f427 100644 --- a/artifacts/r70/manual-trialrun-checklist.md +++ b/artifacts/r70/manual-trialrun-checklist.md @@ -99,3 +99,24 @@ - employee:无发票时,报销详情页在提交按钮附近即时提示“提交前需先绑定至少 1 张发票,可在‘关联票据’区域添加。”,通过。 - employee:上传 `/home/shunli/Projects/hyb-oa/artifacts/北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf` -> 绑定 -> 再次提交后,报销单 `4931211b-34b4-43b8-9e9f-917569d47c33` 进入 `reviewing`,编号 `BX-20260428-0001`,通过。 - 结论: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 在不同票样下的识别质量与耗时体感。 diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 9a1b83e2..5007ab40 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "@playwright/test"; 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 baseURL = e2eMode === "live" ? process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com" @@ -8,8 +9,8 @@ const baseURL = e2eMode === "live" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: r70TrialrunAssist ? [] : ["**/manual/**"], - timeout: 120_000, + testIgnore: r70TrialrunAssist || manualE2e ? [] : ["**/manual/**"], + timeout: 180_000, expect: { timeout: 30_000, }, diff --git a/client/tests/e2e/invoice-ocr-flow.spec.ts b/client/tests/e2e/invoice-ocr-flow.spec.ts new file mode 100644 index 00000000..e5849977 --- /dev/null +++ b/client/tests/e2e/invoice-ocr-flow.spec.ts @@ -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); +}); diff --git a/client/tests/e2e/manual/live-invoice-ocr.spec.ts b/client/tests/e2e/manual/live-invoice-ocr.spec.ts new file mode 100644 index 00000000..03ac9240 --- /dev/null +++ b/client/tests/e2e/manual/live-invoice-ocr.spec.ts @@ -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); +}); diff --git a/issues/2026-04-28_19-59-15-r73-lightweight-ocr-and-playwright.csv b/issues/2026-04-28_19-59-15-r73-lightweight-ocr-and-playwright.csv new file mode 100644 index 00000000..29a364b4 --- /dev/null +++ b/issues/2026-04-28_19-59-15-r73-lightweight-ocr-and-playwright.csv @@ -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 已落地为显式开关辅助项,未纳入本次常规回归。"