12 - 财务票据 OCR:端点全景、统一文档模型与接入架构¶
本文把百度「财务票据文字识别」系列接口纳入本项目:先盘点端点,再给出能容纳各类附件(行程单、火车票、机票、出租车票、定额/机打发票、购物小票、银行回单…)的统一数据模型 RecognizedDocument,最后给出 provider 与 worker 管道的接入架构。字段映射的时代背景见 05-ocr-fields.md(增值税发票)与 03-architecture.md §7(附件子管道)。
1. 背景与问题¶
现状:vat_invoice 解析增值税发票;非发票走 general_basic 取全文,再由 core/matcher 正则提金额/日期/商户做附件匹配。问题有三:
- 无结构:行程单的城市/起点/终点、火车票/机票的始发到达、出租车里程等,
general_basic只给拉平文本且无坐标,靠正则不可靠(真机样例杭州被识成“抗牺市”)。 - 覆盖窄:火车票、机票、汽车票、过路费、购物小票、银行回单等目前只能当纯文本,白白丢掉结构化字段。
- 混贴无法处理:一张图里有多张不同票据时,现有管道无法拆分。
百度财务票据系列提供了结构化识别(部分含混贴分类),可以一次性解决。
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 设计原则¶
- 每种票据一个独立强类型结构:
RecognizedDocument是按kind判别的联合;发票再按invoiceKind二次判别(专票/普票/卷票/全电/通行费/货运/机动车/铁路/航空…)。类型专属字段只出现在各自接口,绝不塞进Record<string, unknown>/extra。 - 公共外壳最小化:
DocumentBase只保留所有类型确实都有的sourceType / raw / confidenceFlags;跨类型的共性(号码/日期/金额/城市/开票方)通过DocumentSummary投影,供存储、匹配、列表等通用逻辑消费。 - 明细一等公民:行程多段、机票多航班、机打/小票多商品,统一为具名数组(
trips / flights / items / lines),各自带起终点、城市、金额;不搞万能items[]。 - 金额一律分(红线 1),经字符串
yuanToFen转换,绝不引入 float。 - 完整原始返回随文档保存(红线 5):
raw存该端点/该票种的完整words_result,重放/重匹配不再付费。 - 未知不猜:
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. 兼容与迁移¶
- 迁移只加列/枚举(
Attachment.ocrRawJson/documents/receiptKind、Invoice.invoiceKind/detail),旧数据为 null,纯向后兼容。 - 老
ocrRawText保留:general_basic兜底与现有 matcher 不变。 - 回填:对历史附件按需调专用端点重识别(付费)或仅回放已存文本(免费)。发票库操作列的「重新识别」按钮即触发前者:
POST /invoices/:id/re-recognize把该发票活动附件经 parse 队列重跑(入队带reRecognize,worker 覆盖已有附件、重匹配)。 - 文档同步:本文件新增;
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 是唯一适配点,务必单测覆盖真机样例。