From 5b53d29846eade290b94b3f1d1f052b95a804022 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 1 Sep 2026 12:58:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A5=E9=94=80=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=BC=B9=E7=AA=97=E9=98=BB=E6=96=AD=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=EF=BC=8808-30-b2-submit-error-ocr-fallback=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design.md | 1 + .../implement.md | 3 +- .../08-30-b2-submit-error-ocr-fallback/prd.md | 3 +- .../pages/MobileReimbursementDetailPage.tsx | 3 +- client/src/pages/ReimbursementDetailPage.tsx | 3 +- .../r111-invoice-reimbursement-guard.spec.ts | 123 +++++++++++++++++- 6 files changed, 128 insertions(+), 8 deletions(-) diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md index 3cdfa187..fa8a8f0c 100644 --- a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/design.md @@ -99,6 +99,7 @@ PC 和移动卡片在未归属时显示明细下拉框和“确认关联”。 - 部署后核 `/ready`、bundle marker、B2 仍为 `force_on + global`,并确认四张真人单据状态未被部署改变。 - 应用异常时回滚到本轮部署前生产候选;本轮无 schema/数据迁移,已存在票据关系无需补偿。 - 2026-08-31 首次发布在 init、应用切换、`/ready` 和 runtime smoke 通过后,被公网文案扫描拦截:4 处用户文案含内部术语 `OCR`。应用已按合同回滚至 `0f3ecfa`。根因不是公网扫描器,而是本地 `check-ui-design-rules.mjs --strict` 未进入本 task Test Plan;热修后把它提升为所有用户可见前端文案变更的提交前标准门禁,并以四词 E2E 断言防止同类回归。 +- 2026-09-01 第二次发布通过全部静态门禁、init、切换、版本和公网文案扫描,但合成草稿发现 `manual_review` 确认弹窗内触发 B2 后,提交 mutation 的 `onError` 未清空 `submitConfirmPrecheck`,模态遮罩持续拦截票据卡片的“确认关联”;应用再次按合同回滚至 `0f3ecfa`。PC/移动修复均在错误回调先关闭确认弹窗,再延后一帧调用既有问题聚焦函数;回归必须覆盖 `manual_review` 和 `warn` 两种进入同一弹窗的路径,不能从 `pass` 预检直接调用提交来替代。 ## 风险控制 diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md index e088b61a..5bfdbf84 100644 --- a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/implement.md @@ -46,6 +46,7 @@ - [x] 运行 `cd client && npm run typecheck && npm run build` 及聚焦 Playwright。 - [x] 运行 `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs` 和 `git diff --check`。 - [x] 部署失败热修:运行 `node scripts/qa/check-ui-design-rules.mjs --strict`,确保四个普通用户禁用术语(`绑定整单`、`不绑定明细`、`发票池`、`OCR`)在报销 PC/移动错误态均无命中;strict 扫描 148 个文件为 0 findings,self-test PASS,该门禁已在重新提交和部署前通过。 +- [x] 第二次部署失败热修:PC/移动提交 mutation 在 B2 `onError` 中关闭 `warn` / `manual_review` 确认弹窗,待模态层卸载后聚焦首个问题;双端回归分别走完两类预检的弹窗确认、B2 阻断、遮罩消失、就地关联和再次提交成功。 ## 7. 只读复核、提交与部署门 @@ -55,4 +56,4 @@ ## 8. 回滚 -- [ ] 应用或公网验收失败时停止员工测试,回滚到部署前生产候选;本轮无 migration/seed/flag 写入,不修改或清理既有票据关联数据。 +- [x] 两次公网验收失败均停止员工测试并回滚到 `0f3ecfa`;本轮无 migration/seed/flag 写入,真人报销和票据关系未修改,合成草稿已删除、合成票据已归档。 diff --git a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md index 17fb02eb..49841e27 100644 --- a/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md +++ b/.trellis/tasks/08-30-b2-submit-error-ocr-fallback/prd.md @@ -34,6 +34,7 @@ - 将“已关联 PDF 但 OCR 未取得金额”从“缺少发票”中拆出,给出独立原因和恢复路径;不得让员工以为需要重复上传一张已经存在的票。 - 错误反馈使用明细序号、费用类型、说明摘要或页面内发票序号,不向普通用户展示数据库 UUID、API 路径、原始 details 或“发票池”等内部术语。 - PC 与移动端都提供提交失败摘要,并在对应明细或发票卡片附近显示原因和下一步;动态错误使用可访问的告警语义,toast 只能补充,不能是唯一反馈。 +- 从 `warn` / `manual_review` 预检确认弹窗继续提交后若被 B2 阻断,PC 与移动端都必须先关闭确认弹窗、移除遮罩,再把焦点移到首个具体问题;错误恢复动作不得被仍打开的模态层遮挡。 - 服务端仍是提交权威;前端预检查不得替代服务端结构化错误和并发后的最终反馈。 ### R3 未归属发票的可恢复操作 @@ -78,7 +79,7 @@ - 后端聚焦测试覆盖未归属、无关联 PDF、金额识别失败、完整金额超额、off/未执行 OCR 人工核对、混合金额证据、免票补助、flag off 兼容和 0014 类正常通过。 - 原子关联接口覆盖成功、权限、状态、跨单、已有归属与并发零写入。 -- 前端单元/组件及 PC/移动 E2E 覆盖安全中文映射、就近定位、原子恢复、人工核对提示、可访问告警和内部 ID 不泄露。 +- 前端单元/组件及 PC/移动 E2E 覆盖安全中文映射、就近定位、原子恢复、人工核对提示、可访问告警和内部 ID 不泄露;`manual_review` 与 `warn` 都必须走完“确认弹窗 → B2 阻断 → 弹窗/遮罩关闭 → 就地关联 → 再次确认提交成功”的真实路径。 - 运行 Prisma validate、相关 Vitest、前端 typecheck/build、聚焦 Playwright、`node scripts/qa/check-ui-design-rules.mjs --strict`、流程/文档/Trellis governance 与 `git diff --check`;凡修改用户可见前端文案,strict UI 文案门禁必须在提交和部署前运行,不能只依赖公网 bundle 扫描兜底。部署后只用合成草稿做公网验收。 ## Out of Scope diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index fa024e14..f3b18fcf 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -436,9 +436,10 @@ export function MobileReimbursementDetailPage() { onError: (error) => { const message = resolveErrorMessage(error); const issues = parseReimbursementInvoiceGuardIssues(error); + setSubmitConfirmPrecheck(null); setActionError(message); setInvoiceGuardIssues(issues); - if (issues) focusFirstInvoiceGuardIssue(); + if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue); toast.error(message); }, }); diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 9a4707d2..1a78cfbe 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -276,9 +276,10 @@ export function ReimbursementDetailPage() { onError: (error) => { const message = buildActionErrorMessage(error, "操作失败,请稍后重试"); const issues = parseReimbursementInvoiceGuardIssues(error); + setSubmitConfirmPrecheck(null); setActionError(message); setInvoiceGuardIssues(issues); - if (issues) focusFirstInvoiceGuardIssue(); + if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue); toast.error(message); }, }); diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts index 7cea26ab..54c0f6d7 100644 --- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -189,11 +189,15 @@ function buildReimbursementState() { bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>, itemSaveRequests: [] as Array>, submitRequestCount: 0, + submitSuccessCount: 0, submitGuardDetails: null as null | Record, invoiceItemLinkRequests: [] as Array<{ invoiceId: string; linkedItemId?: string }>, invoiceItemLinkError: null as null | Record, + clearSubmitGuardAfterInvoiceLink: false, invoiceGuardEnabled: false, invoiceAmountManualReviewRequired: false, + precheckDecision: "pass" as "pass" | "warn" | "manual_review", + precheckStandardChecks: [] as Array>, detailItems: null as null | Array>, }; } @@ -413,14 +417,20 @@ async function mockApis(page: Page, state: ReturnType ( invoice.id === invoiceId ? { ...invoice, linkedItemId: payload.linkedItemId ?? null } : invoice )); + if (state.clearSubmitGuardAfterInvoiceLink) { + state.submitGuardDetails = null; + } await fulfillJson(route, { reimbursementId, invoiceId, @@ -733,6 +747,96 @@ async function collectVisualMetrics(page: Page) { }); } +async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm( + page: Page, + options: { mobile: boolean; decision: "manual_review" | "warn" }, +) { + const state = buildReimbursementState(); + const targetItemId = `item-${options.decision}-${options.mobile ? "mobile" : "pc"}`; + const invoice = { + ...buildUnlinkedInvoice(), + totalAmount: options.decision === "manual_review" ? null : "200.00", + amountEvidenceStatus: options.decision === "manual_review" ? "manual_review" as const : "verified" as const, + }; + state.invoiceGuardEnabled = true; + state.invoiceAmountManualReviewRequired = options.decision === "manual_review"; + state.precheckDecision = options.decision; + state.precheckStandardChecks = options.decision === "warn" ? [{ + itemId: targetItemId, + expenseTypeId: "expense-type-r111", + expenseTypeName: "业务招待", + amount: "200.00", + standardId: "standard-r111", + standardName: "合成费用标准", + maxAmount: "100.00", + perUnit: "item", + controlMode: "warn", + scopeType: "global", + scopeKey: null, + exceeded: true, + exceededAmount: "100.00", + }] : []; + state.detailItems = [{ + id: targetItemId, + expenseTypeId: "expense-type-r111", + expenseDate: "2026-05-08", + amount: "200.00", + description: "合成恢复目标明细", + expenseType: { ...buildExpenseTypes().items[0] }, + }]; + state.invoices = [invoice]; + state.submitGuardDetails = { + items: "invoice_item_guard_failed", + unlinkedInvoiceCount: 1, + unlinkedInvoiceIds: [invoice.id], + missingInvoiceItemIds: [targetItemId], + amountUnavailableItems: [], + amountExceededItems: [], + }; + state.clearSubmitGuardAfterInvoiceLink = true; + + await mockApis(page, state); + await login(page); + await page.setViewportSize(options.mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 }); + await page.goto(options.mobile + ? `/mobile/reimbursements/${reimbursementId}` + : `/reimbursements/${reimbursementId}`); + + await page.getByTestId("reimbursement-submit").click(); + const confirmDialog = page.getByRole("dialog"); + await expect(confirmDialog).toBeVisible(); + await expect(confirmDialog).toContainText( + options.decision === "manual_review" ? "需人工复核后继续" : "可继续,但需确认提醒", + ); + await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click(); + + if (options.mobile) { + await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 2 项需要处理"); + } else { + await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细"); + } + await expect(confirmDialog).toHaveCount(0); + await expect(page.locator("div.fixed.inset-0[data-state='open']")).toHaveCount(0); + const firstGuardIssue = page.locator('[data-reimbursement-guard-issue="true"]').first(); + await expect(firstGuardIssue).toBeFocused(); + + const itemSelect = page.getByLabel("选择报销明细"); + await itemSelect.selectOption(targetItemId); + await itemSelect.locator("..").getByRole("button", { name: "确认关联", exact: true }).click(); + await expect.poll(() => state.invoiceItemLinkRequests).toEqual([{ + invoiceId: invoice.id, + linkedItemId: targetItemId, + }]); + await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toHaveCount(0); + + await page.getByTestId("reimbursement-submit").click(); + await expect(confirmDialog).toBeVisible(); + await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click(); + await expect.poll(() => state.submitSuccessCount).toBe(1); + await expect(page.getByTestId("reimbursement-action-error")).toHaveCount(0); + expect(state.submitRequestCount).toBe(2); +} + test.describe("R111 国内报销发票与附件双端体验", () => { test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => { const state = buildReimbursementState(); @@ -915,6 +1019,17 @@ test.describe("R111 国内报销发票与附件双端体验", () => { await expect.poll(() => state.submitRequestCount).toBe(1); }); + for (const decision of ["manual_review", "warn"] as const) { + for (const surface of ["PC", "移动"] as const) { + test(`${surface} ${decision} 确认提交遇 B2 阻断后关闭弹窗并可恢复重提`, async ({ page }) => { + await exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(page, { + decision, + mobile: surface === "移动", + }); + }); + } + } + test("B2 提交错误在 PC/移动就近呈现,未归属发票可原子选择明细", async ({ page }) => { const state = buildReimbursementState(); state.invoiceGuardEnabled = true;