比较提交
4
次代码提交
eaa2dc0ff3
...
1154887aab
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
1154887aab | ||
|
|
ac17092fa0 | ||
|
|
b4bd484260 | ||
|
|
ac0abafc0e |
@@ -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,62 @@
|
||||
# 技术设计
|
||||
|
||||
## 边界
|
||||
|
||||
本任务不开发新的 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 次专用免费包,不接独立火车票接口。生产固定中国大陆地域;控制台确认免费包已到账、后付费关闭,并配置费用或余额告警。免费包耗尽时服务失败关闭,应用记录额度类可解释终态,不自动付费续用。
|
||||
|
||||
### 生产部署根收敛
|
||||
|
||||
- 生产只读核验发现,活动 Compose 的工作目录被错误设置成标准部署根下的 `deploy/trialrun` 子目录,导致仓库再次同步到自身子目录,形成重复的 `deploy/trialrun/deploy/trialrun`。活动 server、环境文件和四个 systemd 运维 timer 均已引用该嵌套拓扑,因此它不是可直接删除的闲置副本。
|
||||
- 本次发布把部署根收敛回 deploy 脚本约定的标准单层根。迁移窗口开始时先暂停相关 timer,将当前活动根原子改名为时间戳回退目录,再创建干净标准根;不在原目录上用 `rsync --delete` 就地清理,以免同时破坏活动配置与回退证据。
|
||||
- 从回退目录把生产环境文件复制到标准根的预期相对位置,保持所有者和 `0600` 权限,不读取、打印或经本地中转 Secret。数据库和附件继续使用部署根之外的既有宿主目录,不搬迁、不初始化、不重置。
|
||||
- 使用同一 Compose project 和已批准提交部署 server;数据库容器与数据卷保持原位,不创建 worker。部署后重新安装自动备份 daily/watchdog/drill-reminder 与 WARCH3 timer,使所有 WorkingDirectory、ExecStart 和环境文件引用只指向标准根。
|
||||
- 验收包括活动 Compose 工作目录不再重复、环境文件唯一受控、server `/ready` 和构建标识正确、provider 为 `tencent`、仅一个 job executor、四个 timer 启用且各自 dry/status 检查通过。旧根作为短期回退保留,本任务不删除;后续删除需在新备份成功且另行批准后执行。
|
||||
|
||||
## 腾讯 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 处理真实票据。
|
||||
- 若标准根部署、timer 重装或健康检查失败,停止继续发布,将 Compose 工作目录和 systemd unit 恢复到时间戳回退根并重建原 server;数据库和附件目录不参与回退切换。
|
||||
- 样票失败记录使用可解释状态和脱敏摘要,不直接修改数据库字段绕过状态机。
|
||||
|
||||
## 历史凭证结论
|
||||
|
||||
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,76 @@
|
||||
# 实施计划
|
||||
|
||||
## 1. 外部前置门禁
|
||||
|
||||
- [x] 用户确认采用 OA OCR 专用最小权限子用户密钥。
|
||||
- [x] 用户在腾讯云侧创建该专用子用户和密钥。
|
||||
- [x] 在实际账号确认通用票据识别(高级版)每月 1,000 次专用免费包已到账,生产配置使用中国大陆地域。
|
||||
- [x] 在 OCR 控制台设置页确认后付费关闭;额度耗尽时接受 OCR 失败关闭。
|
||||
- [ ] 配置费用或余额告警作为二次兜底。
|
||||
- [ ] 确认腾讯 OCR 服务和 `ocr:RecognizeGeneralInvoice` 最小权限可用,不接独立火车票接口。
|
||||
- [ ] 确认住宿电子普通发票和铁路电子客票各一份 PDF 样票的测试授权及仓库外受控位置;不得向 Codex 或聊天提供票据。
|
||||
|
||||
## 2. 腾讯 adapter 修正
|
||||
|
||||
- [x] 将现有腾讯调用改为 `RecognizeGeneralInvoice`,固定单页 PDF 和最小返回选项。
|
||||
- [x] 分别归一化住宿 VAT/电子普通发票和 `ElectronicTrainTicketFull`。
|
||||
- [x] 按安全票种标识执行必填字段检查;删除代码/号码互相伪回填。
|
||||
- [x] 不持久化腾讯原始响应、乘车人姓名、证件号或完整票面字段;命中旧版腾讯缓存时按同一白名单压缩。
|
||||
- [x] 发票上传增加 7MiB OCR 输入上限和明确错误。
|
||||
- [x] 增加两类票种、无代码、多票/不支持票据、敏感字段、大小门禁和 provider 错误聚焦测试。
|
||||
|
||||
## 3. 生产配置
|
||||
|
||||
- [x] 对当前生产环境文件制作受控、限权、短期回退副本,不输出内容。
|
||||
- [x] 由用户在现场终端静默写入 `OCR_PROVIDER=tencent` 及专用凭证;地域设置为中国大陆地域。
|
||||
- [x] 脱敏检查 provider、凭证非空、环境文件权限和配置合同。
|
||||
- [ ] 仅重建实际执行 jobs 的 server,不启动独立 worker。
|
||||
|
||||
### 3.1 生产部署根收敛
|
||||
|
||||
- [x] 只读确认活动 Compose、生产环境文件和四个运维 timer 均引用重复嵌套部署根。
|
||||
- [ ] 暂停相关 timer,将活动根原子改名为时间戳回退目录,并建立干净标准单层部署根。
|
||||
- [ ] 把生产环境文件在远端复制到标准相对位置,保持所有者和 `0600` 权限;不回显或经本地传输 Secret。
|
||||
- [ ] 使用 `DEPLOY_DATA_MODE=existing`、既有外置数据库/附件宿主目录和同一 Compose project 发布批准提交;不得初始化数据或创建 worker。
|
||||
- [ ] 重新安装 daily、watchdog、drill-reminder 和 WARCH3 timer,确认 unit 不再引用重复目录。
|
||||
- [ ] 验证 `/ready`、构建标识、单一 job executor、四个 timer 状态和一次受控备份/新鲜度检查;旧根仅保留作短期回退,不在本任务删除。
|
||||
|
||||
回退点:标准根发布或任一 timer 验收失败,恢复时间戳回退根及原 unit 引用并重建原 server;数据库和附件宿主目录不移动、不重置。
|
||||
|
||||
回退点:外部依赖 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": {}
|
||||
}
|
||||
@@ -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 @@
|
||||
{"_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,22 @@
|
||||
# Claude生产复核结论只读核验:执行记录
|
||||
|
||||
## 1. 只读核验
|
||||
|
||||
- [x] 核验 02:30/07:10 稳定类别、当前 runtime identity、timer 状态和恢复点新鲜度。
|
||||
- [x] 静态确认 `deploy.sh` 不自动刷新 automatic-backup/WARCH3 units。
|
||||
- [x] 核验 workspace 历史提交、删除、远端可达性、忽略规则和敏感值模式计数。
|
||||
- [x] 核验 CEO fallback 白名单、默认值、active 非本人持有人、自审阻断和常量复用。
|
||||
- [x] 核验企微免密自动绑定的服务端、事务冲突、审计、稳定错误码和前端提示。
|
||||
- [x] 聚合核验生产成功登录、企微登录、binding、session、杨俊主管和报销状态。
|
||||
|
||||
## 2. 验证
|
||||
|
||||
- [x] 5 个聚焦 Vitest 文件、73 项测试通过。
|
||||
- [x] 全部生产命令为只读;未安装 timer、未改配置/数据、未发企微、未创建单据。
|
||||
- [x] task validate、Trellis governance、敏感内容、流程合同、文档注册和 Git 差异检查全部收口;文档审计仅保留既有 22 项 warning。
|
||||
|
||||
## 3. 输出边界
|
||||
|
||||
- 只记录计数、布尔值、稳定类别、短构建号和相对时间。
|
||||
- 不记录完整账号、userid、邮箱、手机号、宿主路径、Secret、日志原文或 payload。
|
||||
- 工程改进只列为后续任务,不在本任务实施。
|
||||
@@ -0,0 +1,96 @@
|
||||
# Claude生产复核结论只读核验
|
||||
|
||||
## Goal
|
||||
|
||||
以仓库历史、当前生产只读状态和两个既有 Trellis task 为证据,逐项核验 Claude 的复核结论,区分“当前事实”“历史事实”“合理建议”和“需要另行批准的改进”,避免因时间点或指标口径不同误判真实登录、备份风险和当前业务阻断。
|
||||
|
||||
## Background
|
||||
|
||||
- Claude 认为 02:30 自动备份因附件宿主挂载身份不匹配而失败,守卫按设计拒绝错误备份;并建议把备份 unit 刷新并入部署收尾。
|
||||
- Claude 认为个人 Trellis workspace 曾被提交后删除,但历史仍可取得;内容扫描风险低,不建议改写 Git 历史。
|
||||
- Claude 认为 CEO `finance` fallback 与企微免登实现正确。
|
||||
- Claude 认为截至其检查时没有任何真实登录,两个既有任务的真机验收和杨俊真实报销仍未完成,因此下一步应暂停新开发、先让马建勇真机登录,再让杨俊提交。
|
||||
|
||||
## Scope
|
||||
|
||||
- 自动备份历史失败、当前 runtime identity、恢复点新鲜度和标准部署脚本的 unit 刷新能力。
|
||||
- 两个 Trellis workspace 历史提交及当前忽略规则。
|
||||
- CEO `finance` fallback 与企微首次免密绑定的代码、测试和生产只读状态。
|
||||
- active 账户成功登录统计、企微真机覆盖、杨俊主管链和真实报销状态。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 只读核验自动备份最近一次 02:30 失败的稳定类别、运行身份差异、最近成功恢复点年龄,以及当前 unit 与活动 Compose/PGDATA/附件/构建身份是否已经一致。
|
||||
- 静态核验标准部署脚本是否会自动刷新自动备份 units;区分“当前已经人工刷新”与“未来部署仍可能再次漂移”。
|
||||
- 核验 `0d58ff1`、`0551212` 和 `.trellis/.gitignore`,只对相关历史 blob 做敏感模式扫描,不输出 workspace 原文。
|
||||
- 核验 R-5 `finance` fallback 的白名单、active 非本人持有人、自审阻断、默认规则与常量单一来源。
|
||||
- 核验企微首次免密绑定的服务端映射错误码与前端提示,不把前端文案修正误判为完整后端实现来源。
|
||||
- 以生产只读数据和既有任务证据核验真实企微/OA 登录次数、人员范围、杨俊报销状态及未勾选验收项;明确证据时间,避免把“此前为 0”写成“当前仍为 0”。
|
||||
- 最终给出按优先级排序的行动建议,但不安装 timer、不修改部署脚本、不改任务勾选、不写生产数据、不创建报销、不发送企微消息。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 每一项 Claude 结论均标记为“确认 / 部分确认 / 已过时 / 不成立”,并附不泄密的证据来源。
|
||||
- [x] 明确当前自动备份数据风险、最近成功点、unit 当前一致性和 deploy 自动刷新缺口是否同时成立。
|
||||
- [x] 明确真实登录的统计口径及当前计数,不以 task checkbox 单独代替生产证据。
|
||||
- [x] 明确杨俊真实报销是否已提交,以及 CEO fallback 是否位于其直接阻断路径。
|
||||
- [x] 给出一个最小下一步,区分立即人工操作与后续需要新 Trellis task 的工程改进。
|
||||
- [x] 全程零生产写入、零企微写入、零真实业务单据创建,聊天和任务文件不出现 Secret、完整账号或原始 payload。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 用 systemd、Docker inspect 和本地备份状态文件做生产只读身份矩阵;只输出布尔值、计数、短构建号和相对时间。
|
||||
- 用 Git object/history 检查 workspace 提交与删除,并只报告敏感模式计数。
|
||||
- 静态检查 workflow、approval rules、seed、企微路由/服务/前端提示并运行聚焦 Vitest。
|
||||
- 用生产 PostgreSQL 聚合查询成功认证审计、binding、active session、目标员工主管链和报销计数。
|
||||
- 运行 task validate、Trellis governance、敏感内容、流程合同、文档注册和 `git diff --check`。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 自动修复或重装备份 units。
|
||||
- 修改 `deploy.sh`、认证、审批或前端代码。
|
||||
- 代替马建勇、杨俊或其他员工完成真机登录与真实报销。
|
||||
- 改写 Git 历史清除已确认低风险的 workspace 提交。
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- 本任务不需要架构设计;因治理脚本将跨部署、认证和审批核验判定为复杂任务,补充 `implement.md` 记录只读执行顺序和门禁。
|
||||
- 生产查询只输出计数、布尔值、稳定类别、短构建号和相对时间,不输出人员完整账号、完整远端路径或审计原文。
|
||||
|
||||
## Review Results(2026-07-28)
|
||||
|
||||
### 1. 自动备份
|
||||
|
||||
- **确认**:02:30 的稳定类别为 `phase_runtime_identity_failed`,唯一身份漂移是附件宿主挂载;07:10 的 `phase_stale_check_failed` 是同一 runtime identity 预检失败,不是第二个归档损坏。runner 在数据库 dump、附件归档和传输前失败关闭。
|
||||
- **已过时**:unit 仍绑定旧附件路径的说法只描述故障时点。当前唯一 server/db、Compose project、PGDATA、附件挂载和四个 service 的构建身份均一致;三项自动备份 timer 与 WARCH3 timer 均 enabled/active。
|
||||
- **部分确认**:最近成功点在 Claude 核验时约 11 小时;本次复核时约 8.1 小时且仍在 24/26 小时窗口内,但恢复点属于上一构建。当前数据新鲜度风险低,构建差异按合同是 finding,不是备份失败。
|
||||
- **确认**:`deploy/trialrun/deploy.sh` 没有调用自动备份或 WARCH3 安装器。规约已要求部署后比较并按需刷新,但标准脚本没有自动执行,未来部署仍可能再次漂移。
|
||||
|
||||
### 2. Trellis workspace Git 历史
|
||||
|
||||
- **确认**:`0d58ff1` 新增两份 workspace 文件共 394 行,`0551212` 删除;原提交已被远端分支包含,因此删除不等于从 Git 历史清除。
|
||||
- **确认**:对原提交 blob 做值形态扫描,凭证赋值、完整企微机器人地址、邮箱、手机号、私钥和银行卡样式均为 0;不建议为低风险内容改写共享历史。
|
||||
- **确认**:`.trellis/.gitignore` 当前已忽略 `workspace/`。
|
||||
|
||||
### 3. CEO `finance` fallback
|
||||
|
||||
- **确认**:`finance` 与 `finance_mgr` 共用 `OWNER_SELF_FALLBACK_ROLE_CODES`,路由不再重复定义白名单;新规则默认 fallback 为 `finance`,三档 seed 的财务节点和 fallback 均为 `finance`。
|
||||
- **确认**:fallback 只接受 active、未归档且非申请人本人的角色持有人;申请人本人持有 fallback 角色、无非本人持有人和不支持角色均失败关闭。
|
||||
- **确认**:生产三条 active 规则的 fallback 均为 `finance`,active finance 持有人唯一。相关 5 个测试文件共 73 项通过。
|
||||
- **确认但非杨俊直接阻断**:杨俊的直属主管是 active CEO,`ceo` 具备 `expense.approve`;CEO fallback 解决的是 CEO 本人申请且无主管时的链路。
|
||||
|
||||
### 4. 企微首次免密绑定
|
||||
|
||||
- **确认**:服务端普通 OAuth 登录先走已有 binding;缺失时按精确 userid 或唯一企业邮箱匹配 active OA 员工,在事务中复核双向冲突、创建 binding、写脱敏审计并创建会话;冲突或 inactive 均失败关闭。
|
||||
- **确认**:服务端输出 `account_mapping_required`、`account_mapping_conflict`、`account_mapping_inactive`,前端分别展示对应提示。
|
||||
- **不成立**:`9b12451`“只有前端改动”的说法不成立。该提交同时修改后端路由并新增自动绑定服务及后端测试;“后端能力确实存在”的最终结论成立。
|
||||
|
||||
### 5. 真实登录与杨俊报销
|
||||
|
||||
- **已过时**:“当前没有任何人登录过”。生产口径为成功认证审计:6 个 active 账户、6 个 active binding,累计 12 次成功登录事件、3 个 active 登录人;其中企微成功 1 人,且该人是管理员,最近一次约 3.3 小时前。当前另有 1 个 active 会话人、2 个 active session。
|
||||
- **部分确认**:其余人员的企微真机验收仍未完成。生产企微成功登录仅 1 人、非管理员为 0;两个既有 task 的全员/目标员工验收项仍未勾选。
|
||||
- **确认**:杨俊 active 且 binding 唯一,但企微成功登录为 0;其名下真实报销总数和非草稿数均为 0。生产当前也没有任何报销单。
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
立即人工操作只做一件事:请马建勇本人从企微工作台进入 OA。若成功,再请杨俊本人进入并提交真实报销;若失败,只记录页面稳定提示,不连续试错。后续工程改进另建 Trellis task,把部署后的 unit 身份比较与按需刷新收敛进标准部署收尾;本只读任务不实施该改动。
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "claude-production-review-verification",
|
||||
"name": "claude-production-review-verification",
|
||||
"title": "Claude生产复核结论只读核验",
|
||||
"description": "只读核验Claude对自动备份、Trellis历史、CEO财务兜底、企微免登和真实登录/报销状态的复核结论。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": "只读核验自动备份、Git历史、审批fallback、企微免登和真实登录/报销状态",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-07-28",
|
||||
"completedAt": "2026-07-28",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"deploy/trialrun/deploy.sh",
|
||||
"server/src/utils/workflow.js",
|
||||
"server/src/routes/approvalRules.js",
|
||||
"server/src/routes/wecomAuth.js",
|
||||
"server/src/services/integrations/wecomLoginAutoBinding.js",
|
||||
".trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md",
|
||||
".trellis/tasks/07-28-all-account-login-readiness/prd.md"
|
||||
],
|
||||
"notes": "只读核验完成:Claude关于历史备份失败、Git历史、CEO fallback和企微后端能力的主体判断成立;无人登录已过时,杨俊尚未企微首登或提交报销。",
|
||||
"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 端:
|
||||
|
||||
+39
-12
@@ -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({
|
||||
config,
|
||||
ocrType: claimedInvoice.ocr_type,
|
||||
imageBase64,
|
||||
sourceMimeType: claimedInvoice.mime_type,
|
||||
});
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "票据识别失败,请稍后重试。";
|
||||
}
|
||||
|
||||
@@ -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("票据识别额度或计费状态暂不可用,请联系管理员。");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
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: "合成公司",
|
||||
});
|
||||
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: "合成公司",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized).toEqual(
|
||||
expect.objectContaining({
|
||||
invoiceCode: "044003204111",
|
||||
invoiceNumber: "12345678",
|
||||
invoiceDate: "2026-04-28",
|
||||
amount: "1488.50",
|
||||
taxAmount: "194.50",
|
||||
totalAmount: "1683.00",
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
buyerName: "华印公司",
|
||||
documentCategory: "full_vat_invoice",
|
||||
invoiceCode: null,
|
||||
invoiceNumber: "2512345678901234567890",
|
||||
totalAmount: "477.00",
|
||||
}),
|
||||
);
|
||||
expect(getMissingRequiredOcrFields("vat", normalized)).toEqual([]);
|
||||
expect(shouldDeduplicateByInvoiceNumberOnly(normalized)).toBe(true);
|
||||
});
|
||||
|
||||
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: "华印公司",
|
||||
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(result).toEqual(
|
||||
expect.objectContaining({
|
||||
amount: "1488.50",
|
||||
taxAmount: "194.50",
|
||||
totalAmount: "1683.00",
|
||||
}),
|
||||
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-");
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户