修复铁路OCR空金额回退(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-29 15:32:16 +08:00
父节点 5f2788ccac
当前提交 665cf4add5
共修改 4 个文件,包含 58 行新增和 2 行删除
@@ -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
@@ -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。
## 风险文件与操作
+1 -1
查看文件
@@ -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({
@@ -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 }),