From 665cf4add54bb2f27a980254401ead04d0ec249c Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 29 Jul 2026 15:32:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=93=81=E8=B7=AFOCR?= =?UTF-8?q?=E7=A9=BA=E9=87=91=E9=A2=9D=E5=9B=9E=E9=80=80=EF=BC=8807-28-tra?= =?UTF-8?q?vel-reimbursement-guide=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/reimbursement-invoice-ocr.md | 16 +++++++++ .../implement.md | 9 ++++- server/src/services/ocr/tencent.js | 2 +- .../tests/unit/services/ocr.tencent.test.js | 33 +++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md index 0d2853df..57a53c34 100644 --- a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md +++ b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md @@ -165,6 +165,9 @@ const ocrRan = invoice.ocr_attempts > 0 `sellerName`. - `electronic_train_ticket`: `invoiceNumber`, `invoiceDate`, `totalAmount`, `buyerName`. +- Railway `totalAmount` uses the first individually parseable value from + Tencent `Total`, then `Fare`. Parse each field before applying fallback: + an empty or otherwise unparseable `Total` must not block a parseable `Fare`. - Never invent an invoice code from an invoice number or vice versa. Known no-code full-VAT and railway tickets use invoice-number-only duplicate detection; other categories retain code-plus-number semantics. @@ -225,6 +228,9 @@ const ocrRan = invoice.ocr_attempts > 0 - OCR service/job tests assert category-specific required fields, number-only dedupe only for known no-code categories, and no retry on terminal errors. +- Railway adapter tests must cover an empty `Total` with a parseable `Fare` and + assert that normalized `totalAmount` uses `Fare` without emitting the source + amount in diagnostics. - Cache tests seed a legacy Tencent result containing `raw` and sensitive fields, then assert both normal and race cache paths return and persist only the allow-listed shape. @@ -251,6 +257,16 @@ const persisted = sanitizeOcrResultForPersistence("tencent", normalized); await prisma.ocrResult.create({ data: { result_json: persisted } }); ``` +For railway amount fallback, do not choose the raw source before parsing: + +```js +// Wrong: an empty string is not nullish, so it blocks Fare. +toNumberString(info?.Total ?? info?.Fare); + +// Correct: only a successfully parsed Total blocks Fare. +toNumberString(info?.Total) ?? toNumberString(info?.Fare); +``` + ## Scenario: Live OCR Sample Stdin Probe ### 1. Scope / Trigger diff --git a/.trellis/tasks/07-28-travel-reimbursement-guide/implement.md b/.trellis/tasks/07-28-travel-reimbursement-guide/implement.md index ec142164..b7fbbfdc 100644 --- a/.trellis/tasks/07-28-travel-reimbursement-guide/implement.md +++ b/.trellis/tasks/07-28-travel-reimbursement-guide/implement.md @@ -47,7 +47,9 @@ - [x] 修正 live 样票脚本:复用 `RecognizeGeneralInvoice`、按票种必填字段和安全持久化白名单,支持单 PDF 标准输入且仅输出脱敏摘要。 - [x] 使用合成 PDF/provider 响应覆盖住宿、铁路、非 PDF、超限、缺字段和原始响应不输出,并完成本地门禁、提交和生产部署。 - [x] 为铁路 `Total`/`Fare` 增加只含存在、类型、非空和可解析布尔值的临时诊断;证明持久化白名单丢弃诊断且 live runner 不输出真实值。 -- [ ] 完成本地门禁、提交和生产部署后,使用剩余一次授权诊断铁路金额字段;铁路未 PASS 时仍不调用住宿样票。 +- [x] 完成本地门禁、提交和生产部署后,使用剩余一次授权诊断铁路金额字段;铁路未 PASS 时仍不调用住宿样票。 +- [ ] 修正铁路空 `Total` 阻断可解析 `Fare` 回退的问题,增加回归测试并完成提交、生产部署与运行身份复核。 +- [ ] 经新增授权复验铁路票;仅在铁路 PASS 后调用住宿票,总计最多两次腾讯 OCR 调用。 - [ ] 经单独样票批准后分别运行住宿电子普通发票和铁路电子客票 live OCR PDF 验收。 - [ ] 只保存脱敏状态、耗时、provider 和字段命中摘要;确认不存在 mock 调用证据。 - [ ] 按正常 API/业务生命周期清理或归档测试记录,并执行只读残留检查。 @@ -99,6 +101,11 @@ - 聚焦测试 19 项、完整后端 168 个文件/1075 项、Prisma validate、前端 typecheck/build、流程合同、Trellis governance、敏感内容和 diff 门禁通过;仅有既有依赖弃用提示与 22 个既有未注册文档 warning。 - P2 聚合器的后端聚焦测试 94 项及前端门禁通过;其业务流因合成账号首次改密状态失败,live-readiness 因本地旧凭据和部署前构建标识不一致失败。进入 live OCR 步骤时已主动终止,生产最近 10 分钟新增发票和 OCR 尝试记录均为 0。 - 诊断只存在于 provider 返回到 live runner 的进程内对象,持久化 sanitizer 丢弃整个 diagnostics;合成敏感标记未出现在序列化摘要中。 +- 诊断提交 `5f2788c` 已部署;公网构建、external deps、runtime smoke、单一 job executor、四个 timer、自动备份和新鲜度检查均通过。 +- 最后一次授权调用确认腾讯返回的铁路 `Total` 是空字符串,而 `Fare` 非空且可被当前解析器接受。现有 `info?.Total ?? info?.Fare` 在解析前选择空字符串,阻断了 `Fare` 回退;根因是适配器空字符串回退顺序,不是 PDF、凭证、网络或腾讯票种识别失败。 +- 本次仍为 `HOLD/missing_required_fields:totalAmount`,住宿样票未调用;所有既有真实 OCR 调用授权已用尽,修复后复验必须重新取得授权。 +- 已将铁路金额改为分别解析 `Total` 与 `Fare` 后回退,并增加“空 `Total` + 可解析 `Fare`”回归测试;聚焦测试 10 项、Prisma validate、流程合同、Trellis governance、敏感内容和 task validate 均通过。 +- 已在现有 OCR code-spec 固化同类字段回退规则和测试要求;本轮尚未提交、部署或调用腾讯 OCR。 ## 风险文件与操作 diff --git a/server/src/services/ocr/tencent.js b/server/src/services/ocr/tencent.js index a3e50a16..045979af 100644 --- a/server/src/services/ocr/tencent.js +++ b/server/src/services/ocr/tencent.js @@ -222,7 +222,7 @@ function normalizeFullVatInvoiceInfo(info, documentSubtype) { function normalizeElectronicTrainTicket(info) { const taxAmount = toNumberString(info?.Tax); - const totalAmount = toNumberString(info?.Total ?? info?.Fare); + const totalAmount = toNumberString(info?.Total) ?? toNumberString(info?.Fare); return { ...buildBaseNormalizedResult({ diff --git a/server/tests/unit/services/ocr.tencent.test.js b/server/tests/unit/services/ocr.tencent.test.js index 24fb304e..18b0e3f4 100644 --- a/server/tests/unit/services/ocr.tencent.test.js +++ b/server/tests/unit/services/ocr.tencent.test.js @@ -199,6 +199,39 @@ describe("Tencent general invoice OCR", () => { ); }); + it("falls back to a parseable railway fare when total is an empty string", () => { + const normalized = normalizeGeneralInvoiceResult({ + MixedInvoiceItems: [ + buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", { + Number: "251234", + Date: "2026-07-26", + Total: "", + Fare: "¥100.00", + Buyer: "合成公司", + }), + ], + }); + + expect(normalized.totalAmount).toBe("100.00"); + expect(normalized.diagnostics.trainAmountFields).toEqual({ + total: { + present: true, + isString: true, + isNumber: false, + nonEmpty: false, + parseable: false, + }, + fare: { + present: true, + isString: true, + isNumber: false, + nonEmpty: true, + parseable: true, + }, + }); + expect(buildMissingRequiredOcrError("vat", normalized)).toBeNull(); + }); + it("returns safe terminal quality results for zero, multiple and unsupported invoices", () => { expect(normalizeGeneralInvoiceResult({ MixedInvoiceItems: [] })).toEqual( expect.objectContaining({ qualityError: "invoice_not_detected", documentCount: 0 }),