跳转至

12 - 财务票据 OCR:端点全景、统一文档模型与接入架构

本文把百度「财务票据文字识别」系列接口纳入本项目:先盘点端点,再给出能容纳各类附件(行程单、火车票、机票、出租车票、定额/机打发票、购物小票、银行回单…)的统一数据模型 RecognizedDocument,最后给出 provider 与 worker 管道的接入架构。字段映射的时代背景见 05-ocr-fields.md(增值税发票)与 03-architecture.md §7(附件子管道)。

1. 背景与问题

现状:vat_invoice 解析增值税发票;非发票走 general_basic 取全文,再由 core/matcher 正则提金额/日期/商户做附件匹配。问题有三:

  1. 无结构:行程单的城市/起点/终点、火车票/机票的始发到达、出租车里程等,general_basic 只给拉平文本且无坐标,靠正则不可靠(真机样例杭州被识成“抗牺市”)。
  2. 覆盖窄:火车票、机票、汽车票、过路费、购物小票、银行回单等目前只能当纯文本,白白丢掉结构化字段。
  3. 混贴无法处理:一张图里有多张不同票据时,现有管道无法拆分。

百度财务票据系列提供了结构化识别(部分含混贴分类),可以一次性解决。

2. 百度端点全景(财务票据文字识别)

2.1 端点清单

端点 URL(/rest/2.0/ocr/v1/) 覆盖 结构化 备注
智能财务票据识别 multiple_invoice 13 类票据分类 + 结构化 + 混贴 ✅ 首选入口(见 §2.2)
增值税发票识别 vat_invoice 增值税专/普/电子/卷票/区块链 ✅ 现状,保留为主票路径
增值税发票验真 vat_invoice_verification 12 类增值税票验真 ✅ 后续(verify)
通用机打发票 invoice 横/竖版机打发票 23 字段 ✅
定额发票 quota_invoice 代码/号码/金额/省市 7 字段 ✅
火车票 train_ticket 车票号/始发/到达/车次/席别… ✅ 含铁路电子客票
出租车票 taxi_receipt 车牌/上下车/金额/省市/里程 ✅
飞机行程单 air_ticket 24 字段,支持多航班 ✅
网约车行程单 online_taxi_itinerary 服务商 + Items[](城市/起终点/金额…) ✅ 滴滴/高德/曹操…
购物小票 shopping_receipt 店名/金额/明细 ✅
银行回单 bank_receipt_new 收付款人/账号/金额…14 字段 ✅ 键名为中文
汽车票 bus_ticket 出发/到达/金额/身份证 ✅ 历史接口,现多由 multiple_invoice 覆盖
过路过桥费 toll_invoice 入口/出口/金额/省市 ✅ 同上
船票 ferry_ticket 出发/到达/金额 ✅ 同上
机动车/二手车销售发票 vehicle_invoice 等 — ✅ 同上

计费:这些接口属百度「财务票据文字识别」资源包,与现有 vat_invoice、general_basic(通用场景)额度/计费各自独立。接入前需在控制台确认免费额度并加配额熔断。

2.2 为什么选 multiple_invoice 作入口

  • 一次调用完成分类 + 结构化,覆盖 13 类,且支持单图混贴多张,返回每张的 type + 字段 + 位置;
  • 其网约车/火车票/机票/出租车/汽车票/过路费的结果字段与各专用端点一致(见下),等于“分类器 + 多类型抽取器”;
  • 可选 probability / location / verify_parameter(一次拿到验真六要素)。

局限:购物小票、POS 小票、限额发票、「其他」仅分类不抽取——这几类按需再调专用端点(如 shopping_receipt)或退回 general_basic。

2.3 关键返回字段(建模依据)

  • 网约车行程单(online_taxi_itinerary / multiple_invoice.type=taxi_online_ticket): ServiceProvider、StartTime、EndTime、Phone、ApplicationDate、TotalFare;Items[]:ItemId/PickupDate/PickupTime/CarType/Distance/StartPlace/DestinationPlace/City/Fare。
  • 火车票(train_ticket / type=train_ticket): ticket_num、starting_station、train_num、destination_station、date、invoice_item[]、ticket_rates[]、seat_category、name、id_num、sales_station、time、seat_num;电子客票追加 invoice_num/invoice_date/fare/tax_rate/tax/elec_ticket_num/purchaser_name[]。
  • 出租车票(taxi_receipt / type=taxi_receipt): InvoiceCode、InvoiceNum、TaxiNum、Date、PickupTime、DropoffTime、Fare、FuelOilSurcharge、CallServiceSurcharge、TotalFare、Location、Province、City、PricePerkm、Distance。
  • 飞机行程单(air_ticket / type=air_ticket): name、starting_station、destination_station、flight、date、time、ticket_number、fare、dev_fund、fuel_surcharge/oil_money、other_tax、ticket_rates、issued_date/start_date、carrier、class、serial_number;multi_detect=true 时多航班。
  • 机打发票(invoice):InvoiceType、InvoiceCode、InvoiceNum、InvoiceDate、AmountInFiguers、AmountInWords、CommodityName/Unit/Price/Num/Amount[]、SellerName、PurchaserName、TotalTax、Province、City。
  • 定额发票(quota_invoice):invoice_code、invoice_number、invoice_rate、invoice_rate_lowercase、location、province、city。
  • 购物小票(shopping_receipt):shop_name、receipt_num、consumption_date/time、total_amount、paid_amount、table[](商品/数量/单价/小计)。
  • 银行回单(bank_receipt_new):中文键——标题、付款人户名/开户行/账号、收款人户名/开户行/账号、大写金额、小写金额、流水号、回单编号、交易日期、摘要、用途。
  • 汽车票/过路费/船票:ExitStation/ArrivalStation/Entrance/Exit、Date/Time、Amount/TotalAmount、Province/City 等。

2.4 发票票种也是不同结构(invoice_type)

同一套 vat_invoice(及 multiple_invoice)会按票种返回不同的字段子集。百度验真接口给出了权威的票种码 invoice_type,直接作为 InvoiceKind(见 §3.2),各票种专属字段:

invoice_type 票种 相对普票的专属字段
special_vat / elec_special_vat / elec_invoice_special 专票 / 电子专票 / 全电专票 购销双方地址、开户行及账号
freight_special 货运运输业增值税专用发票 上述 + 运输工具种类、车牌、起运/到达地、货物信息
normal_vat / elec_normal_vat / elec_invoice_normal 普票 / 电子普票 / 全电普票 校验码;全电票另有数电票号码(密码区)
roll_normal_vat 增值税普通发票(卷式) 卷号
toll_elec_normal 通行费增值税电子普票 车牌号、入口、出口、路段
motor_vehicle_sale 机动车销售统一发票(含电子) 车辆类型、厂牌型号、车架号/VIN、发动机号、合格证号、产地、吨位、限乘人数、购方证件号、主管税务机关
used_vehicle_sale 二手车销售统一发票(含电子) 车辆类型、车牌号、二手车市场、转出方、转入方
blockchain 区块链发票 链上标识
printed_elec 通用机打电子发票 机器编号、省/市
railway_elec 电子发票(铁路电子客票) 乘客姓名/证件、车次、始发/到达站、乘车日期、席别、座位号、电子客票号
air_elec_itinerary 电子发票(航空电子客票行程单) 乘客/证件、承运人、电子客票号、多航段

因此发票建模为两层判别:kind='vat_invoice'(文档大类)+ invoiceKind(票种)。防重键仍只用 invoiceNum/invoiceCode,与票种无关。

3. 统一数据模型 RecognizedDocument

3.1 设计原则

  1. 每种票据一个独立强类型结构:RecognizedDocument 是按 kind 判别的联合;发票再按 invoiceKind 二次判别(专票/普票/卷票/全电/通行费/货运/机动车/铁路/航空…)。类型专属字段只出现在各自接口,绝不塞进 Record<string, unknown> / extra。
  2. 公共外壳最小化:DocumentBase 只保留所有类型确实都有的 sourceType / raw / confidenceFlags;跨类型的共性(号码/日期/金额/城市/开票方)通过 DocumentSummary 投影,供存储、匹配、列表等通用逻辑消费。
  3. 明细一等公民:行程多段、机票多航班、机打/小票多商品,统一为具名数组(trips / flights / items / lines),各自带起终点、城市、金额;不搞万能 items[]。
  4. 金额一律分(红线 1),经字符串 yuanToFen 转换,绝不引入 float。
  5. 完整原始返回随文档保存(红线 5):raw 存该端点/该票种的完整 words_result,重放/重匹配不再付费。
  6. 未知不猜:kind='other' 只留 type 与位置,不结构化成“像”的字段。

3.2 类型定义(建议落 packages/providers,供 core/worker 消费)

每种票据一个独立结构(判别联合),只有所有类型确实都有的字段才放公共外壳;绝不把类型专属字段塞进 Record<string, unknown>。

/** 财务票据种类(与 multiple_invoice 的 type 及专用端点对应)。 */
export type DocumentKind =
  | 'vat_invoice'
  | 'machine_invoice'
  | 'quota_invoice'
  | 'train_ticket'
  | 'taxi_receipt'
  | 'online_taxi_itinerary'
  | 'air_ticket'
  | 'bus_ticket'
  | 'toll_invoice'
  | 'ferry_ticket'
  | 'vehicle_sale_invoice'
  | 'used_vehicle_invoice'
  | 'shopping_receipt'
  | 'bank_receipt'
  | 'other';

/** 所有文档共有的最小外壳:仅存原始返回与质量标记,兜住红线 5。 */
interface DocumentBase {
  readonly sourceType: string | null; // multiple_invoice 的 type 原值
  readonly raw: Readonly<Record<string, unknown>>; // 完整返回(红线 5)
  readonly confidenceFlags: ReadonlyArray<string>; // amount_unparsed / amount_mismatch …
}

// ── 增值税 / 机打 / 定额:金额 + 商品行 ─────────────────────────────
export interface GoodsLine {
  readonly row: number;
  readonly name: string;
  readonly unit: string | null;
  readonly quantity: string | null;
  readonly unitPriceFen: Fen | null;
  readonly amountFen: Fen | null;
  readonly taxRate: string | null;
}

/**
 * 发票票种(取自百度验真接口的 invoice_type 枚举,权威且稳定):发票不是一种结构,
 * 专票有进项字段、通行费有出入口、机动车有车辆/合格证、铁路/航空有乘客与车次航班。
 */
export type InvoiceKind =
  | 'special_vat' // 增值税专用发票
  | 'elec_special_vat' // 增值税电子专票
  | 'normal_vat' // 增值税普通发票
  | 'elec_normal_vat' // 增值税普通发票(电子)
  | 'roll_normal_vat' // 增值税普通发票(卷式)
  | 'elec_invoice_special' // 全电发票(专用发票)
  | 'elec_invoice_normal' // 全电发票(普通发票)
  | 'toll_elec_normal' // 通行费增值税电子普通发票
  | 'freight_special' // 货运运输业增值税专用发票
  | 'motor_vehicle_sale' // 机动车销售统一发票(含电子)
  | 'used_vehicle_sale' // 二手车销售统一发票(含电子)
  | 'blockchain' // 区块链发票
  | 'printed_elec' // 通用机打电子发票
  | 'railway_elec' // 电子发票(铁路电子客票)
  | 'air_elec_itinerary'; // 电子发票(航空电子客票行程单)

/** 发票公共票面字段;票种差异全部下移到各 subtype,避免大而全的“发票结构”。 */
interface InvoiceBase extends DocumentBase {
  readonly kind: 'vat_invoice'; // 文档大类
  readonly invoiceKind: InvoiceKind; // 票种判别键(第二层)
  readonly invoiceNum: string; // 发票号码(组织级防重键)
  readonly invoiceCode: string | null; // 发票代码(全电票为空)
  readonly invoiceTypeOrg: string | null; // 票面名称,如“浙江增值税专用发票”
  readonly invoiceDate: string | null;
  readonly amountFen: Fen; // 价税合计
  readonly totalAmountFen: Fen | null; // 不含税
  readonly totalTaxFen: Fen | null;
  readonly sellerName: string | null;
  readonly sellerRegisterNum: string | null;
  readonly purchaserName: string | null;
  readonly purchaserRegisterNum: string | null;
  readonly items: ReadonlyArray<GoodsLine>;
  readonly remarks: string | null;
}

/** 普票 / 电子普票 / 全电普票:买方常为个人,无专票进项字段。 */
export interface NormalVatInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'normal_vat' | 'elec_normal_vat' | 'elec_invoice_normal';
  readonly checkCode: string | null;
  readonly invoiceNumDigit: string | null; // 全电票号码(密码区)
}

/** 专票 / 电子专票 / 全电专票 / 货运专票:含双方地址银行等进项字段。 */
export interface SpecialVatInvoiceDocument extends InvoiceBase {
  readonly invoiceKind:
    'special_vat' | 'elec_special_vat' | 'elec_invoice_special' | 'freight_special';
  readonly sellerAddress: string | null;
  readonly sellerBank: string | null;
  readonly purchaserAddress: string | null;
  readonly purchaserBank: string | null;
  readonly invoiceNumDigit: string | null;
  /** 货运专票专属:运输工具/车牌/起讫/货物。 */
  readonly transport: {
    readonly type: string | null;
    readonly plateNum: string | null;
    readonly departure: string | null;
    readonly arrival: string | null;
    readonly cargo: string | null;
  } | null;
}

/** 卷票:无代码校验场景,含卷号。 */
export interface RollVatInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'roll_normal_vat';
}

/** 通行费电子普票:含入口/出口/车牌/路段。 */
export interface TollElecInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'toll_elec_normal';
  readonly plateNum: string | null;
  readonly entry: string | null;
  readonly exit: string | null;
  readonly route: string | null;
}

/** 机动车销售统一发票:车辆 / 合格证 / 限乘信息。 */
export interface MotorVehicleSaleInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'motor_vehicle_sale';
  readonly vehicleType: string | null;
  readonly model: string | null; // 厂牌型号
  readonly vin: string | null; // 车架号/VIN
  readonly engineNum: string | null;
  readonly certificateNum: string | null; // 合格证号
  readonly origin: string | null; // 产地
  readonly tonnage: string | null; // 吨位
  readonly passengers: string | null; // 限乘人数
  readonly purchaserIdNum: string | null; // 购方身份证/组织机构代码
  readonly taxAuthority: string | null;
}

/** 二手车销售统一发票:转出/转入与经营单位。 */
export interface UsedVehicleSaleInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'used_vehicle_sale';
  readonly vehicleType: string | null;
  readonly plateNum: string | null;
  readonly market: string | null; // 二手车市场
  readonly transferOut: string | null; // 转出方
  readonly transferIn: string | null; // 转入方
}

/** 区块链发票:含链上标识。 */
export interface BlockchainInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'blockchain';
  readonly blockChainId: string | null;
}

/** 通用机打电子发票(vat_invoice 票种,非 `invoice` 端点)。 */
export interface PrintedElecInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'printed_elec';
  readonly machineNum: string | null;
  readonly province: string | null;
  readonly city: string | null;
}

/** 电子发票(铁路电子客票):乘客 + 车次 + 起讫。 */
export interface RailwayElecInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'railway_elec';
  readonly passengerName: string | null;
  readonly idNum: string | null;
  readonly trainNum: string | null;
  readonly startingStation: string | null;
  readonly destinationStation: string | null;
  readonly travelDate: string | null;
  readonly seatCategory: string | null;
  readonly seatNum: string | null;
  readonly elecTicketNum: string | null;
}

/** 电子发票(航空电子客票行程单):多航段。 */
export interface AirElecItineraryInvoiceDocument extends InvoiceBase {
  readonly invoiceKind: 'air_elec_itinerary';
  readonly passengerName: string | null;
  readonly idNum: string | null;
  readonly carrier: string | null;
  readonly ticketNumber: string | null;
  readonly flights: ReadonlyArray<AirFlight>;
}

export type InvoiceDocument =
  | NormalVatInvoiceDocument
  | SpecialVatInvoiceDocument
  | RollVatInvoiceDocument
  | TollElecInvoiceDocument
  | MotorVehicleSaleInvoiceDocument
  | UsedVehicleSaleInvoiceDocument
  | BlockchainInvoiceDocument
  | PrintedElecInvoiceDocument
  | RailwayElecInvoiceDocument
  | AirElecItineraryInvoiceDocument;

export interface MachineInvoiceDocument extends DocumentBase {
  readonly kind: 'machine_invoice';
  readonly invoiceType: string | null;
  readonly invoiceCode: string | null;
  readonly invoiceNum: string | null;
  readonly invoiceDate: string | null;
  readonly amountFen: Fen | null; // 合计金额小写
  readonly totalTaxFen: Fen | null;
  readonly sellerName: string | null;
  readonly purchaserName: string | null;
  readonly province: string | null;
  readonly city: string | null;
  readonly items: ReadonlyArray<GoodsLine>;
}

export interface QuotaInvoiceDocument extends DocumentBase {
  readonly kind: 'quota_invoice';
  readonly invoiceCode: string | null;
  readonly invoiceNumber: string | null;
  readonly amountInWords: string | null;
  readonly amountFen: Fen | null;
  readonly location: string | null;
  readonly province: string | null;
  readonly city: string | null;
}

// ── 交通:行程 / 车次 / 航班 ────────────────────────────────────────
export interface OnlineTaxiTrip {
  readonly itemId: string | null;
  readonly provider: string | null; // item_provider
  readonly pickupDate: string | null;
  readonly pickupTime: string | null;
  readonly carType: string | null;
  readonly distance: string | null;
  readonly city: string | null;
  readonly startPlace: string | null;
  readonly destinationPlace: string | null;
  readonly fareFen: Fen | null;
}

export interface OnlineTaxiItineraryDocument extends DocumentBase {
  readonly kind: 'online_taxi_itinerary';
  readonly serviceProvider: string | null;
  readonly startTime: string | null;
  readonly endTime: string | null;
  readonly phone: string | null;
  readonly applicationDate: string | null;
  readonly totalFareFen: Fen | null;
  readonly trips: ReadonlyArray<OnlineTaxiTrip>;
}

export interface TaxiReceiptDocument extends DocumentBase {
  readonly kind: 'taxi_receipt';
  readonly invoiceCode: string | null;
  readonly invoiceNum: string | null;
  readonly taxiNum: string | null; // 车牌
  readonly date: string | null;
  readonly time: string | null;
  readonly pickupTime: string | null;
  readonly dropoffTime: string | null;
  readonly fareFen: Fen | null;
  readonly fuelSurchargeFen: Fen | null;
  readonly callServiceSurchargeFen: Fen | null;
  readonly totalFareFen: Fen | null;
  readonly location: string | null;
  readonly province: string | null;
  readonly city: string | null;
  readonly pricePerKm: string | null;
  readonly distance: string | null;
}

export interface TrainTicketDocument extends DocumentBase {
  readonly kind: 'train_ticket';
  readonly ticketNum: string | null;
  readonly trainNum: string | null;
  readonly startingStation: string | null;
  readonly destinationStation: string | null;
  readonly date: string | null;
  readonly time: string | null;
  readonly seatCategory: string | null;
  readonly seatNum: string | null;
  readonly passengerName: string | null;
  readonly idNum: string | null;
  readonly salesStation: string | null;
  readonly invoiceItems: ReadonlyArray<string>; // 票价/退票费/改签费
  readonly ticketRates: ReadonlyArray<string>;
  readonly invoiceNum: string | null; // 电子客票
  readonly invoiceDate: string | null;
  readonly fareFen: Fen | null;
  readonly taxFen: Fen | null;
  readonly taxRate: string | null;
  readonly elecTicketNum: string | null;
  readonly purchaserName: string | null;
}

export interface AirFlight {
  readonly flight: string | null;
  readonly startingStation: string | null;
  readonly destinationStation: string | null;
  readonly date: string | null;
  readonly time: string | null;
  readonly class: string | null;
  readonly fareBasis: string | null;
  readonly fareFen: Fen | null;
}

export interface AirTicketDocument extends DocumentBase {
  readonly kind: 'air_ticket';
  readonly passengerName: string | null;
  readonly idNum: string | null;
  readonly ticketNumber: string | null; // 电子客票号
  readonly serialNumber: string | null; // 印刷序号
  readonly carrier: string | null;
  readonly issuedDate: string | null;
  readonly totalFen: Fen | null; // 合计金额 ticket_rates
  readonly devFundFen: Fen | null;
  readonly fuelSurchargeFen: Fen | null;
  readonly otherTaxFen: Fen | null;
  readonly insuranceFen: Fen | null;
  readonly flights: ReadonlyArray<AirFlight>; // multi_detect 多航班
}

export interface BusTicketDocument extends DocumentBase {
  readonly kind: 'bus_ticket';
  readonly invoiceCode: string | null;
  readonly invoiceNum: string | null;
  readonly exitStation: string | null;
  readonly arrivalStation: string | null;
  readonly date: string | null;
  readonly time: string | null;
  readonly amountFen: Fen | null;
  readonly passengerName: string | null;
  readonly idCard: string | null;
  readonly invoiceTime: string | null;
}

export interface TollInvoiceDocument extends DocumentBase {
  readonly kind: 'toll_invoice';
  readonly invoiceCode: string | null;
  readonly invoiceNum: string | null;
  readonly entrance: string | null;
  readonly exit: string | null;
  readonly date: string | null;
  readonly time: string | null;
  readonly amountFen: Fen | null;
  readonly province: string | null;
  readonly city: string | null;
}

export interface FerryTicketDocument extends DocumentBase {
  readonly kind: 'ferry_ticket';
  readonly invoiceType: string | null;
  readonly invoiceCode: string | null;
  readonly invoiceNum: string | null;
  readonly exitStation: string | null;
  readonly arrivalStation: string | null;
  readonly amountFen: Fen | null;
  readonly date: string | null;
}

// ── 消费 / 银行 ────────────────────────────────────────────────────
export interface ReceiptLine {
  readonly product: string | null;
  readonly quantity: string | null;
  readonly unitPriceFen: Fen | null;
  readonly subtotalFen: Fen | null;
}

export interface ShoppingReceiptDocument extends DocumentBase {
  readonly kind: 'shopping_receipt';
  readonly shopName: string | null;
  readonly receiptNum: string | null;
  readonly machineNum: string | null;
  readonly employeeNum: string | null;
  readonly consumptionDate: string | null;
  readonly consumptionTime: string | null;
  readonly totalFen: Fen | null;
  readonly paidFen: Fen | null;
  readonly changeFen: Fen | null;
  readonly discount: string | null;
  readonly currency: string | null;
  readonly printDate: string | null;
  readonly lines: ReadonlyArray<ReceiptLine>;
}

export interface BankReceiptDocument extends DocumentBase {
  readonly kind: 'bank_receipt';
  readonly title: string | null;
  readonly payerName: string | null;
  readonly payerBank: string | null;
  readonly payerAccount: string | null;
  readonly payeeName: string | null;
  readonly payeeBank: string | null;
  readonly payeeAccount: string | null;
  readonly amountInWords: string | null;
  readonly amountFen: Fen | null; // 小写金额
  readonly serialNo: string | null; // 流水号
  readonly receiptNo: string | null; // 回单编号
  readonly tradeDate: string | null;
  readonly summary: string | null;
  readonly purpose: string | null;
}

/** 未知 / 仅分类(限额度、POS、其他):只留 type 与位置,不猜内容。 */
export interface OtherDocument extends DocumentBase {
  readonly kind: 'other';
  readonly maybeKind: string | null; // limit_invoice / pos_invoice / shopping_receipt(仅分类) …
  readonly box: { left: number; top: number; width: number; height: number } | null;
}

/** 判别联合:按 kind 收窄到各自结构。 */
export type RecognizedDocument =
  | InvoiceDocument // 发票:再按 invoiceKind 收窄(专票/普票/卷票/全电/通行费/货运/机动车/铁路/航空…)
  | MachineInvoiceDocument
  | QuotaInvoiceDocument
  | OnlineTaxiItineraryDocument
  | TaxiReceiptDocument
  | TrainTicketDocument
  | AirTicketDocument
  | BusTicketDocument
  | TollInvoiceDocument
  | FerryTicketDocument
  | ShoppingReceiptDocument
  | BankReceiptDocument
  | OtherDocument;

// ── 统一投影:存储/匹配/列表等通用逻辑只看它(由各类型派生,不替代类型)──
export interface DocumentSummary {
  readonly kind: DocumentKind;
  readonly invoiceKind: InvoiceKind | null; // 仅发票有值
  readonly docNo: string | null;
  readonly issuedDate: string | null;
  readonly amountFen: Fen | null;
  readonly taxFen: Fen | null;
  readonly province: string | null;
  readonly city: string | null;
  readonly issuerName: string | null;
  readonly holderName: string | null;
  /** 匹配锚点:主金额 + 各明细金额(分),去重。 */
  readonly anchorAmountsFen: ReadonlyArray<Fen>;
}

运行时校验用 zod 判别联合(落 @poco/shared):z.discriminatedUnion('kind', [...]),每个类型一份 schema。存入 JSONB 时 kind 就是判别键,读取时按 kind 精确校验,写坏的数据在边界即被拒绝。

3.3 类型 → 字段映射(摘)

kind docNo issuedDate amountFen issuer city/from-to items
online_taxi_itinerary — ApplicationDate TotalFare ServiceProvider Items[].city / StartPlace→DestinationPlace 每段行程
train_ticket ticket_num/invoice_num date/invoice_date ticket_rates[0] sales_station starting→destination 发票项目
air_ticket serial_number/ticket_number date/issued_date ticket_rates carrier starting→destination 多航班
taxi_receipt InvoiceNum Date TotalFare TaxiNum(车牌) City / — —
machine_invoice InvoiceNum InvoiceDate AmountInFiguers SellerName Province/City 商品明细
quota_invoice invoice_number — invoice_rate_lowercase — province/city —
shopping_receipt receipt_num consumption_date total_amount shop_name — 商品表
bank_receipt 回单编号 交易日期 小写金额 收款人户名 — —
vat_invoice InvoiceNum InvoiceDate AmountInFiguers SellerName Province/City 商品明细

增值税发票继续走既有 StandardInvoice(05-ocr-fields.md)的字段映射经验,但产出升级为 InvoiceDocument(按 invoiceKind 收窄,票种专属字段见 §2.4);新增端点统一产出 RecognizedDocument,两者在 worker 层汇入同一匹配/落库逻辑。

4. 存储设计(Prisma)

附件是“文件”,一个文件可能含多张票据(混贴),故 documents 设计为数组;发票主票另有票种维度。

enum ReceiptKind {                 // 非发票文档种类(发票走 Invoice)
  machine_invoice quota_invoice
  online_taxi_itinerary train_ticket taxi_receipt air_ticket
  bus_ticket toll_invoice ferry_ticket
  vehicle_sale_invoice used_vehicle_invoice
  shopping_receipt bank_receipt other
}

enum InvoiceKind {                 // 发票票种(§2.4)
  special_vat elec_special_vat normal_vat elec_normal_vat roll_normal_vat
  elec_invoice_special elec_invoice_normal toll_elec_normal freight_special
  motor_vehicle_sale used_vehicle_sale blockchain printed_elec
  railway_elec air_elec_itinerary
}

model Attachment {
  // ...既有字段...
  /// 端点的完整原始返回(红线 5:重放/重匹配不再付费)
  ocrRawJson   Json?
  /// 归一化 RecognizedDocument[](混贴多张时多项,判别键 kind)
  documents    Json?
  /// 主文档种类(冗余 documents[0].kind,便于筛选/规则;发票时为 'vat_invoice')
  receiptKind  ReceiptKind?
  @@index([workspaceId, receiptKind])
}

model Invoice {
  // ...既有字段:invoiceNum/invoiceCode/amountFen/... 保留,防重键与列表口径不变...
  /// 规范化票种(§2.4),筛选/规则/导出用
  invoiceKind  InvoiceKind?
  /// 票种专属字段(车辆信息/通行费起讫/铁路乘客…),按 invoiceKind 解释
  detail       Json?
}
  • 为何 JSONB:13 类文档 + 15 种发票票种字段差异大、且需原样留存;用 JSONB 存 documents/detail/ocrRawJson,避免为每类建表、每字段建列。JSON 内金额仍为分(红线 1),不引入 float。
  • 两层判别落库:非发票附件 → Attachment.documents[].kind;发票主票 → Invoice.invoiceKind + Invoice.detail(Invoice 行本身就是主票,固定 kind='vat_invoice')。
  • 运行期校验:documents/detail 读出时用 @poco/shared 的 zod 判别联合按 kind / invoiceKind 校验,坏数据在边界拒绝。
  • 既有 attachmentType(itinerary/order_screenshot/proof/other)保留为粗粒度证据类别:receiptKind ∈ {online_taxi_itinerary,train,air,taxi,bus,toll,ferry} → itinerary;{machine,quota} → proof;{shopping,bank} → order_screenshot。列表/规则仍可用。
  • RLS、工作区外键、软删规则不变。

5. Provider 架构

5.1 能力接口

在 InvoiceProvider(parse = vat_invoice,recognizeText = general_basic)基础上扩展为文档能力:

export interface DocumentProvider {
  /** 一次分类 + 结构化 13 类财务票据(含混贴);无则空数组。 */
  recognizeFinancialDocuments(input: ProviderInput): Promise<RecognizedDocument[]>;
  /** 单类型专用端点(精度更高 / 兜底 multiple_invoice 的“其他”)。 */
  recognizeDocument(kind: DocumentKind, input: ProviderInput): Promise<RecognizedDocument | null>;
  /** 通用全文,最终兜底(沿用)。 */
  recognizeText(input: ProviderInput): Promise<string>;
  /** 增值税主票(沿用)。 */
  parse(input: ProviderInput): Promise<StandardInvoice>;
}

5.2 注册表驱动的映射层

所有财务端点请求形状完全一致(image|url|pdf_file|ofd_file + 可选 probability/location/…),只有 URL 与响应映射不同。因此做成数据驱动注册表,新增票据=加一个 mapper:

// packages/providers/src/baidu/financial/registry.ts
interface EndpointSpec {
  readonly url: string;
  readonly docKind: DocumentKind;
  readonly map: (words: Record<string, unknown>) => RecognizedDocument;
}
export const BAIDU_FINANCIAL_ENDPOINTS: Record<DocumentKind, EndpointSpec>;
  • 共享:请求构造、token 号池、限流退避、错误契约(NotAnInvoiceError/QuotaExceededError/RateLimitedError/ProviderAuthError)。
  • multiple_invoice 映射器按返回项的 type 分派到各类型的字段映射,产出 RecognizedDocument[]。
  • 发票票种分支:发票映射器先由 InvoiceType/invoice_type 归一为 InvoiceKind(§2.4),再填充对应 subtype(专票补双方地址银行、通行费补出入口、机动车补车辆信息、铁路/航空补乘客车次航班…)。
  • 归一化工具:toFen(字符串→分)、toIsoDate(2019年04月03日/20-07-01/2017-11-26…)、pick、joinItems。解析失败→字段缺失并打 confidenceFlags,不抛错(同现有红线)。

5.3 配额、熔断与安全

  • 端点各自计费/限额:熔断状态按 (credential, endpoint) 维度维护(日 17/月 19/429)。
  • 配置:同一组 OCR key 复用;是否启用某端点用环境开关(如 OCR_FINANCIAL_ENDPOINTS=multiple_invoice)。
  • 红线 4:日志/错误只出现 baidu-key#N 与端点名,绝不出现 key。
  • 红线 5:RecognizedDocument.raw 必须原样落库。

6. worker 管道路由

parse job
  ▼
vat_invoice(保留首选:主票字段最全 + 组织级防重键依赖)
  ├─ 命中 → invoice 主票(现状不变)
  └─ 282103 → 附件子管道
        ▼
      multiple_invoice(分类 + 结构化;混贴 → RecognizedDocument[])
        ├─ type 可结构化(火车/机票/网约车/出租车/汽车/过路/机打/定额/增值税…)
        │     → 归一化 → 落 Attachment.documents / ocrRawJson / receiptKind
        │     → 金额锚点(主金额 + 各行金额)参与匹配(口径见 §7)
        └─ type ∈ {others, shopping_receipt, pos_invoice, limit_invoice}
              → 调专用端点(shopping_receipt / bank_receipt_new)或退 general_basic
        ▼
      (兜底)general_basic → 全文 → 现有 core/matcher(保证“无锚点不猜”仍可用)

要点:

  • 保留 vat_invoice 为首选:主票字段映射、组织级防重、现有测试都依赖它;multiple_invoice 只服务非发票附件。
  • 降级链:multiple_invoice 不可用/超额 → 直接 general_basic(现状),保证可用性。
  • 混贴:一个文件产出多份 RecognizedDocument,主文档取金额最大者,全部落库。

7. 匹配与规则消费

  • 金额锚点扩展:DocumentSummary.anchorAmountsFen(主金额 + trips/flights/items/lines 各行金额)一并进锚点池;文件名兜底逻辑(docs/05 §6.1)继续生效。
  • 城市/起终点:直接取各类型的 city / startPlace|destinationPlace / startingStation|destinationStation / entry|exit,不再依赖正则猜字;可透出到附件视图与导出。多段行程(trips/flights)经 documentRoutes 按段展开为 routes: { city, origin, destination }[] 随发票列表下发(契约见 08 §3.4;documentRoute 仍返回首段兼容单段调用)。发票库不悬停时用首段命名,悬停展示全部路段。
  • 规则引擎:InvoiceReminderSignal 已增 cities(行程类附件的城市,去重),落地 invoice.reminder.taxi-out-of-city「非本市(杭州市)打车需说明」;后续可扩展 receiptKinds 等。
  • 重匹配:现有 /attachments/rematch 改为从 ocrRawJson/documents 回放,无需重调 OCR。

8. 兼容与迁移

  1. 迁移只加列/枚举(Attachment.ocrRawJson/documents/receiptKind、Invoice.invoiceKind/detail),旧数据为 null,纯向后兼容。
  2. 老 ocrRawText 保留:general_basic 兜底与现有 matcher 不变。
  3. 回填:对历史附件按需调专用端点重识别(付费)或仅回放已存文本(免费)。发票库操作列的「重新识别」按钮即触发前者:POST /invoices/:id/re-recognize 把该发票活动附件经 parse 队列重跑(入队带 reRecognize,worker 覆盖已有附件、重匹配)。
  4. 文档同步:本文件新增;03 §7、04(表结构)、05 §6、02(附件匹配)、08(附件视图字段)联动。

9. 分阶段落地

阶段 状态 内容
M-a ✅ 已落地 provider 注册表 + online_taxi_itinerary + ocrRawJson/documents/receiptKind 与 invoiceKind/detail 存储 + worker 接入 + 发票/附件视图透出路线与票种 + 发票库「城市-起点-终点」命名
M-b ⏳ 待做 multiple_invoice 作附件分类入口 + 火车票/机票/出租车/汽车票/过路费映射
M-c ⏳ 待做 购物小票/银行回单专用端点 + 混贴多文档 + verify_parameter 验真六要素

M-a 已落地要点(对应实现):

  • packages/shared/src/documents.ts:两层判别联合 RecognizedDocument(kind + invoiceKind)与 DocumentSummary 投影。
  • packages/providers/src/financial.ts:端点注册表 FINANCIAL_ENDPOINTS、online_taxi_itinerary 映射、summarizeDocument、documentRoute / routeFromItineraryText。
  • packages/providers/src/invoice-kind.ts:classifyInvoiceKind / buildInvoiceDetail(票种归一 + 专属字段)。
  • apps/worker:行程单分类后追加结构化识别(失败静默回退 general_basic),金额锚点并入总金额 + 每段行程金额。
  • packages/db:迁移 20261004000000_add_receipt_documents。
  • apps/api:InvoiceView.invoiceKind、附件 route;apps/web 发票库市内打车按路线命名并去掉 No.。

10. 风险与待确认

  • 成本:财务票据资源包与通用场景独立计费;需确认免费额度并按工作区做配额/告警。
  • 精度:multiple_invoice 对单一类型未必优于专用端点;若实测不达标,路由改为“先分类(multiple_invoice)→ 再调专用端点”。
  • 混贴:多文档 ↔ 单附件的匹配语义(一图多票,哪张挂哪张)需产品确认。
  • 字段命名:各端点字段风格不一(驼峰/下划线/中文键),注册表 mapper 是唯一适配点,务必单测覆盖真机样例。