修复铁路OCR空金额回退(07-28-travel-reimbursement-guide)
这个提交包含在:
@@ -165,6 +165,9 @@ const ocrRan = invoice.ocr_attempts > 0
|
|||||||
`sellerName`.
|
`sellerName`.
|
||||||
- `electronic_train_ticket`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
|
- `electronic_train_ticket`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
|
||||||
`buyerName`.
|
`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
|
- 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
|
no-code full-VAT and railway tickets use invoice-number-only duplicate
|
||||||
detection; other categories retain code-plus-number semantics.
|
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,
|
- OCR service/job tests assert category-specific required fields,
|
||||||
number-only dedupe only for known no-code categories, and no retry on terminal
|
number-only dedupe only for known no-code categories, and no retry on terminal
|
||||||
errors.
|
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
|
- Cache tests seed a legacy Tencent result containing `raw` and sensitive
|
||||||
fields, then assert both normal and race cache paths return and persist only
|
fields, then assert both normal and race cache paths return and persist only
|
||||||
the allow-listed shape.
|
the allow-listed shape.
|
||||||
@@ -251,6 +257,16 @@ const persisted = sanitizeOcrResultForPersistence("tencent", normalized);
|
|||||||
await prisma.ocrResult.create({ data: { result_json: persisted } });
|
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
|
## Scenario: Live OCR Sample Stdin Probe
|
||||||
|
|
||||||
### 1. Scope / Trigger
|
### 1. Scope / Trigger
|
||||||
|
|||||||
@@ -47,7 +47,9 @@
|
|||||||
- [x] 修正 live 样票脚本:复用 `RecognizeGeneralInvoice`、按票种必填字段和安全持久化白名单,支持单 PDF 标准输入且仅输出脱敏摘要。
|
- [x] 修正 live 样票脚本:复用 `RecognizeGeneralInvoice`、按票种必填字段和安全持久化白名单,支持单 PDF 标准输入且仅输出脱敏摘要。
|
||||||
- [x] 使用合成 PDF/provider 响应覆盖住宿、铁路、非 PDF、超限、缺字段和原始响应不输出,并完成本地门禁、提交和生产部署。
|
- [x] 使用合成 PDF/provider 响应覆盖住宿、铁路、非 PDF、超限、缺字段和原始响应不输出,并完成本地门禁、提交和生产部署。
|
||||||
- [x] 为铁路 `Total`/`Fare` 增加只含存在、类型、非空和可解析布尔值的临时诊断;证明持久化白名单丢弃诊断且 live runner 不输出真实值。
|
- [x] 为铁路 `Total`/`Fare` 增加只含存在、类型、非空和可解析布尔值的临时诊断;证明持久化白名单丢弃诊断且 live runner 不输出真实值。
|
||||||
- [ ] 完成本地门禁、提交和生产部署后,使用剩余一次授权诊断铁路金额字段;铁路未 PASS 时仍不调用住宿样票。
|
- [x] 完成本地门禁、提交和生产部署后,使用剩余一次授权诊断铁路金额字段;铁路未 PASS 时仍不调用住宿样票。
|
||||||
|
- [ ] 修正铁路空 `Total` 阻断可解析 `Fare` 回退的问题,增加回归测试并完成提交、生产部署与运行身份复核。
|
||||||
|
- [ ] 经新增授权复验铁路票;仅在铁路 PASS 后调用住宿票,总计最多两次腾讯 OCR 调用。
|
||||||
- [ ] 经单独样票批准后分别运行住宿电子普通发票和铁路电子客票 live OCR PDF 验收。
|
- [ ] 经单独样票批准后分别运行住宿电子普通发票和铁路电子客票 live OCR PDF 验收。
|
||||||
- [ ] 只保存脱敏状态、耗时、provider 和字段命中摘要;确认不存在 mock 调用证据。
|
- [ ] 只保存脱敏状态、耗时、provider 和字段命中摘要;确认不存在 mock 调用证据。
|
||||||
- [ ] 按正常 API/业务生命周期清理或归档测试记录,并执行只读残留检查。
|
- [ ] 按正常 API/业务生命周期清理或归档测试记录,并执行只读残留检查。
|
||||||
@@ -99,6 +101,11 @@
|
|||||||
- 聚焦测试 19 项、完整后端 168 个文件/1075 项、Prisma validate、前端 typecheck/build、流程合同、Trellis governance、敏感内容和 diff 门禁通过;仅有既有依赖弃用提示与 22 个既有未注册文档 warning。
|
- 聚焦测试 19 项、完整后端 168 个文件/1075 项、Prisma validate、前端 typecheck/build、流程合同、Trellis governance、敏感内容和 diff 门禁通过;仅有既有依赖弃用提示与 22 个既有未注册文档 warning。
|
||||||
- P2 聚合器的后端聚焦测试 94 项及前端门禁通过;其业务流因合成账号首次改密状态失败,live-readiness 因本地旧凭据和部署前构建标识不一致失败。进入 live OCR 步骤时已主动终止,生产最近 10 分钟新增发票和 OCR 尝试记录均为 0。
|
- P2 聚合器的后端聚焦测试 94 项及前端门禁通过;其业务流因合成账号首次改密状态失败,live-readiness 因本地旧凭据和部署前构建标识不一致失败。进入 live OCR 步骤时已主动终止,生产最近 10 分钟新增发票和 OCR 尝试记录均为 0。
|
||||||
- 诊断只存在于 provider 返回到 live runner 的进程内对象,持久化 sanitizer 丢弃整个 diagnostics;合成敏感标记未出现在序列化摘要中。
|
- 诊断只存在于 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。
|
||||||
|
|
||||||
## 风险文件与操作
|
## 风险文件与操作
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ function normalizeFullVatInvoiceInfo(info, documentSubtype) {
|
|||||||
|
|
||||||
function normalizeElectronicTrainTicket(info) {
|
function normalizeElectronicTrainTicket(info) {
|
||||||
const taxAmount = toNumberString(info?.Tax);
|
const taxAmount = toNumberString(info?.Tax);
|
||||||
const totalAmount = toNumberString(info?.Total ?? info?.Fare);
|
const totalAmount = toNumberString(info?.Total) ?? toNumberString(info?.Fare);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBaseNormalizedResult({
|
...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", () => {
|
it("returns safe terminal quality results for zero, multiple and unsupported invoices", () => {
|
||||||
expect(normalizeGeneralInvoiceResult({ MixedInvoiceItems: [] })).toEqual(
|
expect(normalizeGeneralInvoiceResult({ MixedInvoiceItems: [] })).toEqual(
|
||||||
expect.objectContaining({ qualityError: "invoice_not_detected", documentCount: 0 }),
|
expect.objectContaining({ qualityError: "invoice_not_detected", documentCount: 0 }),
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户