跳转至

05 - 百度 OCR 字段映射

接口:百度智能云「增值税发票识别」(vat_invoice),支持普票/专票/全电发票/卷票/区块链发票全字段结构化识别。本文档定义 OCR JSON → invoice 表 → StandardInvoice 的完整映射。

官方文档:增值税发票识别。多票种混拍场景可后续升级「智能财务票据识别」(vat_invoice 为其子类型 type,字段结构一致)。

1. 字段映射总表

OCR 结果位于 words_result 对象内(金额/日期均为字符串,需清洗)。

1.1 票面标识

OCR 字段 invoice 字段 类型 说明
InvoiceNum invoice_num TEXT NOT NULL 发票号码(业务防重键之一)
InvoiceCode invoice_code TEXT 发票代码,全电票为空
InvoiceType invoice_type TEXT 普通发票/专用发票/电子普通发票/电子专用发票/通行费电子普票/区块链发票/通用机打电子发票/电子发票(专用发票)/电子发票(普通发票)
InvoiceTypeOrg invoice_type_org TEXT 发票名称,如"浙江增值税专用发票"
ServiceType service_type TEXT 百度现成的消费类型:餐饮/电器设备/通讯/服务/日用品食品/医疗/交通/其他 → 分类器链兜底环(直映 + 别名归一到科目,规则未命中时生效)
InvoiceTag invoice_tag TEXT 左上角标志:通行费/销项负数/代开/收购/成品油/其他
InvoiceDate invoice_date TEXT "2016年06月02日" → 清洗为 YYYY-MM-DD
CheckCode check_code TEXT 校验码(专票无)
InvoiceNumDigit invoice_num_digit TEXT 数电票号码(密码区,纸质数电票才有)

忽略:InvoiceCodeConfirm/InvoiceNumConfirm(辅助校验码)、Password(密码区)、SheetNum(联次)、Agent、OnlinePay。

1.2 购销双方

OCR 字段 invoice 字段 说明
SellerName seller_name 销售方名称(重命名用"滴滴出行"式短名可再截断)
SellerRegisterNum seller_register_num 销售方纳税人识别号
SellerAddress seller_address 销售方地址电话
SellerBank seller_bank 销售方开户行及账号
PurchaserName purchaser_name 购买方名称(可校验"抬头是否本公司")
PurchaserRegisterNum purchaser_register_num 购买方识别号

忽略:PurchaserAddress/PurchaserBank/Province/City(报销场景无用,原始 JSON 里都有)。

1.3 金额(全部 ×100 转分存储)

OCR 字段 invoice 字段 说明
AmountInFiguers amount_fen 价税合计(小写),如 "100000.00" → 10000000
TotalAmount total_amount_fen 合计金额(不含税)——公司模板要求价税分离时用
TotalTax total_tax_fen 合计税额
AmountInWords amount_in_words 价税合计(大写),对账校验用(大写与小写应一致,不一致标记疑似识别错误)

1.4 明细行(array[{row, word}],按 row 对齐)

OCR 字段 去向 说明
CommodityName items_raw(按 row 排序拼接,; 分隔)+ commodity_count 规则引擎主要输入,如 *信息技术服务*软件服务费
CommodityAmount/CommodityTax/CommodityTaxRate/CommodityNum/CommodityUnit/CommodityPrice 仅存 ocr_raw_json 需要行结构时经 core/goods.py::parse_goods_lines 重建(见下),不另建表

物品行重建(core/goods.py::parse_goods_lines(ocr_raw_json) -> list[GoodsLine]):UI 明细展开与后续 Excel 明细 sheet 消费的展示层函数——以 CommodityName 的 row 为主序,从各 Commodity 平行数组按 row 对齐拼成 GoodsLine(row, name, num, unit, price, amount, tax_rate);金额字段保留 OCR 原始字符串透传(仅展示,不参与运算,红线 1)。raw json 是唯一数据源,落库后任何字段调整免重放重付费。

2. 清洗规则

from decimal import Decimal, InvalidOperation
from datetime import date


def to_fen(s: str | None) -> int:
    """'100000.00' → 10000000;空/脏值 → 0 并打低置信标记"""
    if not s:
        return 0
    try:
        return int(Decimal(s.strip().replace("¥", "").replace(",", "")) * 100)
    except InvalidOperation:
        return 0


def to_date(s: str | None) -> str | None:
    """'2016年06月02日' → '2016-06-02'(ISO)"""
    if not s:
        return None
    for fmt in ("%Y年%m月%d日", "%Y-%m-%d", "%Y/%m/%d"):
        try:
            return datetime.strptime(s, fmt).date().isoformat()
        except ValueError:
            continue
    return None


def join_items(arr: list[dict]) -> str:
    """CommodityName 数组按 row 排序拼接:'*餐饮*餐费;*服务*会议费'"""
    return ";".join(x["word"] for x in sorted(arr or [], key=lambda x: int(x.get("row", 0))))

一致性校验:入库前核对 amount_fen == total_amount_fen + total_tax_fen、大写金额(中文数字解析)与小写一致;不一致时正常入库但置 is_manual_edited 待复核标记,UI 高亮提示。

3. StandardInvoice 领域模型

Provider 产出、规则引擎消费、落库前的统一中间表示:

from dataclasses import dataclass, field


@dataclass
class StandardInvoice:
    # 防重与溯源
    file_hash: str = ""
    ocr_raw_json: str = ""  # 完整原始返回
    source_path: str = ""

    # 票面标识
    invoice_num: str = ""
    invoice_code: str = ""  # 全电票为空
    invoice_type: str = ""
    service_type: str = ""  # 百度消费类型,分类器链兜底环(直映 + 别名归一)
    invoice_date: str | None = None  # ISO YYYY-MM-DD

    # 购销双方
    seller_name: str = ""
    seller_register_num: str = ""
    purchaser_name: str = ""

    # 金额(分)
    amount_fen: int = 0  # 价税合计
    total_amount_fen: int = 0  # 不含税
    total_tax_fen: int = 0  # 税额

    # 明细
    items_raw: str = ""
    commodity_count: int = 0

    # 规则引擎回填
    category: str = "未分类"

    # 一致性校验结果
    confidence_flags: list[str] = field(default_factory=list)  # 如 ['amount_mismatch']

4. Provider 接口

class InvoiceProvider(ABC):
    """发票解析统一接口——百度实现为 BaiduProvider,未来接其他厂商实现同类"""

    @abstractmethod
    def parse(self, file_path: str) -> StandardInvoice: ...

BaiduProvider 职责:读文件 → access_token 鉴权(缓存至过期)→ POST vat_invoice → 限流退避(429/QPS 超限)→ 按本文档 §1~§2 映射清洗 → 产出 StandardInvoice。字段新增/调整只改 Provider 映射与 ocr_raw_json 重放,不动业务层、不重新付费。

TS 实现落在 packages/providers(StandardInvoice 24 字段与上文一一对应;本文 Python 片段为设计原型)。错误契约:Provider 以异常类型表达失败语义——额度耗尽(百度 17/19)→ QuotaExceededError、QPS 限流(18/429)→ RateLimitedError、票据不匹配(282103)→ NotAnInvoiceError、key 无效 → ProviderAuthError;worker 消费侧按类型映射错误码与退避策略(见 03-architecture.md §5.2)。

4.1 百度 key 号池

单个免费 key 的日/月额度与 QPS(2) 都可能成为瓶颈,BaiduProvider 内置多 key 号池做负载均衡(InvoiceProvider 接口与 worker 消费侧零改动):

  • 配置:BAIDU_OCR_API_KEYS / BAIDU_OCR_SECRET_KEYS 逗号分隔、按位置配对;只配单数 BAIDU_OCR_API_KEY/SECRET_KEY 时退化为单 key 池(向后兼容)。两列表长度不一致视为配置错误(ProviderAuthError 终态),不静默丢弃。
  • 调度:round-robin 轮询;每个凭据独立 token 缓存。池大小 N 时理论 QPS = 2N。
  • 熔断(错误码驱动,进程内存态,重启无害):17 日额度 → 冷却至次日 00:01(本地时区);19 月额度 → 冷却至下月 1 日 00:01;18/HTTP 429 → 60s;key 无效 → 10min(防每请求空试坏 key)。
  • 换 key 重试:凭据侧错误(额度/限流/鉴权)自动换池内下一个未冷却 key,单次 parse 至多试完整池;内容性问题(282103)不换 key——换 key 结果相同,白烧额度。
  • 全池耗尽聚合:含限流 → RateLimitedError(可重试优先,稍后有 key 冷却结束恢复);否则含额度耗尽 → QuotaExceededError(终态);全为鉴权失败 → ProviderAuthError(终态)。
  • 安全(红线 4):日志与错误消息只出现 baidu-key#N 序号,key 值不进任何错误文本。
  • 用户自备 key(docs/03 §5.7):用户在设置页填写的百度 Key 经信封加密落库(ocr_credential),worker 以服务角色解密后叠加到环境号池之后,标号 baidu-key#user:<userId>。号池 = 环境 key + 全部启用的用户 key,round-robin 且对所有用户共享(填写者的额度也供他人使用)。

5. Excel 导出取数(价税分离模板)

报销单列 取数
日期 invoice.invoice_date
金额(不含税) total_amount_fen / 100
税额 total_tax_fen / 100
价税合计 amount_fen / 100
发票号码 invoice_num(+ invoice_code 非空时)
附件 attachment 表按 invoice_id 汇总文件名清单

6. 附件子管道:通用文字识别(general_basic)

订单截图、行程单等非发票文件由 vat_invoice 拒识(282103 模板不匹配)后转此接口(管道见 03-architecture.md §3.6)。与 vat_invoice 免费额度独立(各 1000 次/月、QPS=2),鉴权 token 缓存与 QPS 退避复用 BaiduProvider。个人免费层:每个用户每月 10 次(按上传文件计),耗尽且未自备 key 时上传被拒(QUOTA_EXCEEDED_OCR)。

  • 接口:POST /rest/2.0/ocr/v1/general_basic,参数 image(图片 base64)或 pdf_file(PDF base64,仅识别首页即够匹配信号)。
  • 返回:{words_result: [{words: "…"}], words_result_num, log_id},按行拼接为 attachment.ocr_raw_text 全文留存(重匹配不再付费)。

6.1 匹配信号提取(全文 → 结构化信号)

信号 提取规则 用途
金额 正则 (实付|实付款|合计|总计|订单金额)[^0-9]{0,6}([0-9,]+\.?[0-9]{0,2});多候选优先"实付",否则优先取带小数点形态的金额(规避 OCR 吃掉小数点造成的 ×100 假值),全为整数时取最大值;带 ¥/¥ 前缀的金额一并收集。小数形态"X.XX元"后缀值一律并入锚点池:OCR 吃掉小数点时关键词锚点会 ×100(真机样例:高德行程单"合计1863元"实为 18.63,真值"18.63元"在表格行中兜回锚点);要求带小数点以避开"1个行程"类整数噪声。转分存储。提取前先剔除折扣段:(共减|优惠|立减|红包|折扣)[¥¥]?金额(真机样例:淘宝订单"实付款共减¥2.86"是优惠额,实付额在下一行"¥19.14") 必要锚点:与 invoice.amount_fen 精确相等才进候选集
金额(文件名) 原文件名单独一遍宽松金额提取(只对文件名生效,不改全文严格规则):被非数字边界包住的两位小数,兼容裸小数 20251207_94.81_行程单.pdf 与 X.XX元 【妥妥E行-76.91元-1个行程】…。文件名金额并入锚点池,并优先作为主锚点(平台导出名比 OCR 可靠)。真机样例:OCR 把"合计76.91元"识成整数 7691(假锚点 769100),真值只有文件名保留 必要锚点(补 OCR 丢小数时的真值)
商户 全文与候选发票 seller_name 双向子串匹配(剥离"有限公司/个体工商户"等后缀干扰) 消歧 +3
商户(容错) 卖方 3-gram 与全文 3-gram 编辑距离 ≤1 命中 ≥2 个 → 弱命中(真机样例:高德行程单"鞍马出行"被 OCR 成"酸马出行");双 gram 门槛避开"电子商务"类通用词误报 消歧 +2
日期 \d{4}[年/-]\d{1,2}[月/-]\d{1,2},与 invoice_date 差 ≤3 天 消歧 +2
日期(容错) OCR 年份识别错(2026→2028)但月日相同且年差 ≤4 消歧 +1
商品词 候选发票 items_raw 品名(去 *分类* 前缀)在全文中的命中数 ≥1 消歧 +2
文件名(消歧) 附件原名与候选发票源文件名去扩展名及平台模板词(发票/行程单/订单/截图/高德打车/AMAP 等)后,核心实体片段(含商户与金额)互相包含,且两侧长度均 ≥5 消歧 +3

金额提取失败 → 直接 unmatched(无锚点不猜)。正则集中在 core/matcher.ts 信号提取区,与 providers/ 的日期/金额清洗同一红线:解析失败不抛错、只降级为信号缺失。

行程单等票据还可经结构化财务票据端点(如 online_taxi_itinerary)直接得到城市/起终点/每段金额,作为上述全文 + 正则的补充与增强(金额一并进锚点池,城市/起终点用于命名与规则)。统一文档模型、端点注册表与票种归类见 12-receipt-ocr.md。

附件类型识别(classify_attachment,落 attachment.attachment_type):关键词命中行程单(行程单/ITINERARY/行程时间/行程人/网约车/打车)→ itinerary;订单(订单号/订单详情/下单时间/订单金额/物流详情)→ order_screenshot;票面(增值税/发票代码/发票号码/机器编号/开票日期)→ proof;全不命中落 other 不猜。优先级行程单 > 订单 > 票面(高德行程单含"开6%增值税专票"推广语,须先判行程单)。