实现腾讯通用票据OCR安全适配(07-28-travel-reimbursement-guide)

这个提交包含在:
2026-07-28 22:02:11 +08:00
父节点 b4bd484260
当前提交 ac17092fa0
共修改 19 个文件,包含 1277 行新增和 127 行删除
@@ -121,6 +121,136 @@ const ocrRan = invoice.ocr_attempts > 0
|| invoice.ocr_result_id != null;
```
## Scenario: Tencent General Invoice OCR Safe Persistence
### 1. Scope / Trigger
- Trigger: production uses Tencent OCR for employee-uploaded PDF invoices,
including VAT/electronic ordinary invoices and railway electronic tickets.
- Use the single `RecognizeGeneralInvoice` entry point. Do not split railway
tickets onto the legacy train-ticket API or fall back to synthetic OCR.
- This contract applies to upload limits, provider requests, normalization,
required-field validation, duplicate detection, cache reuse, retry behavior
and database persistence.
### 2. Signatures
- Runtime env: `OCR_PROVIDER=tencent`, `TENCENT_SECRET_ID`,
`TENCENT_SECRET_KEY`, `TENCENT_OCR_REGION` and `OCR_TIMEOUT_MS`.
- Upload: `POST /api/invoice-pool/upload` with one
`application/pdf` multipart `file`, at most `7 * 1024 * 1024` bytes.
- Provider call:
`runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType })` calls
`client.RecognizeGeneralInvoice(request)`.
- Persistence boundary:
`sanitizeOcrResultForPersistence(ocrProvider, normalized)` must run before
every Tencent `OcrResult.result_json` create, cache return or cache repair.
- Duplicate boundary:
`shouldDeduplicateByInvoiceNumberOnly(normalized)` is true only for a known
no-code category with a non-empty invoice number.
### 3. Contracts
- Each PDF contains one invoice. Tencent receives page 1 only with
`EnablePdf=true`, `PdfPageNumber=1`, `EnableMultiplePage=false`,
`EnableCutImage=false`, `EnableItemPolygon=false`, `EnableQRCode=false`,
`EnableSeal=false` and `EnableOther=false`.
- Supported categories are `vat_invoice`, `full_vat_invoice` and
`electronic_train_ticket` (`ElectronicTrainTicketFull`). Other subtypes and
multiple detected invoices fail closed.
- Required normalized fields:
- `vat_invoice`: `invoiceCode`, `invoiceNumber`, `invoiceDate`,
`totalAmount`, `sellerName`.
- `full_vat_invoice`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
`sellerName`.
- `electronic_train_ticket`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
`buyerName`.
- 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.
- Tencent persistence is allow-list only: document subtype/category/count,
normalized invoice identifiers/date/amounts/buyer/seller, approved quality
error and `pdf_native` transport mode. Never persist the raw provider
response, passenger name, identity number, QR/seal data or full ticket fields.
- Existing Tencent cache rows are not trusted as already safe. Every cache hit,
including the unique-key race path, is re-sanitized and written back before
use.
- OCR remains assistive. Finance must compare the reimbursement item, source
PDF and required payment evidence; recognition success is not automatic
approval.
### 4. Validation & Error Matrix
- Non-PDF source -> terminal `unsupported_source_mime_type`; do not retry.
- PDF larger than 7 MiB -> upload rejects with
`PDF 发票不能超过 7 MiB`; do not enqueue OCR.
- No invoice / more than one invoice / unsupported subtype / provider item not
`OK` -> `invoice_not_detected` /
`multiple_invoices_not_supported` / `unsupported_invoice_subtype` /
`invoice_recognition_failed`; mark OCR failed without inventing fields.
- Missing per-category fields ->
`missing_required_fields:<comma-separated-fields>`; mark OCR failed.
- `AuthFailure*`, `UnauthorizedOperation*`, `InvalidParameter*`,
`InvalidParameterValue*`, `FailedOperation.UnOpenError`,
`LimitExceeded.TooLargeFileError`,
`ResourceUnavailable.ResourcePackageRunOut`,
`ResourceUnavailable.InArrears` and
`ResourcesSoldOut.ChargeStatusException` -> terminal failure; do not consume
retry attempts blindly or switch to a billable/ mock provider.
- A transient provider/network error not in the terminal allowlist -> retain the
bounded existing retry policy; never expose credentials or raw responses in
the user-facing error.
### 5. Good/Base/Bad Cases
- Good: one railway electronic ticket PDF under 7 MiB yields
`electronic_train_ticket`, a blank code, a valid number/date/total/buyer and
participates in number-only duplicate detection; persisted JSON contains no
passenger identity fields or raw response.
- Base: one supported VAT/electronic ordinary invoice yields its required
category fields and follows the normal code-plus-number or known no-code
duplicate rule.
- Bad: send a multi-invoice PDF, parse only the first detected invoice, copy
number into code, persist `raw`, or retry after the free resource package is
exhausted.
### 6. Tests Required
- Adapter unit tests assert the exact `RecognizeGeneralInvoice` request flags,
PDF-only transport and normalization for VAT, full VAT and
`ElectronicTrainTicketFull`.
- Adapter error tests assert terminal classification for credentials,
authorization, invalid input, oversized input, service-not-open,
arrears/charge state and free-package exhaustion.
- OCR service/job tests assert category-specific required fields,
number-only dedupe only for known no-code categories, and no retry on terminal
errors.
- 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.
- Upload route tests assert exactly 7 MiB remains accepted and any larger PDF
is rejected before OCR enqueue.
- Full backend regression, Prisma validation, sensitive-content check and
Trellis governance check remain required before commit.
### 7. Wrong vs Correct
#### Wrong
```js
const result = await client.VatInvoiceOCR({ ImageBase64: imageBase64 });
await prisma.ocrResult.create({ data: { result_json: { ...result, raw: result } } });
```
#### Correct
```js
const result = await client.RecognizeGeneralInvoice(buildGeneralInvoiceRequest(imageBase64));
const normalized = normalizeGeneralInvoiceResult(result);
const persisted = sanitizeOcrResultForPersistence("tencent", normalized);
await prisma.ocrResult.create({ data: { result_json: persisted } });
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,53 @@
# 技术设计
## 边界
本任务不开发新的 OCR provider,也不开放图片发票或改变审批流。在现有腾讯 provider 内把统一入口从 `VatInvoiceOCR` 改为 `RecognizeGeneralInvoice`,增加票种感知的安全归一化和上传大小门禁,再完成生产配置恢复、真实链路验证及两份既有业务文档更新。
生产配置写入、容器重建、腾讯 API 调用和真实样票上传均属于单独受控步骤;每一步只在前一步通过后继续。
## 配置与运行拓扑
- 生产环境文件继续作为 OCR 配置事实源。
- 凭证采用 OA OCR 专用的最小权限子用户密钥;不使用主账号永久密钥,不从聊天、仓库、日志、命令参数或历史残留复制。
- 用户在现场终端静默输入凭证。自动化只检查 provider、必要字段是否非空、文件权限和配置 smoke,不回显值或长度。
- 当前仅 server 执行 jobs,因此只重建 server,不启动 worker;重建后再次确认只有一个 OCR job executor。
- provider 从 `mock` 直接切换为 `tencent` 只能发生在服务开通、权限、计费状态和密钥均确认后。任一前置失败时保持禁止真实票据上传,不用 mock 代替真实能力。
- 计费只使用 `RecognizeGeneralInvoice` 对应的通用票据识别(高级版)每月 1,000 次专用免费包,不接独立火车票接口。生产固定中国大陆地域;控制台确认免费包已到账、后付费关闭,并配置费用或余额告警。免费包耗尽时服务失败关闭,应用记录额度类可解释终态,不自动付费续用。
## 腾讯 API 合同
- API:`RecognizeGeneralInvoice`,版本 `2018-11-19`,由当前腾讯 Node SDK 调用。
- PDF 请求:Base64、第一页、关闭多页、切图、坐标、二维码和印章返回。
- 一份 PDF 只允许解析出一张受支持票据;零张、多张、未知或不支持子类型进入明确失败状态。
- 支持子类型按两族归一化:住宿 VAT/电子普通发票;电子发票(铁路电子客票)。
- 质量门禁按 `documentSubtype` 选择必填字段;无代码电子票据不再伪造发票代码。
- `OcrResult.result_json` 仅保存规范化业务字段和安全元数据,不保存 provider 原始响应或乘车人身份字段。
- PDF 原文件上限为 7MiB,使 Base64 后保持在腾讯 10M 上限内;大小错误在上传阶段阻断。
## 验证数据流
1. 用户在腾讯云控制台确认通用票据识别(高级版)每月免费包已到账、中国大陆地域、后付费关闭和费用/余额告警。
2. 用户创建或轮换 OA OCR 专用子用户密钥,并确认 OCR 服务可用。
3. 用户在现场终端把 provider、密钥和地域写入生产受控环境文件。
4. 重建 server,使新配置进入实际运行进程。
5. 运行外部依赖 smoke、运行时 smoke、`/ready`、构建标识和 job 拓扑检查。
6. 经样票单独授权后,通过现有 PDF 发票入口分别上传住宿电子普通发票和铁路电子客票样票。
7. 只记录 provider、状态、尝试/完成证据和脱敏字段命中摘要;不记录票面、完整字段或原始 payload。
8. 正常清理或归档测试业务记录,再允许杨俊提交真实票据。
## 文档口径
- `docs/员工快速上手.md` 增加一页式差旅报销步骤:火车票和住宿费拆明细,官方 PDF 绑定对应明细,两类费用均附支付截图,金额差异必须说明,提交后进入主管和财务流程。
- `docs/报销发票附件规则.md` 固化支付证明必交、敏感字段遮挡、退款/代付/优惠差异和无截图例外口径。
- OCR 结果仅辅助识别和查重;财务仍核对明细、PDF 原件和个人支付证明。
## 失败与回退
- adapter 聚焦测试、配置 smoke、腾讯调用或任一真实样票验证失败:停止开放真实票据,不继续制造 pending/retry 数据。
- 若新配置导致运行异常,恢复变更前环境文件的受控副本并重建同一 server;回退后不得使用 mock 处理真实票据。
- 样票失败记录使用可解释状态和脱敏摘要,不直接修改数据库字段绕过状态机。
## 历史凭证结论
2026 年 5 月的证据能证明真实腾讯 OCR 曾可用,但不能证明当时密钥仍有效或满足当前最小权限。当前生产机、历史容器、同机旧环境文件、开发机环境文件及 shell 历史均无可恢复凭证;数据库/附件备份也不包含环境密钥。因此本任务不设计“从旧部署复制密钥”的路径。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,64 @@
# 实施计划
## 1. 外部前置门禁
- [x] 用户确认采用 OA OCR 专用最小权限子用户密钥。
- [ ] 用户在腾讯云侧创建或轮换该专用密钥。
- [ ] 在实际账号确认通用票据识别(高级版)每月 1,000 次专用免费包已到账,生产使用中国大陆地域。
- [ ] 在 OCR 控制台设置页确认后付费关闭,并配置费用或余额告警;额度耗尽时接受 OCR 失败关闭。
- [ ] 确认腾讯 OCR 服务和 `ocr:RecognizeGeneralInvoice` 最小权限可用,不接独立火车票接口。
- [ ] 确认住宿电子普通发票和铁路电子客票各一份 PDF 样票的测试授权及仓库外受控位置;不得向 Codex 或聊天提供票据。
## 2. 腾讯 adapter 修正
- [x] 将现有腾讯调用改为 `RecognizeGeneralInvoice`,固定单页 PDF 和最小返回选项。
- [x] 分别归一化住宿 VAT/电子普通发票和 `ElectronicTrainTicketFull`。
- [x] 按安全票种标识执行必填字段检查;删除代码/号码互相伪回填。
- [x] 不持久化腾讯原始响应、乘车人姓名、证件号或完整票面字段;命中旧版腾讯缓存时按同一白名单压缩。
- [x] 发票上传增加 7MiB OCR 输入上限和明确错误。
- [x] 增加两类票种、无代码、多票/不支持票据、敏感字段、大小门禁和 provider 错误聚焦测试。
## 3. 生产配置
- [ ] 对当前生产环境文件制作受控、限权、短期回退副本,不输出内容。
- [ ] 由用户在现场终端静默写入 `OCR_PROVIDER=tencent` 及专用凭证;地域沿用核验后的配置。
- [ ] 脱敏检查 provider、凭证非空、环境文件权限和配置合同。
- [ ] 仅重建实际执行 jobs 的 server,不启动独立 worker。
回退点:外部依赖 smoke 前若任一配置门禁失败,恢复变更前环境文件并重建 server;保持真实票据禁止上传。
## 4. 生产与真实能力验证
- [ ] 检查公网 `/ready` 和预期构建标识。
- [ ] 在 server 内运行现有 external deps 和运行时 smoke,不输出 Secret 或原始响应。
- [ ] 确认实际 provider 为 `tencent`,且只有一个 OCR job executor。
- [ ] 经单独样票批准后分别运行住宿电子普通发票和铁路电子客票 live OCR PDF 验收。
- [ ] 只保存脱敏状态、耗时、provider 和字段命中摘要;确认不存在 mock 调用证据。
- [ ] 按正常 API/业务生命周期清理或归档测试记录,并执行只读残留检查。
回退点:真实调用失败时停止样票和真实票据上传,保留可解释失败摘要;不得改库伪造成功或切回 mock 处理真实票据。
## 5. 文档更新
- [x] 更新 `docs/员工快速上手.md` 的差旅报销一页指南。
- [x] 更新 `docs/报销发票附件规则.md` 的支付截图、实报实销差异、退款/代付和人工复核规则。
- [x] 不新建平行员工指南,不写入真实票据、支付信息、账号或环境路径。
## 6. 验证与审查
- [x] 静态核对页面/API 上传格式、明细绑定和员工/财务字段边界。
- [x] 运行腾讯 adapter、OCR service/job 和发票上传聚焦测试。
- [x] `cd server && npm run prisma:validate`
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`(0 个错误;22 个既有未注册文档 warning,本任务未新增)
- [x] `node scripts/qa/check-trellis-governance.mjs`
- [x] `git diff --check`
- [ ] 对生产配置、样票证据和文档口径进行只读复核。
- [ ] 部署与公网检查均通过后,才通知杨俊可以提交真实差旅报销。
## 风险文件与操作
- 生产环境文件:包含 Secret,只允许用户现场静默输入;不得由补丁、聊天或命令参数传入。
- server 重建:短暂影响 OA;仅操作当前实际 server,保留回退配置。
- 真实样票:包含发票和个人/企业信息;只留在仓库外受控位置。
- 文档:员工指南必须短且与生产费用类型、PDF-only 和附件入口一致,避免把财务付款回单误写成员工材料。
@@ -0,0 +1,80 @@
# 差旅报销指南与腾讯 OCR 正式启用
## Goal
先把生产发票识别从 mock 切换到真实腾讯 OCR 并取得授权样票证据,再让杨俊及后续员工一次看懂差旅报销需要提交什么、分别上传到哪里,以及财务如何按“明细—发票—个人支付证明”复核。
## Background
- 当前报销单明细包含费用类型、发生日期、金额和说明;发票绑定到具体明细,补充附件属于整张报销单。
- 发票入口只接受 PDF;补充附件接受 PDF、JPG、PNG、WebP,可用于支付截图、订单和情况说明。
- 员工不填写财务付款凭证号、付款日期或付款渠道;这些字段属于审批通过后的财务处理。
- 生产已有 37 个 active 费用类型,其中“火车票”“住宿费”“机票”“打车费”“差旅餐补”各唯一存在;当前 active 费用标准为 0,不会自动执行金额上限。
- 用户已决定:火车票和住宿费均要求员工提交个人支付截图。
- 生产只读核验发现当前运行配置为 `OCR_PROVIDER=mock`,`TENCENT_SECRET_ID` 和 `TENCENT_SECRET_KEY` 均未配置;server 自身运行 jobs,当前没有独立 worker。
- 2026-05-05 的试运行证据证明生产腾讯 OCR 当时曾通过真实 PDF 验证;但当前生产环境、同机历史环境文件、已停止应用容器、开发机环境文件和 shell 历史均没有可恢复的旧腾讯密钥。现有自动备份只覆盖数据库和附件,不覆盖环境密钥,因此不能从备份恢复该凭证。
- 官方 API 和当前实现复核发现:现有腾讯 adapter 调用 `VatInvoiceOCR`,适合住宿增值税/电子普通发票,但不是统一覆盖电子发票(铁路电子客票)的推荐接口。官方 `RecognizeGeneralInvoice` 同时覆盖住宿常见 VAT 子类型和 `ElectronicTrainTicketFull`,当前锁定 SDK 已支持该方法。
- 当前实现会对所有票据强制要求发票代码和销售方,并在代码/号码缺失时互相回填;铁路电子客票及部分数电票会被误判失败或污染查重语义。现有 `raw` 结果还会持久化腾讯完整响应,铁路票返回可能包含乘车人证件信息。
- 腾讯要求文件 Base64 编码后不超过 10M,现有 OA 允许 10MiB 原始 PDF,存在上传成功后 OCR 必然超限的区间。
- 用户决定不接受 `off` 人工模式,要求配置并正式启用真实 OCR;在凭证配置和授权样票验证通过前,杨俊不得上传真实发票。
- 用户已确认采用推荐的凭证方案:在腾讯云创建或轮换 OA OCR 专用最小权限子用户密钥,并由用户在现场终端静默写入生产。
## Scope
- 核对报销明细、PDF 发票、补充附件、支付截图和财务付款记录的实际页面/API边界。
- 更新 `docs/员工快速上手.md`,增加一页可直接照做的差旅报销指南。
- 更新 `docs/报销发票附件规则.md`,固化火车票/住宿费支付证明、实报实销和 OCR 关闭期间人工核对规则。
- 配置并正式启用生产腾讯 OCR,完成配置门禁、受控发布、公网检查和授权 PDF 样票验证。
- 在现有腾讯 provider 内改用通用票据识别高级版,补齐住宿电子普通发票和铁路电子客票的安全归一化、质量门禁、大小限制和测试。
## Requirements
- 火车票与住宿费必须拆为两条报销明细;每条分别填写费用类型、发生日期、实际合规金额和事由说明。
- 正式电子票据/发票使用官方 PDF,通过对应明细的“添加发票”入口上传并绑定;截图不得冒充正式发票。
- 火车票和住宿费的个人支付截图通过“附件材料/补充附件”上传,并用文件名或说明明确对应哪条明细。
- 支付截图仅保留商户、金额、时间和支付成功状态;银行卡号、支付账号、订单号等无关敏感字段应遮挡。
- 报销金额以个人实际支付的合规金额为准,原则上不得超过有效发票金额;优惠券、多人合并开票、部分自费或其他差异必须在明细中说明。
- 已退款、公司直接支付或他人承担的金额不得重复报销;确实无法提供支付截图时写情况说明,由财务决定退回或例外接受,不得伪造凭证。
- 腾讯 OCR 识别字段用于匹配、查重和财务复核,但不替代支付真实性、费用合理性或发票法律状态的人工判断;财务仍核对明细金额、PDF 原件和支付证明。
- 腾讯 provider 使用 `RecognizeGeneralInvoice`,每份 PDF 只识别第一页且只接受一张受支持票据;不请求切图、字段坐标、二维码或印章数据。
- 住宿 VAT/电子普通发票与铁路电子客票按各自官方返回结构归一化;铁路电子客票不强制要求不存在的发票代码或销售方,不得用发票号码伪造代码。
- 持久化结果只保留业务需要的规范化字段、安全票种标识和传输模式,不保存腾讯原始响应、乘车人姓名、证件号或完整票面字段。
- 正式 PDF 发票增加不高于 7MiB 的 OCR 输入门禁,避免 Base64 后超过腾讯 10M 限制;超限在上传阶段给出明确提示,不进入重试队列。
- 腾讯云凭证使用最小权限子用户或专用身份,不使用主账号永久密钥;凭证只通过用户现场终端写入生产受控环境文件,不进入聊天、命令参数、日志、仓库或 Trellis。
- 不复用来源、权限或有效性无法确认的历史密钥;优先在腾讯云侧为 OA OCR 创建或轮换专用子用户密钥,并撤销确认不再使用的旧密钥。
- 正式启用前需确认 OCR 服务已开通、调用费用/免费额度和告警边界;无凭证、权限不足、欠费或 API 未开通均不得把 provider 切成 `tencent`。
- 只使用 `RecognizeGeneralInvoice` 的通用票据识别(高级版)每月 1,000 次专用免费额度,不接仅老客户续费的独立火车票接口;生产使用中国大陆地域,必须在实际账号确认免费包到账、关闭后付费并配置费用或余额告警。额度耗尽时允许 OCR 明确失败,不得自动产生费用。
- 当前无独立 worker 且 server 自身运行 jobs,配置变更只重建实际加载配置的 server;不得额外启动第二个 OCR job executor。
- 真实能力必须使用已授权 PDF 样票验证;缺少授权样票只能证明配置齐全,不能宣称真实 OCR 已通过。
- 指南明确审批链:员工提交、直属主管审批、财务复核;财务付款和回单不由员工预填。
- 文档不放真实票据、支付截图、账号、订单号、银行信息或完整业务数据。
## Acceptance Criteria
- [ ] 员工指南能在一页内回答“交什么、传到哪里、金额怎么填、哪些材料不能混传、提交后谁处理”。
- [ ] 指南内容与当前页面、服务端格式限制及生产费用类型一致。
- [ ] 业务事实源明确火车票和住宿费支付截图为必交补充材料,并包含遮挡、差异、退款和无截图例外规则。
- [ ] 腾讯 adapter 使用 `RecognizeGeneralInvoice`,聚焦测试覆盖住宿电子普通发票、铁路电子客票、无代码票据、多票/不支持票据、大小门禁和敏感原始字段不持久化。
- [ ] 腾讯云控制台确认通用票据识别(高级版)每月 1,000 次免费包已到账、中国大陆地域、后付费关闭和费用/余额告警可用;额度耗尽不产生后付费。
- [ ] 生产实际 provider 为 `tencent`,只授予 `ocr:RecognizeGeneralInvoice`,两个凭证字段已配置但不泄露,external deps smoke、`/ready` 和运行 job 拓扑通过。
- [ ] 住宿电子普通发票和铁路电子客票各一份已授权 PDF 经真实腾讯 OCR 进入可解释终态,并证明没有调用 mock;样票和原始识别 payload 不进入仓库或聊天。
- [ ] 明确 OCR 只辅助字段识别/查重,财务仍执行明细—PDF 原件—支付证明复核。
- [ ] 在真实 OCR 验收完成前不得仅因指南已完成就通知杨俊可以提交。
- [ ] 全程不把真实票据、支付截图、完整账号、银行资料、Secret 或原始 payload 写入仓库、任务或聊天。
## Out of Scope
- 新增第二个 OCR provider、开放图片发票、自动验真或增加人工发票字段。
- 本轮不做通用发票平台或覆盖全部票种;仅支持现有住宿 VAT/电子普通发票和电子发票(铁路电子客票)主路径。
- 修改报销页面、上传格式、审批规则、费用标准或费用类型。
- 代替杨俊上传票据、创建报销、提交审批,或代替主管/财务处理。
- 制定覆盖所有费用类别的完整财务制度;本轮只收口首笔差旅的火车票和住宿费。
## Test Plan
- 静态核对前后端上传格式、明细绑定、员工/财务字段边界。
- 运行腾讯 adapter 和 OCR job 聚焦单测,覆盖两类票种、缺字段、错误分类、大小限制和敏感字段最小化。
- 生产只读核对费用类型、OCR provider 和必要运行状态,仅输出类别与计数。
- 运行文档注册、敏感内容、流程合同、Trellis governance 和 `git diff --check`。
- 启用真实 OCR 后验证 `/ready`、构建标识、运行 provider=`tencent`、单一 job executor 和配置 smoke。
- 经单独样票批准后运行现有 live OCR 验收,输出只保留状态、耗时和脱敏字段命中摘要,并按正常业务/API 生命周期清理或归档测试记录。
@@ -0,0 +1,88 @@
# 腾讯 OCR API 调研
调研日期:2026-07-28。仅查阅腾讯云官方文档、当前锁定 SDK 和本地代码;未创建密钥、未调用付费 API、未上传票据。
## 官方资料
- 通用票据识别(高级版):https://cloud.tencent.com/document/api/866/90802
- 增值税发票识别:https://cloud.tencent.com/document/api/866/36210
- OCR 控制台访问管理:https://cloud.tencent.com/document/product/866/102912
- 免费额度:https://cloud.tencent.com/document/product/866/35945
- 计费概述:https://cloud.tencent.com/document/product/866/17619
- 混贴票据计费说明:https://cloud.tencent.com/document/product/866/48522
- 错误码计费说明:https://cloud.tencent.com/document/product/866/45470
- 新手常见问题:https://cloud.tencent.com/document/product/866/43818
- 欠费说明:https://cloud.tencent.com/document/product/866/30576
## 结论
### 1. 当前接口不适合作为两类差旅票据的统一入口
当前代码调用 `VatInvoiceOCR`。官方说明明确覆盖增值税专票、普票、电子专票、电子普票和数电普通/专用发票,适合住宿电子普通发票;但官方用于统一识别火车票、电子发票(铁路电子客票)和增值税发票的是 `RecognizeGeneralInvoice`。
`RecognizeGeneralInvoice` 明确返回 `ElectronicTrainTicketFull`,同时覆盖 `VatElectronicCommonInvoice`、`VatElectronicInvoiceFull` 等住宿发票常见子类型。因此本轮应在现有腾讯 provider 内改用该接口,而不是直接给当前 `VatInvoiceOCR` 配密钥。
### 2. 请求合同
- API 版本仍为 `2018-11-19`,请求域名为 `ocr.tencentcloudapi.com`。
- 当前锁定的 Node SDK 已包含 `RecognizeGeneralInvoice`;无需为了该方法先升级依赖。
- PDF 通过 Base64 传输,不需要公开文件 URL。
- Base64 编码后不得超过 10M;为避免 OA 的 10MiB 原文件上限在编码后超限,正式发票应增加不高于 7MiB 的 OCR 输入门禁。
- 本系统一份 PDF 对应一张发票,只识别第一页:`EnablePdf=true`、`PdfPageNumber=1`、`EnableMultiplePage=false`。
- 不请求切图、字段坐标、二维码或印章返回,避免无用数据和额外敏感内容。
- 默认接口频率为 5 次/秒;当前 job 串行处理,远低于该上限。
### 3. 返回结构和业务字段
- 住宿增值税/电子普通发票:从对应 VAT 子类型归一化发票代码(存在时)、号码、日期、不含税金额、税额、价税合计、销售方和购买方。
- 电子发票(铁路电子客票):从 `ElectronicTrainTicketFull` 归一化发票号码、开票日期、价税合计、税额和购买方;该票种可以没有发票代码和销售方,不能继续套用现有 VAT 五字段必填规则。
- 归一化结果需要保留安全的 `documentSubtype`,用它选择必填字段和脱敏处理;不得把乘车人姓名、证件号、完整票面字段或腾讯原始响应写入 `OcrResult.result_json`。
- 电子票据没有发票代码时,不再把发票号码复制到代码字段。文件 SHA-256 查重继续生效;号码级查重需要按已知无代码子类型做显式规则,不能依赖伪造代码绕过现有复合键。
### 4. CAM 最小权限
腾讯官方确认 OCR 支持接口级自定义授权,接口级授权的 `resource` 必须为 `*`。运行身份只需:
```json
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": ["ocr:RecognizeGeneralInvoice"],
"resource": ["*"]
}
]
}
```
运行子用户不需要 `QcloudOCRFullAccess`,也不需要开通/关闭服务或控制台管理权限。OCR 服务开通、计费设置和告警由管理员在控制台完成。
### 5. 免费接口选择、计费和错误
- 官方免费额度表明确列出:`通用票据识别(高级版)` 使用同名专用资源包,免费额度为每月 1,000 次,当月有效、每月发放。它不是与其他票据接口共用的模糊额度。
- `增值税发票识别` 也有每月 1,000 次专用免费额度,但只能解决住宿常见 VAT/电子普通发票,不能统一覆盖电子发票(铁路电子客票)。
- 独立的 `火车票识别` 位于官方“仅老客户续费相关接口”区域,且增加第二套 API、权限、归一化和错误处理,不适合作为当前新接入方案。
- 因此最省钱且最简单的方案是只接 `RecognizeGeneralInvoice`:一个接口同时覆盖住宿和铁路电子客票,并消耗通用票据识别(高级版)的每月 1,000 次专用免费额度。按当前 8 人规模通常足够,但免费政策不是永久合同,上线前仍必须在实际账号控制台确认免费包已到账。
- 高级版免费额度耗尽后,如已购买预付费包则继续扣包;如开通后付费则可能继续计费。官方当前后付费起价为 0.15 元/次。
- 为确保零费用,生产应使用中国大陆地域,在 OCR 控制台设置页确认后付费关闭;免费额度耗尽后允许 OCR 不可用并进入可解释失败,不为保持识别可用而自动付费。境外地域按国际站后付费计费,不能抵扣国内账号资源包,因此不在本轮方案内。
- 官方 FAQ 说明,2021 年 12 月 14 日前开通 OCR 的客户在资源包耗尽后可能自动开通后付费;无论账号何时开通,都以实际控制台开关状态为准,不能只凭开通日期推断不会收费。
- 通用票据识别高级版按实际识别出的发票张数计费;增值税发票进入大票计费项,铁路电子客票/全电发票进入小票计费项。
- 每份 PDF 必须只含一张票据,使一次正常识别对应一次额度扣减;混贴或多票文件可能按实际识别张数扣减,应用继续拒绝多票结果。
- 免费/付费资源包耗尽且未开后付费时会不可用;应用需把它分类为额度/计费门禁失败,而不是票据质量失败。腾讯云侧同时设置费用或余额告警作为兜底。
- 高级版接口官方错误码已核对:`ResourceUnavailable.ResourcePackageRunOut` 表示资源包耗尽,`ResourceUnavailable.InArrears` 表示欠费,`ResourcesSoldOut.ChargeStatusException` 表示计费状态异常,`FailedOperation.UnOpenError` 表示服务未开通,`LimitExceeded.TooLargeFileError` 表示文件过大。这些状态对同一输入重试没有收益,应用应一次终止并给出配置/额度类提示。
- 大部分失败不计费,但官方明确有少量失败错误也可能计费。应用必须保留有限重试和可解释终态,不能无限重试。
- 需要区别处理未开通、欠费/资源包耗尽、文件过大、图片解码/模糊和一般 OCR 失败;不得把配置或额度错误伪装成票据质量问题。
## 当前实现差距
1. `server/src/services/ocr/tencent.js` 调用 `VatInvoiceOCR`,没有铁路电子客票结构解析。
2. `server/src/services/ocr/index.js` 对所有 `vat` 强制要求代码、号码、日期、金额、销售方;铁路电子客票和部分数电票会被误判失败。
3. 现有归一化会在代码/号码缺失时互相回填,掩盖无代码电子票据并污染复合查重语义。
4. 现有归一化把腾讯完整响应放进 `raw`,随后持久化到 `OcrResult.result_json`;改用高级版后可能保存乘车人证件信息。
5. OA 发票上传使用通用 10MiB 上限,可能产生“上传成功、腾讯因 Base64 超限失败”的死路。
6. 旧版腾讯 `OcrResult.result_json` 可能仍带 `raw`;新 adapter 不再生成还不够,命中历史缓存时也必须先按安全白名单压缩,避免旧格式绕过新隐私边界。
## 推荐
只使用 `RecognizeGeneralInvoice`,不拆成 `VatInvoiceOCR + TrainTicketOCR` 两个接口。先完成腾讯 adapter、票种归一化、按票种质量门禁、敏感响应最小化、7MiB PDF 门禁和聚焦测试;再由用户在实际腾讯云账号确认高级版每月 1,000 次免费包已到账、中国大陆地域、后付费关闭和费用告警,最后创建最小权限密钥并部署。住宿发票和铁路电子客票各用一份已授权 PDF 做真实验收;在此之前不允许真实票据进入当前 `mock` 环境。
@@ -0,0 +1,32 @@
{
"id": "travel-reimbursement-guide",
"name": "travel-reimbursement-guide",
"title": "差旅报销指南与腾讯OCR正式启用",
"description": "配置并验证生产腾讯OCR,随后固化火车票、住宿费发票和支付截图的提交位置、实报实销及财务复核规则。",
"status": "in_progress",
"dev_type": null,
"scope": "生产腾讯OCR启用、授权PDF样票验证及差旅报销员工指南与支付凭证规则",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"docs/员工快速上手.md",
"docs/报销发票附件规则.md",
"server/src/routes/reimbursements.js",
"server/src/routes/invoicePool.js",
"client/src/pages/ReimbursementDetailPage.tsx"
],
"notes": "",
"meta": {}
}
+15
查看文件
@@ -49,6 +49,21 @@
- OAOPS1 期间请用真实业务数据完成报销、采购/费用、合同台账和项目关联;遇到提交、审批、付款、导出或权限阻断时,记录页面、步骤和影响等级,让管理员当天建修复合同或暂停对应流程。
- OALAUNCH1 收口期内,请把真实业务问题按“阻断 / 非阻断 / 建议”告诉管理员;不要只在聊天里说“系统有问题”。阻断包括无法提交、无法审批、付款/AP 无法闭环、越权可见、合同或资产敏感信息泄露、备份恢复或导出证据缺失。
### 差旅报销一页指南(火车票 + 住宿费)
1. 从企业微信工作台进入海音达工作台,打开 `报销`,新建报销单。
2. 把费用拆成两条明细,不要合并填写:
- 火车票:费用类型选“火车票”,填写乘车日期、个人实际支付的合规金额和事由,例如“项目出差往返高铁”。
- 住宿费:费用类型选“住宿费”,填写实际住宿日期、个人实际支付的合规金额和事由,例如“项目出差住宿”。
3. 在每条明细的“添加发票”入口上传对应的官方 PDF:火车电子票据 PDF 绑定火车票明细,住宿电子普通发票 PDF 绑定住宿费明细。每个 PDF 只放一张票据、只识别第一页,单个文件不能超过 7 MiB;图片、照片和支付截图不能当作正式发票上传。
4. 火车票和住宿费都必须另附个人支付截图。支付截图通过整张报销单的“上传附件 / 补充材料”入口上传,并在文件名或说明中写清对应“火车票”还是“住宿费”。
5. 支付截图应看得出商户、金额、支付时间和支付成功状态;银行卡号、支付账号、订单号等与复核无关的信息先遮挡。
6. 明细金额填写个人实际支付的合规金额,原则上不得超过有效发票金额。使用优惠券、多人合并开票、部分自费或发票金额与实际支付金额不一致时,必须在明细说明中写清原因。
7. 已退款、公司直接支付或他人承担的金额不能再次报销。确实无法提供支付截图时,上传情况说明,由财务决定退回还是例外接受,不得补造凭证。
8. 提交前逐项检查:两条明细、两份对应 PDF、两类支付证明和金额说明都已齐全。提交后由直属主管审批、财务复核;实际付款和付款回单由财务处理,员工不预填。
OCR 会辅助识别发票字段和查重,但不代表系统已经确认费用真实合规。财务仍会同时核对明细金额、PDF 原件和个人支付证明;三者不一致时会退回补充或更正。
## 7. 入职 / 离职时你会看到什么
- 入职时 HR 会优先在企业微信/人事助手维护人员资料,OA 后续同步生成账号、角色和绑定。
- 离职前需要归还固定资产,并交接自己负责或保管的电子资料、系统、代码仓库和备份资产;企业微信和 OA 的账号状态会按流程同步停用或禁用。
+18
查看文件
@@ -38,12 +38,16 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
## 上传与 OCR
- 发票/OCR 上传只允许 PDF 发票文件;图片、拍照直传和 HTML `capture` 入口不作为正式发票上传方式。
- 单个正式发票 PDF 不能超过 7 MiB,每份文件第一页只能包含一张票据;超过限制、多票混贴或不支持的票种在进入报销前返回可理解失败,不进入无限重试。
- PC 票据中心、PC 报销详情、移动报销详情和企业微信 WebView 内的发票入口都必须执行 PDF-only 口径。
- 上传后页面必须显示明确状态,不应长期停留在“上传中”。
- OCR 完成后,添加发票弹窗应自动刷新并尝试添加可用发票。
- OCR 失败、重复、已使用、他人已使用都要显示可理解原因。
- 刷新按钮必须有明显反馈,不能让用户误以为没点中。
- 正式环境不得使用 `OCR_PROVIDER=mock` 处理真实票据;mock 仅用于本地或明确隔离的合成测试。
- 腾讯真实 OCR 统一使用通用票据识别高级版 `RecognizeGeneralInvoice`,当前正式范围仅为住宿 VAT/电子普通发票和电子发票(铁路电子客票)。铁路电子客票不伪造发票代码或销售方;无代码票据仅在已知安全票种内启用发票号码查重。
- 腾讯 OCR 持久化结果只保留票种、发票号码/代码、日期、金额、购销方和 PDF 传输模式等业务必需字段;不得保存腾讯原始响应、乘车人姓名、证件号、完整票面字段、切图、二维码、印章或字段坐标。命中旧版腾讯缓存时也必须先按同一白名单压缩。
- 资源包耗尽、欠费、计费异常、服务未开通、凭证/权限错误和无效文件属于配置、额度或输入终态,不得伪装成票据内容质量问题,也不得对同一文件无限重试。
- 未配置真实 OCR 时使用 `OCR_PROVIDER=off`:PDF 直接进入 `available`,不入 OCR 队列、不生成发票代码、号码、日期、购销方或金额。员工仍可把票据添加到已有明细并提交,财务必须查看 PDF 原件人工核对。
- off 模式下文件 SHA-256 查重继续有效,依赖发票代码和号码的查重暂不可用。对当前小团队可短期接受;强制人工补录或接入腾讯 OCR 应另行规划,不能在紧急报销中临时增加门槛。
- 既有 `InvoicePool.ocr_provider` 为非空枚举且默认 `tencent`,所以 off 模式的新记录仍可能带该历史默认标记。判定是否实际调用 OCR 必须同时看 provider 配置、`ocr_attempts`、请求/完成时间和 OCR 结果,不能只看 `ocr_provider`。
@@ -127,6 +131,20 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 报销页只显示付款状态、回单状态、查看付款或补回单入口;标记已付款、取消付款、批量付款和执行付款必须进入付款/财务处理详情,不能放在报销列表或普通报销详情里直接执行。
- 报销页展示项目、采购、合同、资产候选或付款记录时只显示摘要和跳转,不复制对方模块的审批、付款、合同正文下载、资产建卡或高敏附件操作。
## 差旅实报实销与支付证明
首笔差旅报销按“明细—正式发票—个人支付证明”三方对应复核,当前火车票和住宿费执行以下规则:
- 火车票和住宿费必须拆成不同明细,分别选择生产已有的“火车票”和“住宿费”费用类型,填写发生日期、个人实际支付的合规金额和事由。
- 火车电子票据、住宿电子普通发票等正式票据必须使用官方 PDF,从对应明细的发票入口上传并绑定;发票截图、支付截图或拍照图片不得冒充正式发票。
- 火车票和住宿费均必须提供个人支付截图。支付截图属于整张报销单的补充附件,不绑定到发票记录;文件名或附件说明必须能对应到具体费用明细。
- 支付截图保留商户、金额、支付时间和支付成功状态即可;银行卡号、支付账号、订单号等与审批无关的信息应先遮挡,不要求员工提供完整支付账户或银行流水。
- 明细金额以员工个人实际支付的合规金额为准,原则上不得超过有效发票金额。优惠券、多人合并开票、部分自费、票面金额与实付金额不一致等情况必须在明细说明中写明差异。
- 已退款、公司直接支付或由他人承担的金额不得重复报销。发生部分退款时,只能申报扣除退款后的个人实际承担金额,并附情况说明。
- 确实无法提供支付截图时,员工必须上传情况说明;财务可以退回补充,也可以在有其他充分证据时例外接受并留痕。不得伪造、补造或借用他人支付凭证。
- OCR 只辅助识别发票字段、匹配和查重,不判断支付真实性、费用合理性或发票法律状态。财务复核时必须同时查看明细金额、PDF 原件和个人支付证明;三者不一致时退回更正或补充说明。
- 财务付款凭证号、付款日期、付款渠道和付款回单属于审批通过后的财务处理,不是员工提交报销时填写的个人支付证明。
## PC 与手机体验
PC 端:
+35 -8
查看文件
@@ -1,6 +1,11 @@
const cron = require("node-cron");
const { buildMissingRequiredOcrError, runOcrForInvoice } = require("../services/ocr");
const {
buildMissingRequiredOcrError,
runOcrForInvoice,
sanitizeOcrResultForPersistence,
shouldDeduplicateByInvoiceNumberOnly,
} = require("../services/ocr");
const { publishInvoiceUpdated } = require("../services/events");
const { resolveInvoiceDedupBlocker } = require("../services/invoiceGovernance");
const { readFileBuffer } = require("../services/storage");
@@ -117,18 +122,29 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
});
if (cached) {
const normalized = sanitizeOcrResultForPersistence(ocrProvider, cached.result_json);
if (ocrProvider === "tencent") {
await prisma.ocrResult.update({
where: { id: cached.id },
data: { result_json: normalized },
});
}
return {
ocrResultId: cached.id,
normalized: cached.result_json,
normalized,
};
}
const normalized = await runOcrForInvoice({
const normalized = sanitizeOcrResultForPersistence(
ocrProvider,
await runOcrForInvoice({
config,
ocrType: claimedInvoice.ocr_type,
imageBase64,
sourceMimeType: claimedInvoice.mime_type,
});
}),
);
try {
const created = await prisma.ocrResult.create({
@@ -164,9 +180,17 @@ async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64)
throw error;
}
const racedNormalized = sanitizeOcrResultForPersistence(ocrProvider, raced.result_json);
if (ocrProvider === "tencent") {
await prisma.ocrResult.update({
where: { id: raced.id },
data: { result_json: racedNormalized },
});
}
return {
ocrResultId: raced.id,
normalized: raced.result_json,
normalized: racedNormalized,
};
}
}
@@ -246,10 +270,10 @@ async function markInvoiceAsQualityFailed(prisma, config, invoiceId, normalized,
});
}
async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config) {
async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config, { terminal = false } = {}) {
const now = new Date();
const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1;
const isTerminal = nextAttempts >= config.ocrMaxAttempts;
const isTerminal = terminal || nextAttempts >= config.ocrMaxAttempts;
const nextStatus = isTerminal ? "ocr_failed" : "pending_ocr";
await prisma.invoicePool.update({
@@ -313,6 +337,7 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
fileSha256: claimedInvoice.file_sha256,
invoiceCode: normalized.invoiceCode,
invoiceNumber: normalized.invoiceNumber,
deduplicateByInvoiceNumberOnly: shouldDeduplicateByInvoiceNumberOnly(normalized),
ownerEmployeeId: claimedInvoice.owner_employee_id,
},
);
@@ -336,7 +361,9 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l
return { outcome: "available", status: "available" };
} catch (error) {
const message = trimErrorMessage(error);
const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
const status = await markInvoiceAsFailed(prisma, claimedInvoice, message, config, {
terminal: error?.terminal === true,
});
logger.error(
{
err: error,
+22 -4
查看文件
@@ -9,7 +9,11 @@ const { compactDateString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { toMoneyString } = require("../utils/workflow");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const { getMissingRequiredOcrFields, isOcrEnabledForType } = require("../services/ocr");
const {
OCR_PDF_MAX_BYTES,
getMissingRequiredOcrFields,
isOcrEnabledForType,
} = require("../services/ocr");
const { enqueueOcrInvoice } = require("../jobs/pgBossJobs");
const { registerInvoiceVerificationRoutes } = require("./invoicePool/verification");
const { registerInvoicePoolReadRoutes } = require("./invoicePool/read");
@@ -353,6 +357,11 @@ async function invoicePoolRoutes(fastify) {
let fileMimeType = null;
let fileSize = 0;
const fields = {};
const configuredUploadMaxBytes = Number(fastify.config.maxUploadSizeBytes);
const invoiceUploadMaxBytes =
Number.isFinite(configuredUploadMaxBytes) && configuredUploadMaxBytes > 0
? Math.min(configuredUploadMaxBytes, OCR_PDF_MAX_BYTES)
: OCR_PDF_MAX_BYTES;
try {
for await (const part of request.parts()) {
@@ -380,12 +389,21 @@ async function invoicePoolRoutes(fastify) {
const chunks = [];
for await (const chunk of part.file) {
fileSize += chunk.length;
if (fileSize > fastify.config.maxUploadSizeBytes) {
if (fileSize > invoiceUploadMaxBytes) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
message:
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "PDF 发票不能超过 7 MiB"
: "参数校验失败",
statusCode: 400,
details: { file: "file_too_large" },
details: {
file:
invoiceUploadMaxBytes === OCR_PDF_MAX_BYTES
? "ocr_pdf_too_large"
: "file_too_large",
maxBytes: invoiceUploadMaxBytes,
},
});
}
chunks.push(chunk);
+45 -1
查看文件
@@ -14,9 +14,15 @@ const OCR_FIELD_LABELS = {
invoiceDate: "开票日期",
totalAmount: "总金额",
sellerName: "销售方",
buyerName: "购买方",
};
function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) {
function buildInvoiceDedupFilters({
fileSha256 = null,
invoiceCode = null,
invoiceNumber = null,
deduplicateByInvoiceNumberOnly = false,
}) {
const filters = [];
if (fileSha256) {
filters.push({ file_sha256: fileSha256 });
@@ -26,6 +32,11 @@ function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoi
invoice_code: invoiceCode,
invoice_number: invoiceNumber,
});
} else if (deduplicateByInvoiceNumberOnly && invoiceNumber) {
filters.push({
invoice_code: null,
invoice_number: invoiceNumber,
});
}
return filters;
}
@@ -217,6 +228,39 @@ function formatInvoiceOcrFailureMessage(rawError) {
return "票据识别失败,请重试或人工补齐关键信息。";
}
if (rawError === "invoice_not_detected") {
return "未识别到支持的发票,请确认 PDF 第一页只有一张有效票据。";
}
if (rawError === "multiple_invoices_not_supported") {
return "一个 PDF 第一页只能包含一张发票,请拆分后重新上传。";
}
if (rawError === "unsupported_invoice_subtype") {
return "当前仅支持住宿增值税发票和铁路电子客票。";
}
if (rawError === "invoice_recognition_failed") {
return "腾讯 OCR 未能识别该发票,请检查 PDF 清晰度后重试。";
}
if (
rawError.includes("ResourceUnavailable.ResourcePackageRunOut") ||
rawError.includes("ResourceUnavailable.InArrears") ||
rawError.includes("ResourcesSoldOut.ChargeStatusException")
) {
return "票据识别额度或计费状态暂不可用,请联系管理员。";
}
if (
rawError.includes("ocr_credentials_missing") ||
rawError.includes("AuthFailure") ||
rawError.includes("UnauthorizedOperation") ||
rawError.includes("FailedOperation.UnOpenError")
) {
return "票据识别服务配置暂不可用,请联系管理员。";
}
if (!rawError) {
return "票据识别失败,请稍后重试。";
}
+98 -3
查看文件
@@ -1,10 +1,47 @@
const { runTencentVatOcr } = require("./tencent");
const { runTencentGeneralInvoiceOcr } = require("./tencent");
const { runMockOcr } = require("./mock");
const OCR_PDF_MAX_BYTES = 7 * 1024 * 1024;
const REQUIRED_OCR_FIELDS_BY_TYPE = {
vat: ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"],
};
const REQUIRED_OCR_FIELDS_BY_DOCUMENT_CATEGORY = {
vat_invoice: ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"],
full_vat_invoice: ["invoiceNumber", "invoiceDate", "totalAmount", "sellerName"],
electronic_train_ticket: ["invoiceNumber", "invoiceDate", "totalAmount", "buyerName"],
};
const NUMBER_ONLY_DEDUP_DOCUMENT_CATEGORIES = new Set([
"full_vat_invoice",
"electronic_train_ticket",
]);
const SAFE_DOCUMENT_CATEGORIES = new Set([
"vat_invoice",
"full_vat_invoice",
"electronic_train_ticket",
]);
const SAFE_QUALITY_ERRORS = new Set([
"invoice_not_detected",
"multiple_invoices_not_supported",
"invoice_recognition_failed",
"unsupported_invoice_subtype",
]);
const SAFE_NORMALIZED_FIELDS = [
"invoiceCode",
"invoiceNumber",
"invoiceDate",
"amount",
"taxAmount",
"totalAmount",
"sellerName",
"buyerName",
];
function isOcrEnabledForType(config, ocrType) {
if (config.ocrProvider === "mock") {
return ocrType === "vat";
@@ -14,7 +51,10 @@ function isOcrEnabledForType(config, ocrType) {
}
function getMissingRequiredOcrFields(ocrType, normalized) {
const requiredFields = REQUIRED_OCR_FIELDS_BY_TYPE[ocrType] ?? [];
const requiredFields =
REQUIRED_OCR_FIELDS_BY_DOCUMENT_CATEGORY[normalized?.documentCategory] ??
REQUIRED_OCR_FIELDS_BY_TYPE[ocrType] ??
[];
if (!requiredFields.length) {
return [];
}
@@ -26,6 +66,10 @@ function getMissingRequiredOcrFields(ocrType, normalized) {
}
function buildMissingRequiredOcrError(ocrType, normalized) {
if (typeof normalized?.qualityError === "string" && normalized.qualityError) {
return normalized.qualityError;
}
const missingFields = getMissingRequiredOcrFields(ocrType, normalized);
if (!missingFields.length) {
return null;
@@ -47,12 +91,63 @@ async function runOcrForInvoice({ config, ocrType, imageBase64, sourceMimeType }
throw new Error(`Unsupported OCR type: ${ocrType}`);
}
return runTencentVatOcr(config, { imageBase64, sourceMimeType });
return runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType });
}
function shouldDeduplicateByInvoiceNumberOnly(normalized) {
return Boolean(
normalized?.invoiceNumber &&
!normalized?.invoiceCode &&
NUMBER_ONLY_DEDUP_DOCUMENT_CATEGORIES.has(normalized?.documentCategory),
);
}
function normalizeSafeString(value, maxLength) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim();
return normalized ? normalized.slice(0, maxLength) : null;
}
function sanitizeOcrResultForPersistence(ocrProvider, normalized) {
if (ocrProvider !== "tencent") {
return normalized;
}
const safe = {
documentSubtype: normalizeSafeString(normalized?.documentSubtype, 64),
documentCategory: SAFE_DOCUMENT_CATEGORIES.has(normalized?.documentCategory)
? normalized.documentCategory
: null,
documentCount:
Number.isInteger(normalized?.documentCount) && normalized.documentCount >= 0
? Math.min(normalized.documentCount, 100)
: 1,
};
for (const field of SAFE_NORMALIZED_FIELDS) {
safe[field] = normalizeSafeString(normalized?.[field], field.endsWith("Name") ? 256 : 64);
}
if (SAFE_QUALITY_ERRORS.has(normalized?.qualityError)) {
safe.qualityError = normalized.qualityError;
}
if (normalized?.transportMode === "pdf_native") {
safe.transportMode = "pdf_native";
}
return safe;
}
module.exports = {
OCR_PDF_MAX_BYTES,
buildMissingRequiredOcrError,
getMissingRequiredOcrFields,
isOcrEnabledForType,
runOcrForInvoice,
sanitizeOcrResultForPersistence,
shouldDeduplicateByInvoiceNumberOnly,
};
+174 -67
查看文件
@@ -1,6 +1,32 @@
const tencentcloud = require("tencentcloud-sdk-nodejs");
const PDF_MIME_TYPE = "application/pdf";
const VAT_INVOICE_SUBTYPE_FIELDS = new Map([
["VatSpecialInvoice", "VatSpecialInvoice"],
["VatCommonInvoice", "VatCommonInvoice"],
["VatElectronicCommonInvoice", "VatElectronicCommonInvoice"],
["VatElectronicSpecialInvoice", "VatElectronicSpecialInvoice"],
["VatElectronicInvoiceBlockchain", "VatElectronicInvoiceBlockchain"],
["VatElectronicInvoiceToll", "VatElectronicInvoiceToll"],
]);
const FULL_VAT_INVOICE_SUBTYPE_FIELDS = new Map([
["VatElectronicSpecialInvoiceFull", "VatElectronicSpecialInvoiceFull"],
["VatElectronicInvoiceFull", "VatElectronicInvoiceFull"],
]);
const ELECTRONIC_TRAIN_TICKET_SUBTYPE = "ElectronicTrainTicketFull";
const TERMINAL_TENCENT_ERROR_CODE_PATTERNS = [
/^AuthFailure(?:\.|$)/,
/^UnauthorizedOperation(?:\.|$)/,
/^InvalidParameter(?:\.|$)/,
/^InvalidParameterValue(?:\.|$)/,
/^FailedOperation\.(?:Arrears|ImageBlur|ImageDecodeFailed|ImageSizeTooLarge|NoChargePermission|OcrFailed|ServiceIsolate|UnOpenError)$/,
/^LimitExceeded\.TooLargeFileError$/,
/^ResourceUnavailable\.(?:InArrears|ResourcePackageRunOut)$/,
/^ResourcesSoldOut\.ChargeStatusException$/,
];
function normalizeDate(value) {
if (!value || typeof value !== "string") {
return null;
@@ -34,7 +60,7 @@ function toNumberString(value) {
return null;
}
const stripped = value.replace(/[,¥¥\s]/g, "").trim();
const stripped = value.replace(/[,¥¥元\s]/g, "").trim();
if (!stripped) {
return null;
}
@@ -85,38 +111,33 @@ function normalizeString(value) {
return normalized || null;
}
function normalizeInvoiceInfoMap(result) {
if (!Array.isArray(result?.VatInvoiceInfos)) {
return null;
}
return result.VatInvoiceInfos.reduce((acc, item) => {
if (typeof item?.Name !== "string") {
return acc;
}
acc[item.Name.trim()] = item?.Value;
return acc;
}, {});
}
function resolveTransportMode(sourceMimeType) {
return sourceMimeType === PDF_MIME_TYPE ? "pdf_native" : "image_native";
}
function isTerminalTencentErrorCode(code) {
return (
code === "ocr_credentials_missing" ||
code === "unsupported_source_mime_type" ||
TERMINAL_TENCENT_ERROR_CODE_PATTERNS.some((pattern) => pattern.test(code))
);
}
function wrapTencentError(error, transportMode) {
const code = typeof error?.code === "string" ? error.code : "ocr_failed";
const message = error?.message ?? "ocr_failed";
const wrapped = new Error(message);
wrapped.code = code;
wrapped.transportMode = transportMode;
wrapped.cause = error;
wrapped.terminal = isTerminalTencentErrorCode(code);
return wrapped;
}
function createTencentOcrClient(config) {
if (!config.tencentSecretId || !config.tencentSecretKey) {
throw new Error("Tencent OCR credentials are missing");
const error = new Error("Tencent OCR credentials are missing");
error.code = "ocr_credentials_missing";
throw error;
}
const OcrClient = tencentcloud.ocr.v20181119.Client;
@@ -134,66 +155,150 @@ function createTencentOcrClient(config) {
});
}
function normalizeVatInvoiceResult(result) {
const infoMap = normalizeInvoiceInfoMap(result);
const invoiceCodeFromInfo =
normalizeIdentifier(infoMap?.["发票代码"]) ?? normalizeIdentifier(infoMap?.["发票号码"]);
const invoiceNumberFromInfo =
normalizeIdentifier(infoMap?.["发票号码"]) ?? normalizeIdentifier(infoMap?.["发票代码"]);
const invoiceDateFromInfo = normalizeDate(infoMap?.["开票日期"]);
const amountFromInfo = toNumberString(infoMap?.["合计金额"] ?? infoMap?.["小计金额"]);
const taxAmountFromInfo = toNumberString(infoMap?.["合计税额"] ?? infoMap?.["小计税额"]);
const totalAmountFromInfo = toNumberString(infoMap?.["价税合计(小写)"]);
const sellerNameFromInfo = normalizeString(infoMap?.["销售方名称"]);
const buyerNameFromInfo = normalizeString(infoMap?.["购买方名称"]);
const amount = toNumberString(result?.AmountWithoutTax);
const taxAmount = toNumberString(result?.TaxAmount);
const totalAmountFromSource = toNumberString(result?.TotalAmount ?? result?.AmountInFiguers);
const resolvedAmount = amount ?? amountFromInfo;
const resolvedTaxAmount = taxAmount ?? taxAmountFromInfo;
const resolvedTotalAmountFromSource = totalAmountFromSource ?? totalAmountFromInfo;
const derivedAmount =
resolvedAmount ?? subtractNumberStrings(resolvedTotalAmountFromSource, resolvedTaxAmount);
const derivedTotalAmount =
resolvedTotalAmountFromSource ?? addNumberStrings(derivedAmount, resolvedTaxAmount);
function buildBaseNormalizedResult({ documentSubtype = null, documentCategory = null, documentCount = 1 } = {}) {
return {
invoiceCode: normalizeIdentifier(result?.InvoiceCode) ?? invoiceCodeFromInfo,
invoiceNumber: normalizeIdentifier(result?.InvoiceNum) ?? invoiceNumberFromInfo,
invoiceDate: normalizeDate(result?.InvoiceDate) ?? invoiceDateFromInfo,
amount: derivedAmount,
taxAmount: resolvedTaxAmount,
totalAmount: derivedTotalAmount,
sellerName: normalizeString(result?.SellerName) ?? sellerNameFromInfo,
buyerName: normalizeString(result?.BuyerName) ?? buyerNameFromInfo,
raw: result ?? {},
documentSubtype,
documentCategory,
documentCount,
invoiceCode: null,
invoiceNumber: null,
invoiceDate: null,
amount: null,
taxAmount: null,
totalAmount: null,
sellerName: null,
buyerName: null,
};
}
async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) {
const client = createTencentOcrClient(config);
function normalizeVatInvoiceInfo(info, documentSubtype) {
const amount = toNumberString(info?.PretaxAmount);
const taxAmount = toNumberString(info?.Tax);
const totalAmount = toNumberString(info?.Total);
return {
...buildBaseNormalizedResult({ documentSubtype, documentCategory: "vat_invoice" }),
invoiceCode: normalizeIdentifier(info?.Code),
invoiceNumber: normalizeIdentifier(info?.Number ?? info?.ElectronicFullNumber),
invoiceDate: normalizeDate(info?.Date),
amount: amount ?? subtractNumberStrings(totalAmount, taxAmount),
taxAmount,
totalAmount: totalAmount ?? addNumberStrings(amount, taxAmount),
sellerName: normalizeString(info?.Seller),
buyerName: normalizeString(info?.Buyer),
};
}
function normalizeFullVatInvoiceInfo(info, documentSubtype) {
const amount = toNumberString(info?.PretaxAmount ?? info?.SubTotal);
const taxAmount = toNumberString(info?.Tax ?? info?.SubTax);
const totalAmount = toNumberString(info?.Total);
return {
...buildBaseNormalizedResult({ documentSubtype, documentCategory: "full_vat_invoice" }),
invoiceNumber: normalizeIdentifier(info?.Number),
invoiceDate: normalizeDate(info?.Date),
amount: amount ?? subtractNumberStrings(totalAmount, taxAmount),
taxAmount,
totalAmount: totalAmount ?? addNumberStrings(amount, taxAmount),
sellerName: normalizeString(info?.Seller),
buyerName: normalizeString(info?.Buyer),
};
}
function normalizeElectronicTrainTicket(info) {
const taxAmount = toNumberString(info?.Tax);
const totalAmount = toNumberString(info?.Total ?? info?.Fare);
return {
...buildBaseNormalizedResult({
documentSubtype: ELECTRONIC_TRAIN_TICKET_SUBTYPE,
documentCategory: "electronic_train_ticket",
}),
invoiceNumber: normalizeIdentifier(info?.Number),
invoiceDate: normalizeDate(info?.Date),
amount: subtractNumberStrings(totalAmount, taxAmount),
taxAmount,
totalAmount,
buyerName: normalizeString(info?.Buyer),
};
}
function buildQualityFailure(qualityError, documentCount, documentSubtype = null) {
return {
...buildBaseNormalizedResult({ documentSubtype, documentCount }),
qualityError,
};
}
function normalizeGeneralInvoiceResult(result) {
const invoiceItems = Array.isArray(result?.MixedInvoiceItems) ? result.MixedInvoiceItems : [];
if (invoiceItems.length === 0) {
return buildQualityFailure("invoice_not_detected", 0);
}
if (invoiceItems.length !== 1) {
return buildQualityFailure("multiple_invoices_not_supported", invoiceItems.length);
}
const invoiceItem = invoiceItems[0];
const documentSubtype = normalizeIdentifier(invoiceItem?.SubType);
if (invoiceItem?.Code !== "OK") {
return buildQualityFailure("invoice_recognition_failed", 1, documentSubtype);
}
const invoiceInfo = invoiceItem?.SingleInvoiceInfos;
const vatInfoField = VAT_INVOICE_SUBTYPE_FIELDS.get(documentSubtype);
if (vatInfoField && invoiceInfo?.[vatInfoField]) {
return normalizeVatInvoiceInfo(invoiceInfo[vatInfoField], documentSubtype);
}
const fullVatInfoField = FULL_VAT_INVOICE_SUBTYPE_FIELDS.get(documentSubtype);
if (fullVatInfoField && invoiceInfo?.[fullVatInfoField]) {
return normalizeFullVatInvoiceInfo(invoiceInfo[fullVatInfoField], documentSubtype);
}
if (
documentSubtype === ELECTRONIC_TRAIN_TICKET_SUBTYPE &&
invoiceInfo?.ElectronicTrainTicketFull
) {
return normalizeElectronicTrainTicket(invoiceInfo.ElectronicTrainTicketFull);
}
return buildQualityFailure("unsupported_invoice_subtype", 1, documentSubtype);
}
function buildGeneralInvoiceRequest(imageBase64) {
return {
ImageBase64: imageBase64,
EnableOther: false,
EnablePdf: true,
PdfPageNumber: 1,
EnableMultiplePage: false,
EnableCutImage: false,
EnableItemPolygon: false,
EnableQRCode: false,
EnableSeal: false,
};
}
async function runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType }) {
const transportMode = resolveTransportMode(sourceMimeType);
try {
const request = {
ImageBase64: imageBase64,
};
if (transportMode === "pdf_native") {
request.IsPdf = true;
request.PdfPageNumber = 1;
if (transportMode !== "pdf_native") {
const error = new Error("unsupported_source_mime_type");
error.code = "unsupported_source_mime_type";
throw error;
}
const result = await client.VatInvoiceOCR(request);
const normalized = normalizeVatInvoiceResult(result);
const client = createTencentOcrClient(config);
const result = await client.RecognizeGeneralInvoice(buildGeneralInvoiceRequest(imageBase64));
const normalized = normalizeGeneralInvoiceResult(result);
return {
...normalized,
transportMode,
raw: {
...(normalized.raw ?? {}),
transportMode,
},
};
} catch (error) {
throw wrapTencentError(error, transportMode);
@@ -201,6 +306,8 @@ async function runTencentVatOcr(config, { imageBase64, sourceMimeType }) {
}
module.exports = {
normalizeVatInvoiceResult,
runTencentVatOcr,
buildGeneralInvoiceRequest,
isTerminalTencentErrorCode,
normalizeGeneralInvoiceResult,
runTencentGeneralInvoiceOcr,
};
@@ -1,6 +1,7 @@
const {
INVOICE_UNAVAILABLE_REASONS,
classifyInvoiceConflict,
buildInvoiceDedupFilters,
formatInvoiceOcrFailureMessage,
resolveInvoiceAvailability,
resolveUploadDedupDecision,
@@ -32,6 +33,17 @@ function buildTx(conflicts) {
}
describe("invoiceGovernance dedup", () => {
it("uses invoice number alone only when the caller marks a known no-code subtype", () => {
expect(
buildInvoiceDedupFilters({
invoiceNumber: "251234",
deduplicateByInvoiceNumberOnly: true,
}),
).toEqual([{ invoice_code: null, invoice_number: "251234" }]);
expect(buildInvoiceDedupFilters({ invoiceNumber: "251234" })).toEqual([]);
});
it("ignores manually archived invoices when re-uploading the same file", async () => {
const archivedManual = buildInvoice({
status: "archived",
@@ -122,5 +134,13 @@ describe("invoiceGovernance dedup", () => {
expect(formatInvoiceOcrFailureMessage("mock_ocr_failed")).toBe(
"票据识别失败,请重试或人工补齐关键信息。",
);
expect(formatInvoiceOcrFailureMessage("multiple_invoices_not_supported")).toBe(
"一个 PDF 第一页只能包含一张发票,请拆分后重新上传。",
);
expect(
formatInvoiceOcrFailureMessage(
"[pdf_native][ResourceUnavailable.ResourcePackageRunOut] resource package run out",
),
).toBe("票据识别额度或计费状态暂不可用,请联系管理员。");
});
});
+144 -3
查看文件
@@ -19,15 +19,16 @@ function buildClaimedInvoice(overrides = {}) {
};
}
function buildPrisma(claimedInvoices) {
function buildPrisma(claimedInvoices, { cachedOcrResult = null, invoiceConflicts = [] } = {}) {
return {
$queryRaw: vi.fn(async () => claimedInvoices),
ocrResult: {
findUnique: vi.fn(async () => null),
findUnique: vi.fn(async () => cachedOcrResult),
create: vi.fn(async () => ({ id: "ocr-result-1" })),
update: vi.fn(async ({ data }) => ({ id: cachedOcrResult?.id, ...data })),
},
invoicePool: {
findMany: vi.fn(async () => []),
findMany: vi.fn(async () => invoiceConflicts),
findFirst: vi.fn(async () => null),
update: vi.fn(async () => ({ id: "invoice-1" })),
},
@@ -218,4 +219,144 @@ describe("ocrJob quality gate", () => {
);
expect(JSON.stringify(createdPayload)).not.toContain("\\u0000");
});
it("accepts a cached no-code electronic train ticket and checks number-only duplicates", async () => {
await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), "%PDF-1.4 synthetic");
const cachedOcrResult = {
id: "ocr-result-train",
result_json: {
documentSubtype: "ElectronicTrainTicketFull",
documentCategory: "electronic_train_ticket",
documentCount: 1,
transportMode: "pdf_native",
invoiceCode: null,
invoiceNumber: "251234567890",
invoiceDate: "2026-07-26",
amount: "95.00",
taxAmount: "5.00",
totalAmount: "100.00",
sellerName: null,
buyerName: "合成公司",
raw: {
UserName: "SENSITIVE-CACHED-PASSENGER",
UserID: "SENSITIVE-CACHED-ID",
},
},
};
const prisma = buildPrisma(
[
buildClaimedInvoice({
ocr_provider: "tencent",
storage_path: "20260428/invoice.pdf",
mime_type: "application/pdf",
}),
],
{ cachedOcrResult },
);
const logger = { warn: vi.fn(), error: vi.fn() };
const result = await runOcrOnce(
prisma,
{
storageDir: tempDir,
ocrBatchSize: 1,
ocrLockTtlSeconds: 300,
ocrMaxAttempts: 3,
ocrProvider: "tencent",
},
logger,
"ocr-test-worker",
);
expect(result).toEqual({ claimed: 1, available: 1, duplicate: 0, failed: 0 });
expect(prisma.invoicePool.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "available",
invoice_code: null,
invoice_number: "251234567890",
seller_name: null,
buyer_name: "合成公司",
}),
}),
);
expect(prisma.invoicePool.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
AND: expect.arrayContaining([
expect.objectContaining({
OR: expect.arrayContaining([
{ invoice_code: null, invoice_number: "251234567890" },
]),
}),
]),
}),
}),
);
expect(prisma.ocrResult.create).not.toHaveBeenCalled();
expect(prisma.ocrResult.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ocr-result-train" },
data: expect.objectContaining({
result_json: expect.not.objectContaining({ raw: expect.anything() }),
}),
}),
);
expect(JSON.stringify(prisma.ocrResult.update.mock.calls[0][0])).not.toContain(
"SENSITIVE-CACHED",
);
});
it("marks a cached multiple-invoice result as a quality failure without provider retry", async () => {
await fs.writeFile(path.join(tempDir, "20260428/invoice.pdf"), "%PDF-1.4 synthetic");
const prisma = buildPrisma(
[
buildClaimedInvoice({
ocr_provider: "tencent",
storage_path: "20260428/invoice.pdf",
mime_type: "application/pdf",
}),
],
{
cachedOcrResult: {
id: "ocr-result-multiple",
result_json: {
documentSubtype: null,
documentCategory: null,
documentCount: 2,
qualityError: "multiple_invoices_not_supported",
transportMode: "pdf_native",
},
},
},
);
const logger = { warn: vi.fn(), error: vi.fn() };
const result = await runOcrOnce(
prisma,
{
storageDir: tempDir,
ocrBatchSize: 1,
ocrLockTtlSeconds: 300,
ocrMaxAttempts: 3,
ocrProvider: "tencent",
},
logger,
"ocr-test-worker",
);
expect(result).toEqual({ claimed: 1, available: 0, duplicate: 0, failed: 1 });
expect(prisma.invoicePool.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "ocr_failed",
ocr_last_error: "multiple_invoices_not_supported",
ocr_attempts: { increment: 1 },
}),
}),
);
expect(logger.error).not.toHaveBeenCalled();
});
});
@@ -43,6 +43,7 @@ async function buildApp({
invoiceConflicts = [],
statusRows = [],
permissionCodes = ["invoice.upload"],
maxUploadSizeBytes = 5 * 1024 * 1024,
} = {}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r71-invoice-upload-"));
const invoicePoolCreate = vi.fn(async ({ data }) => ({
@@ -74,7 +75,7 @@ async function buildApp({
app.decorate("config", {
appTimezone: "Asia/Shanghai",
storageDir: tempDir,
maxUploadSizeBytes: 5 * 1024 * 1024,
maxUploadSizeBytes,
invoiceArchiveUnusedDays: 30,
ocrProvider,
jobDriver,
@@ -121,7 +122,7 @@ async function buildApp({
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 1, fileSize: 5 * 1024 * 1024 },
limits: { files: 1, fileSize: maxUploadSizeBytes },
});
await app.register(invoicePoolRoutes, { prefix: "/api" });
@@ -271,6 +272,48 @@ describe("invoicePool upload route", () => {
}
});
it("rejects a PDF above the 7 MiB Tencent OCR input boundary before storage", async () => {
const { app, prisma, tempDir } = await buildApp({
maxUploadSizeBytes: 8 * 1024 * 1024,
});
try {
const requestPayload = buildMultipartRequest({
filename: "invoice.pdf",
contentType: "application/pdf",
content: Buffer.alloc(7 * 1024 * 1024 + 1, 0x20),
fields: [["ocr_type", "vat"]],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/upload",
headers: {
authorization: "Bearer test",
...requestPayload.headers,
},
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toEqual(
expect.objectContaining({
code: "VALID_001",
message: "PDF 发票不能超过 7 MiB",
details: {
file: "ocr_pdf_too_large",
maxBytes: 7 * 1024 * 1024,
},
}),
);
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("reuses same-owner existing invoice instead of creating a duplicate file record", async () => {
const existingInvoiceId = "22222222-2222-4222-8222-222222222222";
const { app, prisma, tempDir } = await buildApp({
+205 -32
查看文件
@@ -1,51 +1,224 @@
const {
normalizeVatInvoiceResult,
buildGeneralInvoiceRequest,
isTerminalTencentErrorCode,
normalizeGeneralInvoiceResult,
} = require("../../../src/services/ocr/tencent");
const {
buildMissingRequiredOcrError,
getMissingRequiredOcrFields,
sanitizeOcrResultForPersistence,
shouldDeduplicateByInvoiceNumberOnly,
} = require("../../../src/services/ocr");
describe("tencent VAT OCR normalization", () => {
it("normalizes identifiers, date and amounts from Tencent result", () => {
const normalized = normalizeVatInvoiceResult({
InvoiceCode: " 0440 0320 4111 ",
InvoiceNum: " 1234 5678 ",
InvoiceDate: "2026年4月28日",
AmountWithoutTax: "1,488.50",
TaxAmount: "194.50",
AmountInFiguers: "¥1683.00",
SellerName: " 北京悦海堂餐饮管理有限公司 ",
BuyerName: " 华印公司 ",
function buildInvoiceItem(subType, fieldName, payload) {
return {
Code: "OK",
SubType: subType,
SingleInvoiceInfos: {
[fieldName]: payload,
},
};
}
describe("Tencent general invoice OCR", () => {
it("builds a single-page PDF request without cut images or sensitive extras", () => {
expect(buildGeneralInvoiceRequest("cGRm")).toEqual({
ImageBase64: "cGRm",
EnableOther: false,
EnablePdf: true,
PdfPageNumber: 1,
EnableMultiplePage: false,
EnableCutImage: false,
EnableItemPolygon: false,
EnableQRCode: false,
EnableSeal: false,
});
});
expect(normalized).toEqual(
expect.objectContaining({
it("marks credential, billing and invalid-input provider errors as terminal", () => {
expect(isTerminalTencentErrorCode("AuthFailure.SecretIdNotFound")).toBe(true);
expect(isTerminalTencentErrorCode("FailedOperation.Arrears")).toBe(true);
expect(isTerminalTencentErrorCode("FailedOperation.NoChargePermission")).toBe(true);
expect(isTerminalTencentErrorCode("InvalidParameterValue.InvalidImageContent")).toBe(true);
expect(isTerminalTencentErrorCode("LimitExceeded.TooLargeFileError")).toBe(true);
expect(isTerminalTencentErrorCode("ResourceUnavailable.InArrears")).toBe(true);
expect(isTerminalTencentErrorCode("ResourceUnavailable.ResourcePackageRunOut")).toBe(true);
expect(isTerminalTencentErrorCode("ResourcesSoldOut.ChargeStatusException")).toBe(true);
expect(isTerminalTencentErrorCode("ocr_credentials_missing")).toBe(true);
expect(isTerminalTencentErrorCode("unsupported_source_mime_type")).toBe(true);
expect(isTerminalTencentErrorCode("RequestLimitExceeded")).toBe(false);
expect(isTerminalTencentErrorCode("InternalError")).toBe(false);
});
it("normalizes a VAT electronic common invoice without preserving the provider payload", () => {
const normalized = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("VatElectronicCommonInvoice", "VatElectronicCommonInvoice", {
Code: " 0440 0320 4111 ",
Number: " 1234 5678 ",
Date: "2026年4月28日",
PretaxAmount: "1,488.50",
Tax: "194.50",
Total: "¥1683.00",
Seller: " 合成酒店 ",
Buyer: " 合成公司 ",
SellerBankAccount: "SENSITIVE-BANK-ACCOUNT",
}),
],
RequestId: "SENSITIVE-REQUEST-ID",
});
expect(normalized).toEqual({
documentSubtype: "VatElectronicCommonInvoice",
documentCategory: "vat_invoice",
documentCount: 1,
invoiceCode: "044003204111",
invoiceNumber: "12345678",
invoiceDate: "2026-04-28",
amount: "1488.50",
taxAmount: "194.50",
totalAmount: "1683.00",
sellerName: "北京悦海堂餐饮管理有限公司",
buyerName: "华印公司",
sellerName: "合成酒店",
buyerName: "合成公司",
});
expect(JSON.stringify(normalized)).not.toContain("SENSITIVE-");
});
it("accepts a full electronic VAT invoice without fabricating an invoice code", () => {
const normalized = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("VatElectronicInvoiceFull", "VatElectronicInvoiceFull", {
Number: " 25 12345678901234567890 ",
Date: "2026/04/28",
PretaxAmount: "450.00",
Tax: "27.00",
Total: "477.00",
Seller: "合成住宿服务商",
Buyer: "合成公司",
}),
);
],
});
it("derives amount from total and tax when Tencent omits AmountWithoutTax", () => {
const result = normalizeVatInvoiceResult({
InvoiceCode: "044003204111",
InvoiceNum: "12345678",
InvoiceDate: "2026/04/28",
TaxAmount: 194.5,
TotalAmount: 1683,
SellerName: "北京悦海堂餐饮管理有限公司",
BuyerName: "华印公司",
});
expect(result).toEqual(
expect(normalized).toEqual(
expect.objectContaining({
amount: "1488.50",
taxAmount: "194.50",
totalAmount: "1683.00",
documentCategory: "full_vat_invoice",
invoiceCode: null,
invoiceNumber: "2512345678901234567890",
totalAmount: "477.00",
}),
);
expect(getMissingRequiredOcrFields("vat", normalized)).toEqual([]);
expect(shouldDeduplicateByInvoiceNumberOnly(normalized)).toBe(true);
});
it("normalizes an electronic train ticket without retaining passenger identity fields", () => {
const normalized = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", {
Number: " 25 98765432109876543210 ",
Date: "2026年7月26日",
Total: "553.00元",
Tax: "4.06",
Buyer: "合成公司",
UserName: "SENSITIVE-PASSENGER-NAME",
UserID: "SENSITIVE-PASSENGER-ID",
ElectronicTicketNum: "SENSITIVE-TICKET-NUMBER",
}),
],
});
expect(normalized).toEqual({
documentSubtype: "ElectronicTrainTicketFull",
documentCategory: "electronic_train_ticket",
documentCount: 1,
invoiceCode: null,
invoiceNumber: "2598765432109876543210",
invoiceDate: "2026-07-26",
amount: "548.94",
taxAmount: "4.06",
totalAmount: "553.00",
sellerName: null,
buyerName: "合成公司",
});
expect(buildMissingRequiredOcrError("vat", normalized)).toBeNull();
expect(shouldDeduplicateByInvoiceNumberOnly(normalized)).toBe(true);
expect(JSON.stringify(normalized)).not.toContain("SENSITIVE-");
});
it("returns safe terminal quality results for zero, multiple and unsupported invoices", () => {
expect(normalizeGeneralInvoiceResult({ MixedInvoiceItems: [] })).toEqual(
expect.objectContaining({ qualityError: "invoice_not_detected", documentCount: 0 }),
);
const multiple = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [
buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", {
UserID: "SENSITIVE-FIRST-ID",
}),
buildInvoiceItem("ElectronicTrainTicketFull", "ElectronicTrainTicketFull", {
UserID: "SENSITIVE-SECOND-ID",
}),
],
});
expect(multiple).toEqual(
expect.objectContaining({ qualityError: "multiple_invoices_not_supported", documentCount: 2 }),
);
expect(JSON.stringify(multiple)).not.toContain("SENSITIVE-");
const unsupported = normalizeGeneralInvoiceResult({
MixedInvoiceItems: [buildInvoiceItem("TaxiTicket", "TaxiTicket", { Number: "SENSITIVE-NO" })],
});
expect(buildMissingRequiredOcrError("vat", unsupported)).toBe("unsupported_invoice_subtype");
expect(JSON.stringify(unsupported)).not.toContain("SENSITIVE-");
});
it("requires buyer name for electronic train tickets but not invoice code or seller", () => {
const normalized = {
documentCategory: "electronic_train_ticket",
invoiceCode: null,
invoiceNumber: "251234",
invoiceDate: "2026-07-26",
totalAmount: "100.00",
sellerName: null,
buyerName: null,
};
expect(getMissingRequiredOcrFields("vat", normalized)).toEqual(["buyerName"]);
expect(buildMissingRequiredOcrError("vat", normalized)).toBe(
"missing_required_fields:buyerName",
);
});
it("strips raw and unknown fields from historical Tencent cache payloads", () => {
const sanitized = sanitizeOcrResultForPersistence("tencent", {
invoiceCode: "044001",
invoiceNumber: "12345678",
invoiceDate: "2026-07-26",
totalAmount: "100.00",
sellerName: "合成销售方",
buyerName: "合成购买方",
transportMode: "pdf_native",
raw: {
UserName: "SENSITIVE-CACHED-NAME",
UserID: "SENSITIVE-CACHED-ID",
},
RequestId: "SENSITIVE-CACHED-REQUEST",
});
expect(sanitized).toEqual({
documentSubtype: null,
documentCategory: null,
documentCount: 1,
invoiceCode: "044001",
invoiceNumber: "12345678",
invoiceDate: "2026-07-26",
amount: null,
taxAmount: null,
totalAmount: "100.00",
sellerName: "合成销售方",
buyerName: "合成购买方",
transportMode: "pdf_native",
});
expect(JSON.stringify(sanitized)).not.toContain("SENSITIVE-");
});
});