---
name: invoice-extract
description: >-
  把单份票据文件解析成结构化字段：PDF（数电票 / 旧版增值税票 / 铁路电子客票 / 航空行程单）、
  OFD（优先读内嵌国标结构化 XML）、扫描件图片（视觉识别），含字段抽取规则、置信度评分、
  勾稽校验与失败处理。Use when 需要读出某个发票文件里的发票号码 / 开票日期 / 销售方 / 价税合计等字段，
  或解析报错、抽出来的字段不对、遇到加密 PDF / 扫描件 / OFD 时。
  Also covers invoice field extraction, OCR fallback and confidence scoring.
version: 1.0.0
type: procedural
risk_level: low
status: enabled
tags:
  - invoice
  - extraction
  - pdf
  - ofd
metadata:
  category: extraction
  i18n:
    default_locale: en-US
    source_locale: zh-CN
    locales:
      - zh-CN
      - en-US
    zh-CN:
      name: 发票解析
      short_desc: PDF / OFD / 扫描件的字段抽取规则与置信度评分
    en-US:
      name: Invoice Extraction
      short_desc: Field extraction rules and confidence scoring for PDF, OFD and scans
  requires:
    tools:
      - Read
      - FileDigest
---

# 发票解析

## L0

三条路径，按可信度从高到低：**OFD 内嵌结构化数据 → PDF/OFD 文本层启发式抽取 → 视觉识别**。

走哪条由文件本身决定，不由你选。OFD 的结构化数据有三个来源、可信度并不一样，取值前先认出处。
抽不齐必填字段就隔离，绝不用「常见格式」补全。

## L1

### 路径选择

| 文件 | 怎么做 | `extractedBy` | 置信度基线 |
| --- | --- | --- | --- |
| `.ofd` 有「内嵌附件」来源 | `Read` 它，取标注「来源: 内嵌附件」的那一段 | `ofd-xml` | **1.0** |
| `.ofd` 只有「发票标引」来源 | 同上，取标注「来源: …CustomTag.xml」的那一段，**缺的字段回页文本补** | `ofd-xml` | 0.9 |
| `.ofd` 只有 `DocInfo/CustomDatas` | 当线索用，**必须**与页文本交叉核对（尤其价税合计） | `text-layer` | 0.85 |
| `.ofd` 三个来源都没有 | 用 `Read` 给出的逐页文本走文本层规则 | `text-layer` | 0.85 起 |
| `.pdf` 有可用文字层 | `Read` 直接抽文本 | `text-layer` | 0.9 起 |
| `.pdf` 无文字层，或文字层抽出的内容不足以判定 | 无文字层时 `Read` 自动渲染成图；有垃圾文字层时显式 `pdf_mode:"render"` + `pages` | `vision` | 0.7 起 |
| `.jpg` / `.png` / `.webp` | 直接 `Read` 图片路径，你自己看 | `vision` | 0.7 起 |

不需要 Python，不需要安装任何东西，`Read` 一个工具全包。

**图片千万不要用 `UnderstandImage`**——那个工具只吃 HTTP URL，本地文件路径喂不进去。本地图片就是 `Read{file_path}`。

### OFD：三个结构化数据来源，先认出处再取值

OFD 是中国电子发票的两大法定载体之一。`Read` 一个 `.ofd` 返回三段：**文档概览**（页数、电子签章、内嵌附件清单、内嵌标引清单、文档自带元数据）、**内嵌结构化数据**、**逐页文本**。

结构化数据有**三个互不等价的来源**，`Read` 的输出里每一段都写明了出处。**先看出处，再决定怎么用**——三者的字段完整度和可信度差得很远，混为一谈会算错税额。

| 来源 | 在输出里长什么样 | 年代 | 有什么 | 缺什么 |
| --- | --- | --- | --- | --- |
| **① 内嵌附件** | `## 内嵌结构化数据: … — 来源: 内嵌附件 Doc_0/Attachs/…` | 2020 年式样 | 字段最全：发票代码、号码、买卖双方名称/税号/地址电话/开户行、金额税额、逐行商品明细 | — |
| **② 发票标引** | `## 内嵌结构化数据: 发票标引 (TypeID=…) — 来源: …CustomTag.xml，值由 ObjectRef 指向的页面文字对象解引用得到` | **2024 数电票 / 全电发票**（当前主流） | 发票号码、开票日期、买卖双方名称与税号、不含税金额、税额、价税合计、开票人、货物名称、税率、单价、数量、备注 | **发票代码、地址电话、开户行、多行明细的逐行结构**——只能回逐页文本找 |
| **③ 文档自带元数据** | 只出现在 `## 文档概览` 的 `- 文档自带元数据 (DocInfo/CustomDatas):` 这一条 bullet 里，**从来不是** `## 内嵌结构化数据` 段 | 数电票常见 | 实测只有 6 个字段：发票号码、买卖方税号、合计金额、合计税额、开票日期 | **没有价税合计，也没有买卖方名称** |

三者可能同时出现、可能只有一两个、也可能一个都没有（那就只剩逐页文本）。

**键名是点号路径。** 展平器剥掉命名空间前缀、丢掉根元素、用 `.` 连接层级、同名兄弟加 `[序号]`。真实的键长这样：

```
InvoiceNo = 24112000000000010001
Buyer.BuyerName = 示例数字科技（北京）有限公司
Seller.SellerTaxID = 91110105MA00P8L2RU
TaxInclusiveTotalAmount = ¥4449.00
GoodsInfos.GoodsInfo[1].Item = 餐费
```

`Buyer/BuyerName`、`GoodsInfos/GoodsInfo[]` 这类斜杠写法**在输出里一个都不存在**，别按它匹配。来源 ① 与 ② 走同一套展平，键名同构，所以下表两种来源通用：

| 键 | 记录字段 | 哪些来源有 |
| --- | --- | --- |
| `InvoiceNo` | `invoiceNumber` | ①② |
| `InvoiceCode` | `invoiceCode` | **只有 ①**（数电票本就没有发票代码，为空是正常的） |
| `IssueDate` | `invoiceDate`（原文 `2024年05月28日` 中文格式，转成 `YYYY-MM-DD`） | ①② |
| `InvoiceCheckCode` | `checkCode` | 只有 ① |
| `Buyer.BuyerName` · `Buyer.BuyerTaxID` | `buyerName` · `buyerTaxId` | ①② |
| `Seller.SellerName` · `Seller.SellerTaxID` | `sellerName` · `sellerTaxId` | ①② |
| `TaxInclusiveTotalAmount` | `totalAmount`（价税合计） | ①② |
| `TaxExclusiveTotalAmount` | `amountExcludingTax` | ①② |
| `TaxTotalAmount` | `taxAmount` | ①② |
| `GoodsInfos.GoodsInfo[n].{Item,Specification,MeasurementDimension,Price,Quantity,Amount,TaxScheme,TaxAmount}` | `items[]` | **只有 ①**；② 只有零散的 `Item` / `Price` / `Quantity`，凑不出逐行结构 |

#### 三条硬规则，每条都对应一种会把钱算错的写法

1. **`totalAmount` 只认 `TaxInclusiveTotalAmount`。** 标引式（来源 ②）的金额常带 `¥` 前缀——票面上「¥」和「4449.00」是两个页面文字对象，解引用后被按顺序拼成 `¥4449.00`。写进记录前把货币符号与千分位逗号剥掉，只留纯数字。

2. **绝不把 `DocInfo/CustomDatas` 的「合计金额」当 `totalAmount`。** 那是**不含税金额**。实测一张 2024 数电票：CustomDatas 写 `合计金额: 4197.17`、`合计税额: 251.83`，而同一张票的**价税合计是 ¥4449.00**——照抄就是每张票少记一个税额，还带着高置信度混进合计。CustomDatas 里压根没有价税合计这一项。正确对应：`合计金额` → `amountExcludingTax`，`合计税额` → `taxAmount`；`totalAmount` 去标引段取 `TaxInclusiveTotalAmount`，或回页文本取 `价税合计（大写）… （小写）¥xxxx`。两个都拿不到就按必填字段缺失隔离，不要拿合计金额顶替。

3. **只有来源 ① 配得上「别再从版面文字里猜字段」。** 来源 ② 拿得到四个必填字段，所以票不会被隔离——它的代价是**静默残缺**：缺的发票代码与逐行明细都是选填字段，于是 `invoiceCode` 永远为空、`items[]` 永远是空数组，而台账上看不出任何异常，用户也不会收到任何提示。来源 ③ 更彻底，连价税合计和买卖方名称都没有；标引链路断掉（`Read` 会在「解析诊断」里报出来）而只剩它时，必填字段是真的抽不齐。**② 与 ③ 都必须继续读逐页文本把缺的字段补上**，别在结构化段落上就收工。

#### 置信度按来源分档

| 来源 | `extractedBy` | 基线 | 理由 |
| --- | --- | --- | --- |
| ① 内嵌附件 | `ofd-xml` | **1.0** | 值内联在开票系统写出的国标 XML 里，不是 OCR，也不是版面猜测 |
| ② 发票标引 | `ofd-xml` | **0.9** | 字段归属可信，但**值是页面文字对象解引用来的**，与版面文本同源 |
| ③ CustomDatas | `text-layer` | **0.85**，且必须与页文本交叉核对 | 只有 6 个字段，且「合计金额」的语义极易读错（见上面第 2 条） |
| 三个来源都没有 | `text-layer` | 0.85 起 | 走下面的版面文本规则 |

来源 ② 的明细尤其不可盲信：实测 `单位 / 数量 / 单价` 三列会被挤成一格（`1 4197.16981132075`），且长词会断到下一行。窄列的值必须与页文本对过才写进 `items[]`；对不上就留空并降置信度，不要把挤在一起的串硬拆。合计三项（不含税金额 / 税额 / 价税合计）在实测里是可信的。

#### 三个模式参数

- 某个值在展平时被截断（回执提示「字段数超过上限」）→ `ofd_mode:"attachments"` 拿原文补那一个字段，不要凭截断值入账。该模式对来源 ② 同样给出解引用后的取值与标引原文
- 结构化数据太大把页文本挤没了 → `ofd_mode:"text"` 只看版面文字（注意该模式**不解析标引**，来源 ② 会一起消失）
- 只想读某几页 → `pages`，语法与 PDF 相同

`Signature`（base64 签章）和 `TaxControlCode`（密码区乱码）没有语义，**不要**为了拿它们去调 `attachments` 模式，也不要往上下文里倒——记一句「含签名，N 字节」就够了。

OFD **不会**被渲染成图片，也不做签章有效性校验（只报告是否含签章）。所以纯图形的 OFD 没有视觉兜底：三个结构化来源全缺、页文本又为空时直接隔离，写清「OFD 无结构化数据且无可读页文本」。

**回落到版面文本时，OFD 的页文本比 PDF 好用**：模板层的固定标签已经与页内的值合并到同一行，`标签：值` 成对出现，表格列用 Tab 分隔——不会出现 PDF 那种竖排字段名被逐字拆行的形态。下面那套「忽略单字行」的规则是给 PDF 的，读 OFD 页文本时不必套用。

### PDF / 版面文本：抽取规则

具体到每种票的文本形态，读 `${SKILL_DIR}/references/票面文本形态.md`。那份文件是按真实票面版式构造的夹具在 `Read` 下的实测输出，不是示意图（票据本身是合成的，字段值为虚构值）。开工前**先读它**，尤其在遇到不认识的票种时。

跨票种通用的六条：

1. **先归一化空白再匹配。** 票面字段名里有全角排版留下的空格（`名 称：`、`校 验 码：`），把连续空白折叠成一个空格再匹配，别把空格写死进模式。
2. **忽略单字行。** 竖排字段（`购/买/方/信/息`、`销/售/方/信/息`、`备/注`）会被逐字拆成单行。这是正常的，整段跳过。
3. **买卖方靠顺序，不靠标签——但顺序必须自校验。** 数电票里两方的字段名一模一样，**先出现的是购买方，后出现的是销售方**（旧版票是购买方在上、销售方在下，中间隔着密码区）。这条规则依赖开票系统的版面布局，不是国标保证的：一旦某家系统的排版相反，每张票的买卖方都会静默对调，而勾稽校验一条都不会报错。所以**取到两方之后必须回头验一次**：preflight 已经问过用户的报销主体抬头，如果「后出现的一方」等于报销主体、而「先出现的一方」不是，说明这份票的顺序是反的——按抬头把两方归位，并在收尾里单独报告「检测到该开票系统买卖方顺序与常规相反」。两方都不等于报销主体时（代开、个人抬头、集团内其他主体）不动顺序，按现有的「抬头不符」流程标出来交用户判断。
4. **丢掉密码区。** 旧版票 `密码区` 之后的 4 行是乱码，进上下文只会干扰。
5. **金额取小写。** `价税合计（大写） 壹仟玖佰伍拾玖元玖角捌分 （小写）¥1959.98` —— 取 `（小写）` 后面的数字。大写金额用来做校验，不用来当值。
6. **税率不一定是百分比。** `免税` / `不征税` / `***` 都会出现，原样记录，不要强行转成 0。

### 名称字段的标签污染防护（PDF 文本层）

PDF 的文本层顺序由生成它的开票系统决定，不保证等于版面阅读顺序。部分模板会把「两栏的全部标签」连续输出完，再输出「两栏的全部取值」（`名称：` `统一社会信用代码/纳税人识别号：` 各连续出现两次，之后才是两个公司名和两个税号）——这是一次真实批量整理里实测踩到的坑：某网约车平台开具的两张「旅客运输服务」数电票，`sellerName` 被按"标签后紧跟的下一段文字"这个假设错误抽成了字面的「统一社会信用代码/纳税人识别号：」。**这条只针对 PDF 文本层**——OFD 的页面文本按坐标重建阅读顺序，模板标签与取值已经合并在同一行（见上一节），不会出现这种错位；结构化来源①②③同样不受影响，遇到 PDF 判断不可靠时优先切到 OFD 的结构化字段或视觉识别，而不是死磕文本层位置匹配。

对 PDF 文本层抽出的 `sellerName` / `buyerName` 做以下硬校验：

- 若名称等于或主要由 `统一社会信用代码`、`纳税人识别号`、`名称`、`名称：`、`销售方信息`、`购买方信息` 等字段标签组成，必须视为**提取失败**，不得以低置信度直接采信。
- 先尝试从同页其他文本块重新定位（常见于双栏模板整段错位，两个值仍在文本里，只是顺序对不上）；仍不能可靠确定时，改用整页视觉阅读（`pdf_mode:"render"`）。
- 视觉阅读仍不能确定时，按「抽不齐必填字段就隔离」处理——名称是必填字段，抽不出真实值就不是「低置信度」，是缺失：整份文件移入 `_quarantine/`，`.reason.txt` 写清「PDF 文本层名称字段疑似标签污染，视觉复核仍无法判定」；不得从邮件主题、文件名或相邻税号猜测公司名，也不要拿标签本身或空值凑一条低置信度记录混进台账。

### ⚠️ 文本里会有 NUL 字节

PDF 文本抽取的结果里常出现 `\x00`（未映射字形），位置多在 `价税合计（大写）` 与中文大写之间。

后果：这样的文件 `file` 会判成 `data`，**裸 `grep` 会当二进制处理并静默返回空结果**——你会以为「没找到关键词」，其实是根本没搜。

对策：

- 检索发票文本一律用 `grep -a`，或者干脆用 `Read` 读回来自己判断
- 不要依赖 shell 的 locale（很多环境里 `LANG` / `LC_ALL` 是空的，`grep 中文` 同样会静默失效）
- 写进记录之前把 `\x00` 与其它控制字符剥掉

### 扫描件与图片

`Read` 遇到**没有文字层**的 PDF 页会自动渲染成图交给你看，不需要额外参数。

- **有文字层但抽出来是垃圾的扫描件**（劣质 OCR 层，现实中很常见：乱码、字序错乱、只有零星几个字）不会触发自动渲染——`Read` 认为这页有文字，就只给你那堆垃圾。判据不是「有没有文字层」，而是**「抽出来的内容够不够判定」**：文字层存在但拼不出发票号码或价税合计时，显式 `pdf_mode:"render"` 加 `pages` 重读一次，走视觉路径，`extractedBy` 记 `vision`。
- **想看有文字层那一页上的印章 / 配图 / 版式**：同样要显式 `pdf_mode:"render"` 加明确的 `pages`（如 `"1"`、`"1,3-5"`），否则默认只抽文字，看不到图形。
- **多页扫描件**：不带 `pages` 连续调用即可，`Read` 会自动接着上次的位置读；每读完一批用一两句话记下关键数字，图会被自动退场只留占位。
- 视觉识别出的字段一律 `confidence ≤ 0.85`；金额、发票号码这两个字段视觉识别的错误代价最高，逐字复核一遍再写。
- 图片模糊、倾斜、只有半张票 → 不要硬猜，隔离并在 `.reason.txt` 里写清「图片质量不足以识别 <哪几个字段>」。

### 置信度评分

从路径基线出发，逐项扣分：

| 情况 | 调整 |
| --- | --- |
| OFD 内嵌附件（来源 ①） | 1.0，不再扣分 |
| OFD 发票标引（来源 ②） | 0.9 起；标引没覆盖到、靠页文本补的字段按页文本口径扣分 |
| OFD `DocInfo/CustomDatas`（来源 ③） | 0.85 起；与页文本核对不上的字段 −0.3 并标 `checkFailed` |
| `金额 + 税额 == 价税合计`（差 ≤ 0.01） | +0.05（上限 1.0） |
| 勾稽对不上 | −0.3，并在记录里标 `checkFailed` |
| 大写金额与小写金额不一致 | −0.3 |
| 必填字段靠视觉识别得到 | 每项 −0.05 |
| 销售方名称被截断或含明显乱码 | −0.2 |
| 开票日期不在用户给定的时间范围内 | 不扣分，但要在收尾里单独列出来 |

**低于 0.8 的字段在台账里标「待复核」**，并进收尾清单。整条记录的 `confidence` 取所有必填字段里的最小值。

### 特殊票据

| 票种 | 关键差异 |
| --- | --- |
| 铁路电子客票（新版） | 走数电票版式，正常处理 |
| 铁路电子客票报销凭证（旧版） | 没有 `发票号码：` 标签；抬头下的 21 位电子客票号当 `invoiceNumber`，`confidence` 上限 0.9 |
| 航空运输电子客票行程单 | 没有发票号码，用 `电子客票号码`；`invoiceDate` 取**填开日期**不是航班日期；`印刷序号` 进 `checkCode` |
| 出租车 / 网约车 | 网约车通常是标准数电票，正常处理；车牌与里程在备注里。**卷式与机打的出租车票、通行费票、部分定额票票面上只写「金额」「合计」，从不出现「价税合计」四个字**——按 `invoice-workflow` 的判定阶梯第 1 条，合计项认「价税合计 / 合计金额 /（金额+税额）」中的任意一种，别因为找不到「价税合计」就把它判成非发票 |
| 定额发票 | 只有代码 + 号码 + 面额，没有明细也没有税额；走兜底主键 |
| 作废票 | 文本里出现 `作废` / `已作废` → `isVoid: true`。**文本里没有不代表没作废**，作废戳可能是纯图形 |
| 免税 / 不征税 | `taxRate` 原样记 `免税`；`taxAmount == 0` 且 `amountExcludingTax == totalAmount` 是正常的 |
| 外币结算 | 票面仍是人民币，原币与汇率写在备注里。照抄备注，**不做换算**，`currency` 保持票面币种（也就是 `CNY`）——所以台账里不会出现非 CNY 的记录，外币信息只存在于备注 |

### 解析失败的处理

失败不是异常，是常态的一部分。四种失败态各自的处置：

| 失败态 | 现象 | 处置 |
| --- | --- | --- |
| 加密带口令 | `Read` 返回 `PDF 文件无法解析: <底层报错>` + 换行 + `可能原因: 文件损坏、加密或非标准格式`（半角冒号，中间夹着底层报错原文）。**加密没有被单独识别，这是一条通用兜底**——同一句话也会用于损坏和非标准格式，别只凭它就断定是加密 | 隔离。若底层报错里出现 `password` 之类字样，在收尾里告诉用户这张票带口令，口令通常写在**邮件正文**里（常见为发票号码后 6 位或手机号后 6 位），请他自己解密后重新放进 `_inbox/`。**不要**尝试猜口令 |
| 文件损坏 / 空文件 | 同一句兜底报错 | 隔离，`.reason.txt` 写「文件损坏或为空，字节数 N」，并把底层报错原文一并抄进去（那是唯一能区分这三种失败的线索） |
| 能读但内容为空 | 抽出来只有几个字符 | 隔离，写「文本层为空，可能是纯图形 PDF 但渲染也未产出可读内容」 |
| 判定为非发票 | 命中负向关键词或缺关键标签 | 隔离，写清是哪一条判据命中的 |

隔离 = 把文件移到 `_quarantine/`，写一个同名 `.reason.txt`，然后**继续处理下一份**。一份失败不能中断整批。

## L2

### 形式校验清单

抽完字段后逐条走一遍，任何一条不过都要在记录里留痕：

- `金额 + 税额 = 价税合计`（浮点比较留 0.01 容差）
- 大写金额与小写金额一致（大写解析可以只做数量级校验，不必逐字）
- 明细行 `数量 × 单价 = 金额`（多行时求和）
- 数电票发票号码 20 位纯数字；旧版发票代码 12 位、号码 8 位
- 开票日期是合法日期且不在未来
- 税号 18 位（老式 15 位纳税人识别号也合法，不要判错）

校验只降置信度、只留标记，**不修改抽出来的值**。票面本身写错的情况真实存在，改数据比留标记危险得多。

### 批量解析的顺序

一批文件的处理顺序：先 `FileDigest` 一次性算完所有哈希（一次最多 100 个路径），去掉命中缓存的，再逐个解析。

先算哈希的理由：重复下载在发票场景里非常常见（同一封邮件转发多次、用户手工又存了一份），先去重能省掉大部分解析开销。

### 什么时候值得回看原图

台账做完后用户质疑某个数字时，用记录里的 `archivedPath` 加 `pdf_mode:"render"` 和该页页码重读一次，肉眼核对再答。不要凭 `.index/raw/<sha256>.json` 里的缓存回答「我当时读到的是这个」——用户问的是票面写的是什么。
