Documentation
¶
Overview ¶
Package invoice 从 OFD 电子发票中抽取结构化发票信息,并支持多票汇总、对账 报表与合并打印。
抽取以 OFD 包内嵌的发票结构化附件(通常为 Doc_0/Attachs/original_invoice.xml) 为主;没有有效附件时,按文档自定义标引的 ObjectRef 引用读取页面中的发票字段。 两种结构化数据来源均不可用时返回错误。页面文字层另用于补齐票面视觉字段(发票标题、 价税合计大写金额),并据此推断发票类型。不涉及 PDF 等非 OFD 输入。
单张发票用 Extract;多张发票用 ExtractBatch 批量抽取并汇总金额(按销售方、 发票类型和开票月份分组),用 WriteCSV 与 WriteXLSX 导出对账报表,用 MergePDF 合并成一个便于打印的 PDF。金额基于任意精度有理数,求和后按最小货币单位舍入, 红字(红冲)发票的负金额照常参与合计。
批量处理默认严格:任一输入抽不出发票结构化数据即整批报错,避免金额在无人 察觉时少算;BatchOptions.SkipBroken 与 BatchOptions.Dedup 分别放宽失败输入 和排除重复发票,两者都会改变参与合计的口径。
Index ¶
- Constants
- Variables
- func SortItems(items []Item, key string) error
- func WriteCSV(w io.Writer, batch *Batch) error
- func WriteXLSX(w io.Writer, batch *Batch) error
- type Batch
- type BatchOptions
- type Decimal
- func (d *Decimal) Abs() *Decimal
- func (d *Decimal) Add(other *Decimal) *Decimal
- func (d *Decimal) Cmp(other *Decimal) int
- func (d *Decimal) Fixed(places int) string
- func (d *Decimal) IsNegative() bool
- func (d *Decimal) IsZero() bool
- func (d *Decimal) MarshalJSON() ([]byte, error)
- func (d *Decimal) Money() *Decimal
- func (d *Decimal) Mul(other *Decimal) *Decimal
- func (d *Decimal) Neg() *Decimal
- func (d *Decimal) Round(places int) *Decimal
- func (d *Decimal) Sign() int
- func (d *Decimal) String() string
- func (d *Decimal) Sub(other *Decimal) *Decimal
- type Detail
- type Group
- type Invoice
- type Item
- type MergeLimits
- type MergeOptions
- type Party
- type Skipped
- type Source
- type Summary
Examples ¶
Constants ¶
const ( // SortInput 保持输入顺序,是默认值。 SortInput = "input" // SortDate 按开票日期升序。 SortDate = "date" // SortCode 按发票代码与号码升序。 SortCode = "code" // SortName 按来源名升序。 SortName = "name" )
SortItems 支持的排序方式。
const ( // FitContain 等比缩放并居中补白,是唯一支持的适配方式。 // // 不提供"填满裁切":裁切会切掉票面边缘的印章与价税合计,对发票来说等于毁票。 FitContain = "contain" // PagesAll 保留每张票的全部页面。 PagesAll = "all" // PagesFirst 只取每张票的第 1 页。 PagesFirst = "first" // PagesFace 只取每张票的票面页(页面上有发票代码或价税合计字样)。 PagesFace = "face" // OrientationAuto 按实际票源挑占用率更高的方向,是默认值。 OrientationAuto = "auto" // OrientationPortrait 固定纵向。 OrientationPortrait = "portrait" // OrientationLandscape 固定横向,格子宽高随纸张一起交换。 OrientationLandscape = "landscape" )
拼版与裁页的取值。
const MoneyScale = 2
MoneyScale 是金额保留的小数位数,即最小货币单位「分」。
Variables ¶
var ErrNoAttachment = errors.New("未找到 OFD 发票结构化附件(Attachs 目录下的 XML)")
ErrNoAttachment 表示 OFD 包内未找到发票结构化附件。
var ErrNotInvoice = errors.New("附件不是发票数据:既无发票代码或铁路电子客票号,也无价税合计")
ErrNotInvoice 表示附件存在,但里面不是发票数据:既没有发票代码或铁路电子 客票号,也没有价税合计。
与 ErrNoAttachment 分开是因为两者的后续动作不同:找不到附件时厂商可能把附件放在了 别处(值得确认),而附件里没有发票数据就是这份文档本身不是发票(没什么可查)。
Functions ¶
func SortItems ¶ added in v0.1.5
SortItems 按 key 就地重排 items,让同一批发票的汇总、报表和合并 PDF 保持同一次序。 支持 SortInput(保持输入顺序)、SortDate(开票日期)、SortCode(发票代码与号码) 和 SortName(来源名);无法排序的值排在最后,同值之间保持输入顺序。
func WriteCSV ¶ added in v0.1.5
WriteCSV 写出发票明细与合计的 CSV 报表。
明细按输入顺序输出,每行一张票,抽取失败的输入也占一行并在「异常」列写明 原因——对账时要能看出哪些票没进来,而不是表格里凭空少了行。末行是合计行, 与 Batch.Summary 的口径一致(已按分四舍五入、含红字负数)。
输出写 UTF-8 BOM 并使用 CRLF 行尾:Excel 在中文 Windows 上按本地代码页读取 无 BOM 的 UTF-8 CSV,中文列名会变成乱码。
Example ¶
ExampleWriteCSV 演示导出对账 CSV:明细逐票一行,末行是合计。
package main
import (
"bytes"
"fmt"
"strings"
"github.com/klauspost/compress/zip"
"github.com/zc310/ofd/pkg/invoice"
)
func main() {
batch, err := invoice.ExtractBatch([]invoice.Source{
{Name: "九月", Input: sampleInvoiceOFD()},
}, invoice.BatchOptions{})
if err != nil {
fmt.Println(err)
return
}
var out bytes.Buffer
if err := invoice.WriteCSV(&out, batch); err != nil {
fmt.Println(err)
return
}
// CSV 以 CRLF 结尾,切分后最后一个元素是空串,合计行是倒数第二个。
lines := strings.Split(strings.TrimPrefix(out.String(), "\ufeff"), "\r\n")
fmt.Println(lines[0])
fmt.Println(lines[len(lines)-2])
}
// sampleInvoiceOFD 构造一个最小 OFD,包含发票结构化附件与票面文字层。
func sampleInvoiceOFD() []byte {
files := map[string]string{
"OFD.xml": `<?xml version="1.0" encoding="UTF-8"?>
<OFD xmlns="http://www.ofdspec.org/2016" Version="1.0" DocType="OFD">
<DocBody>
<DocInfo><DocID>invoice</DocID><Title>发票</Title></DocInfo>
<DocRoot>Doc_0/Document.xml</DocRoot>
</DocBody>
</OFD>`,
"Doc_0/Document.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="http://www.ofdspec.org/2016">
<CommonData>
<MaxUnitID>10</MaxUnitID>
<PageArea><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></PageArea>
</CommonData>
<Pages><Page ID="1" BaseLoc="Pages/Page_0/Content.xml"/></Pages>
</Document>`,
"Doc_0/Pages/Page_0/Content.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Page xmlns="http://www.ofdspec.org/2016">
<Area><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></Area>
<Content>
<Layer ID="1" Type="Body">
<TextObject ID="1" Boundary="70 10 70 8" Font="1" Size="4"><TextCode X="30" Y="20">增值税电子普通发票</TextCode></TextObject>
<TextObject ID="2" Boundary="110 260 60 6" Font="1" Size="3"><TextCode X="100" Y="262">价税合计(大写)壹佰壹拾叁元整</TextCode></TextObject>
</Layer>
</Content>
</Page>`,
"Doc_0/Attachs/original_invoice.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:cn:gov:tax:invoice">
<MachineNo>444000224046</MachineNo>
<InvoiceCode>033001900111</InvoiceCode>
<InvoiceNo>12345678</InvoiceNo>
<IssueDate>2026-09-18</IssueDate>
<InvoiceCheckCode>6A5B2C3D1E4F</InvoiceCheckCode>
<TaxExclusiveTotalAmount>100.00</TaxExclusiveTotalAmount>
<TaxTotalAmount>13.00</TaxTotalAmount>
<TaxInclusiveTotalAmount>113.00</TaxInclusiveTotalAmount>
<Payee>王五</Payee>
<Checker>赵六</Checker>
<InvoiceClerk>张三</InvoiceClerk>
<Buyer><BuyerName>买方公司</BuyerName><BuyerTaxID>911100000000000000</BuyerTaxID></Buyer>
<Seller><SellerName>卖方公司</SellerName><SellerTaxID>911100001111000000</SellerTaxID></Seller>
<GoodsInfos>
<G1>
<Item>办公用品*中性笔</Item>
<Specification>0.5mm</Specification>
<MeasurementDimension>支</MeasurementDimension>
<Quantity>10.00</Quantity>
<Price>2.00</Price>
<Amount>20.00</Amount>
<TaxScheme>13%</TaxScheme>
<TaxAmount>2.60</TaxAmount>
</G1>
</GoodsInfos>
</Invoice>`,
}
var data bytes.Buffer
archive := zip.NewWriter(&data)
for name, value := range files {
writer, err := archive.Create(name)
if err != nil {
panic(err)
}
if _, err := writer.Write([]byte(value)); err != nil {
panic(err)
}
}
if err := archive.Close(); err != nil {
panic(err)
}
return data.Bytes()
}
Output: 来源,发票代码,发票号码,开票日期,发票类型,红字,购买方名称,购买方税号,销售方名称,销售方税号,不含税金额,税额,价税合计,重复,异常 合计 1 张,不含税金额,100.00,税额,13.00,价税合计,113.00,,,,,,,,
func WriteXLSX ¶ added in v0.1.5
WriteXLSX 写出对账用的 XLSX 报表,包含明细、分组汇总与异常三个工作表。
明细与 CSV 同源同口径:抽取失败的输入也占一行,末行是合计。分组汇总按 「维度 + 键 + 名称」的长表组织,销售方、发票类型和开票月份都放进去,方便在 Excel 里直接做数据透视。异常表列出自校验不通过和被去重跳过的输入。
三张表统一版式:第 1 行标题、第 2 行副标题(带张数与合计)、第 4 行表头、 第 5 行起数据,冻结窗格固定在表头下方。配色沿用校验器与分析器报表那一套。
金额写成带两位小数的**数值**单元格,负数由数字格式标红:写成文本会让 Excel 的 SUM 整列跳过,对账表就失去了意义。自动筛选只覆盖表头与明细,不含合计行—— 合计跟着筛选变动会被读成「筛选后的合计」。
Types ¶
type Batch ¶ added in v0.1.5
type Batch struct {
// Items 按输入顺序排列的处理结果。
Items []Item `json:"items"`
// Skipped 是因去重而未参与合计的输入。
Skipped []Skipped `json:"skipped,omitempty"`
// Summary 是参与合计的发票的汇总。
Summary Summary `json:"summary"`
}
Batch 是一批发票的抽取结果与汇总。
func ExtractBatch ¶ added in v0.1.5
func ExtractBatch(sources []Source, options BatchOptions) (*Batch, error)
ExtractBatch 依次抽取 sources 中的发票并汇总金额。
默认严格:任一输入抽取失败即返回错误,避免整批金额在无人察觉时少算。 BatchOptions.SkipBroken 打开后失败项记入 Item.Error 继续处理, BatchOptions.Dedup 打开后重复发票移入 Skipped。
Example ¶
ExampleExtractBatch 演示汇总多张发票的金额,并按销售方分组。
package main
import (
"bytes"
"fmt"
"github.com/klauspost/compress/zip"
"github.com/zc310/ofd/pkg/invoice"
)
func main() {
batch, err := invoice.ExtractBatch([]invoice.Source{
{Name: "九月", Input: sampleInvoiceOFD()},
{Name: "十月", Input: sampleInvoiceWithAmount("12345679", "2026-10-08", "200.00", "26.00", "226.00")},
}, invoice.BatchOptions{})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(batch.Summary.Count, batch.Summary.TotalAmount.Fixed(invoice.MoneyScale))
for _, group := range batch.Summary.ByMonth {
fmt.Println(group.Name, group.Count, group.TotalAmount.Fixed(invoice.MoneyScale))
}
// 分组按价税合计降序排列,因此十月在九月之前。
Output:
func MergePDF ¶ added in v0.1.5
func MergePDF(ctx context.Context, sources []Source, w io.Writer, options MergeOptions) (*Batch, error)
MergePDF 把多张发票合并为一个 PDF 写入 w,并返回合并前的抽取与汇总结果。
合并走的是「逐张解析成渲染文档,自己按版面输出 PDF」,而不是先合并 OFD 再转换: 这样保留了每张票的矢量文字与 PDF 大纲(打印时可在侧栏直接跳票),也不生成 中间文件,还能按票裁页与拼版(见 Pages、Paper、NUp)。被去重跳过和抽取失败的 输入不会出现在 PDF 里,具体清单见返回的 Batch.Skipped 与 Batch.Items。
默认行为与旧版一致:不给 Paper/NUp 时每页按自身尺寸 1:1 输出,不缩放不补白。
汇总页由 pkg/creator 现生成一张 OFD 再并入同一份 PDF。它只声明逻辑字体名 coverFont,字形由渲染环节解析——所以真正的前提是那个名字能被解析到一份含 中文字形的字体(浏览器里没有系统字体,需调用方先注册)。解析不到时汇总页 文字缺失,而失败没有任何报错,发票页面本身不受影响。
Example ¶
ExampleMergePDF 演示把多张发票合并成一个便于打印的 PDF,并在首页放汇总对账表。
package main
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/klauspost/compress/zip"
"github.com/zc310/ofd/pkg/invoice"
)
func main() {
sources := []invoice.Source{
{Name: "a", Input: sampleInvoiceOFD()},
{Name: "b", Input: sampleInvoiceWithAmount("12345679", "2026-10-08", "200.00", "26.00", "226.00")},
}
var out bytes.Buffer
if _, err := invoice.MergePDF(context.Background(), sources, &out, invoice.MergeOptions{
Cover: true,
Sort: invoice.SortDate,
}); err != nil {
fmt.Println(err)
return
}
data := out.Bytes()
fmt.Println(strings.HasPrefix(string(data), "%PDF-"))
}
// sampleInvoiceOFD 构造一个最小 OFD,包含发票结构化附件与票面文字层。
func sampleInvoiceOFD() []byte {
files := map[string]string{
"OFD.xml": `<?xml version="1.0" encoding="UTF-8"?>
<OFD xmlns="http://www.ofdspec.org/2016" Version="1.0" DocType="OFD">
<DocBody>
<DocInfo><DocID>invoice</DocID><Title>发票</Title></DocInfo>
<DocRoot>Doc_0/Document.xml</DocRoot>
</DocBody>
</OFD>`,
"Doc_0/Document.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="http://www.ofdspec.org/2016">
<CommonData>
<MaxUnitID>10</MaxUnitID>
<PageArea><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></PageArea>
</CommonData>
<Pages><Page ID="1" BaseLoc="Pages/Page_0/Content.xml"/></Pages>
</Document>`,
"Doc_0/Pages/Page_0/Content.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Page xmlns="http://www.ofdspec.org/2016">
<Area><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></Area>
<Content>
<Layer ID="1" Type="Body">
<TextObject ID="1" Boundary="70 10 70 8" Font="1" Size="4"><TextCode X="30" Y="20">增值税电子普通发票</TextCode></TextObject>
<TextObject ID="2" Boundary="110 260 60 6" Font="1" Size="3"><TextCode X="100" Y="262">价税合计(大写)壹佰壹拾叁元整</TextCode></TextObject>
</Layer>
</Content>
</Page>`,
"Doc_0/Attachs/original_invoice.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:cn:gov:tax:invoice">
<MachineNo>444000224046</MachineNo>
<InvoiceCode>033001900111</InvoiceCode>
<InvoiceNo>12345678</InvoiceNo>
<IssueDate>2026-09-18</IssueDate>
<InvoiceCheckCode>6A5B2C3D1E4F</InvoiceCheckCode>
<TaxExclusiveTotalAmount>100.00</TaxExclusiveTotalAmount>
<TaxTotalAmount>13.00</TaxTotalAmount>
<TaxInclusiveTotalAmount>113.00</TaxInclusiveTotalAmount>
<Payee>王五</Payee>
<Checker>赵六</Checker>
<InvoiceClerk>张三</InvoiceClerk>
<Buyer><BuyerName>买方公司</BuyerName><BuyerTaxID>911100000000000000</BuyerTaxID></Buyer>
<Seller><SellerName>卖方公司</SellerName><SellerTaxID>911100001111000000</SellerTaxID></Seller>
<GoodsInfos>
<G1>
<Item>办公用品*中性笔</Item>
<Specification>0.5mm</Specification>
<MeasurementDimension>支</MeasurementDimension>
<Quantity>10.00</Quantity>
<Price>2.00</Price>
<Amount>20.00</Amount>
<TaxScheme>13%</TaxScheme>
<TaxAmount>2.60</TaxAmount>
</G1>
</GoodsInfos>
</Invoice>`,
}
var data bytes.Buffer
archive := zip.NewWriter(&data)
for name, value := range files {
writer, err := archive.Create(name)
if err != nil {
panic(err)
}
if _, err := writer.Write([]byte(value)); err != nil {
panic(err)
}
}
if err := archive.Close(); err != nil {
panic(err)
}
return data.Bytes()
}
// sampleInvoiceWithAmount 构造一张指定号码、日期与金额的发票 OFD,
// 用于演示多票汇总。号码不同,避免被当成重复票。
func sampleInvoiceWithAmount(number, date, amount, tax, total string) []byte {
files := map[string]string{
"OFD.xml": `<?xml version="1.0" encoding="UTF-8"?>
<OFD xmlns="http://www.ofdspec.org/2016" Version="1.0" DocType="OFD">
<DocBody>
<DocInfo><DocID>invoice</DocID><Title>发票</Title></DocInfo>
<DocRoot>Doc_0/Document.xml</DocRoot>
</DocBody>
</OFD>`,
"Doc_0/Document.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="http://www.ofdspec.org/2016">
<CommonData>
<MaxUnitID>10</MaxUnitID>
<PageArea><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></PageArea>
</CommonData>
<Pages><Page ID="1" BaseLoc="Pages/Page_0/Content.xml"/></Pages>
</Document>`,
"Doc_0/Pages/Page_0/Content.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Page xmlns="http://www.ofdspec.org/2016">
<Area><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></Area>
<Content>
<Layer ID="1" Type="Body">
<TextObject ID="1" Boundary="70 10 70 8" Font="1" Size="4"><TextCode X="30" Y="20">增值税电子普通发票</TextCode></TextObject>
</Layer>
</Content>
</Page>`,
"Doc_0/Attachs/original_invoice.xml": fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:cn:gov:tax:invoice">
<InvoiceCode>033001900111</InvoiceCode>
<InvoiceNo>%s</InvoiceNo>
<IssueDate>%s</IssueDate>
<TaxExclusiveTotalAmount>%s</TaxExclusiveTotalAmount>
<TaxTotalAmount>%s</TaxTotalAmount>
<TaxInclusiveTotalAmount>%s</TaxInclusiveTotalAmount>
<Buyer><BuyerName>买方公司</BuyerName><BuyerTaxID>911100000000000000</BuyerTaxID></Buyer>
<Seller><SellerName>卖方公司</SellerName><SellerTaxID>911100001111000000</SellerTaxID></Seller>
<GoodsInfos>
<G1>
<Item>技术服务</Item>
<Quantity>1.00</Quantity>
<Price>%s</Price>
<Amount>%s</Amount>
<TaxScheme>13%%</TaxScheme>
<TaxAmount>%s</TaxAmount>
</G1>
</GoodsInfos>
</Invoice>`, number, date, amount, tax, total, amount, amount, tax),
}
var data bytes.Buffer
archive := zip.NewWriter(&data)
for name, value := range files {
writer, err := archive.Create(name)
if err != nil {
panic(err)
}
if _, err := writer.Write([]byte(value)); err != nil {
panic(err)
}
}
if err := archive.Close(); err != nil {
panic(err)
}
return data.Bytes()
}
Output: true
func (*Batch) ActiveItems ¶ added in v0.1.5
ActiveItems 返回参与合计的输入,保持输入顺序。被去重跳过的项不在其中。 合并 PDF 等后续处理必须用它,而不是直接遍历 Items,否则会多合并一张票。
type BatchOptions ¶ added in v0.1.5
type BatchOptions struct {
// SkipBroken 为 true 时,单个输入失败只记入该条结果并继续;默认 false,
// 首个失败即整批返回错误。默认严格是为了避免一批票里静默少算金额——
// 汇总少一张票和合并 PDF 少一页,在财务场景里都是事故。
SkipBroken bool
// Dedup 为 true 时,发票代码与号码重复的输入只保留第一张,其余移入
// Skipped 并说明与哪张重复。默认 false:重复项仍然参与合计并被标记,
// 丢弃与否交给使用者判断。
Dedup bool
}
BatchOptions 控制批量抽取行为。
type Decimal ¶
type Decimal struct {
// contains filtered or unexported fields
}
Decimal 表示精确的十进制数值(金额、数量、税率),基于任意精度的有理数。 JSON 序列化为十进制字符串(如 "123.45"),避免浮点误差。
零值和 nil 的含义不同:nil 表示「票面上没有这个数」(序列化时省略该字段), 而 &Decimal{} 的零值是合法数字 0。金额相加时 nil 一律按「没有参与」处理, 不当成 0 累加。
func NewDecimal ¶ added in v0.1.5
NewDecimal 解析十进制文本(如 "1280.00"、"-13.5"、"1280.00/3")返回精确数值, 文本不含合法数值时返回 nil。这是构造 Decimal 的公开入口:解析文本用 NewDecimal, 金额求和用 Add 或 Sum。
允许分数形式("1/3")是 big.Rat 的原生语法,Decimal 本就基于有理数, 明细行数量/单价的异常数据会用到。
func Sum ¶ added in v0.1.5
Sum 返回一组数值之和,逐项跳过 nil。全部为 nil 时返回 nil(区别于合计为零), 以便调用方判断「这批票根本没有金额」与「这批票金额都是 0」。
func (*Decimal) Add ¶ added in v0.1.5
Add 返回两数之和。任一操作数为 nil 时返回 nil:这个数在票面上不存在, 把它当成 0 参与求和会让「缺字段」与「金额为零」无法区分。
func (*Decimal) Fixed ¶ added in v0.1.5
Fixed 返回固定 places 位小数的字符串表示,用于报表定宽输出。 金额字段用 String() 即可(最少十进制形式),需要「1280.00」这种定宽形态时 用 Fixed(MoneyScale)。
func (*Decimal) IsNegative ¶ added in v0.1.5
IsNegative 判断金额是否为负。红字(红冲)发票的金额为负,是正常业务数据, 汇总时必须参与求和,不能当异常丢弃。
func (*Decimal) MarshalJSON ¶
MarshalJSON 把 Decimal 输出为十进制字符串。
func (*Decimal) Round ¶ added in v0.1.5
Round 返回按 places 位小数四舍五入的结果,恰好在中点时远离零(half away from zero),与财务票据的进位习惯一致。places 为负时按 0 处理,nil 返回 nil。
不做舍入的求和会输出 64 位小数(例如 1/3 展开成 0.333…),这种数字无法对账, 金额合计必须先过 Round(MoneyScale)。
type Detail ¶
type Detail struct {
// Name 是项目名称。
Name string `json:"name,omitempty"`
// Model 是规格型号。
Model string `json:"model,omitempty"`
// Unit 是单位。
Unit string `json:"unit,omitempty"`
// Count 是数量。
Count *Decimal `json:"count,omitempty"`
// Price 是单价。
Price *Decimal `json:"price,omitempty"`
// Amount 是金额。
Amount *Decimal `json:"amount,omitempty"`
// TaxRate 是税率(小数形式,如 0.13)。
TaxRate *Decimal `json:"tax_rate,omitempty"`
// TaxAmount 是税额。
TaxAmount *Decimal `json:"tax_amount,omitempty"`
}
Detail 是一行价税明细。
type Group ¶ added in v0.1.5
type Group struct {
Key string `json:"key"`
Name string `json:"name"`
Count int `json:"count"`
Red int `json:"red,omitempty"`
Amount *Decimal `json:"amount,omitempty"`
TaxAmount *Decimal `json:"tax_amount,omitempty"`
TotalAmount *Decimal `json:"total_amount,omitempty"`
}
Group 是一个维度的汇总行。Key 是分组键(税号、发票类型或 YYYY-MM), Name 是展示名;某张票在该维度上没有值时归入「未知」而不是被丢弃。
type Invoice ¶
type Invoice struct {
// Title 是票面标题,例如「增值税电子普通发票」。
Title string `json:"title,omitempty"`
// Type 是由标题推断的发票类型:普通发票、专用发票、通行费或铁路电子客票。
Type string `json:"type,omitempty"`
// MachineNumber 是机器编号。
MachineNumber string `json:"machine_number,omitempty"`
// Code 是发票代码。
Code string `json:"code,omitempty"`
// Number 是发票号码。
Number string `json:"number,omitempty"`
// Date 是开票日期(通常为 YYYY-MM-DD)。
Date string `json:"date,omitempty"`
// Checksum 是发票校验码。
Checksum string `json:"checksum,omitempty"`
// TotalAmountString 是价税合计的大写金额文本,来自页面文字层。
TotalAmountString string `json:"total_amount_string,omitempty"`
// Amount 是不含税金额。
Amount *Decimal `json:"amount,omitempty"`
// TaxAmount 是税额。
TaxAmount *Decimal `json:"tax_amount,omitempty"`
// TotalAmount 是价税合计(小写)。
TotalAmount *Decimal `json:"total_amount,omitempty"`
// TaxControlCode 是税控码。
TaxControlCode string `json:"tax_control_code,omitempty"`
// Payee 是收款人。
Payee string `json:"payee,omitempty"`
// Reviewer 是复核人。
Reviewer string `json:"reviewer,omitempty"`
// Drawer 是开票人。
Drawer string `json:"drawer,omitempty"`
// Buyer 是购买方信息。
Buyer Party `json:"buyer"`
// Seller 是销售方信息。
Seller Party `json:"seller"`
// Details 是商品或劳务明细行。
Details []Detail `json:"details"`
// Attachment 是实际使用到的结构化附件或自定义标引数据文件的包内路径。
Attachment string `json:"attachment"`
// Warnings 是抽取过程中的非致命提示。
Warnings []string `json:"warnings,omitempty"`
}
Invoice 是一张电子发票的抽取结果。
func Extract ¶
Extract 从 OFD 电子发票中抽取发票信息。input 支持文件路径(string)、 文件数据([]byte)或 io.Reader。
抽取以 OFD 包内嵌的结构化附件为主:先尝试 Doc_0/Attachs/original_invoice.xml, 不存在时扫描所有包含 attach 的 XML 条目,取第一个带发票代码、铁路电子客票号 或价税合计的文件。没有有效附件时,尝试按文档自定义标引中的 ObjectRef 读取发票字段。 之后读取页面文字层补齐票面标题与价税合计大写金额,并按标题推断发票类型。
附件或自定义标引存在但没有有效发票数据时返回 ErrNotInvoice,不返回空壳。
Example ¶
ExampleExtract 演示从包含发票结构化附件的 OFD 字节中抽取发票信息。
package main
import (
"bytes"
"fmt"
"github.com/klauspost/compress/zip"
"github.com/zc310/ofd/pkg/invoice"
)
func main() {
data := sampleInvoiceOFD()
got, err := invoice.Extract(data)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(got.Type)
fmt.Println(got.Code, got.Number)
fmt.Println(got.TotalAmountString)
fmt.Println(got.Buyer.Name)
fmt.Println(got.Details[0].Name, got.Details[0].TaxRate)
}
// sampleInvoiceOFD 构造一个最小 OFD,包含发票结构化附件与票面文字层。
func sampleInvoiceOFD() []byte {
files := map[string]string{
"OFD.xml": `<?xml version="1.0" encoding="UTF-8"?>
<OFD xmlns="http://www.ofdspec.org/2016" Version="1.0" DocType="OFD">
<DocBody>
<DocInfo><DocID>invoice</DocID><Title>发票</Title></DocInfo>
<DocRoot>Doc_0/Document.xml</DocRoot>
</DocBody>
</OFD>`,
"Doc_0/Document.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="http://www.ofdspec.org/2016">
<CommonData>
<MaxUnitID>10</MaxUnitID>
<PageArea><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></PageArea>
</CommonData>
<Pages><Page ID="1" BaseLoc="Pages/Page_0/Content.xml"/></Pages>
</Document>`,
"Doc_0/Pages/Page_0/Content.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Page xmlns="http://www.ofdspec.org/2016">
<Area><PhysicalBox>0 0 210 297</PhysicalBox><ApplicationBox>0 0 210 297</ApplicationBox></Area>
<Content>
<Layer ID="1" Type="Body">
<TextObject ID="1" Boundary="70 10 70 8" Font="1" Size="4"><TextCode X="30" Y="20">增值税电子普通发票</TextCode></TextObject>
<TextObject ID="2" Boundary="110 260 60 6" Font="1" Size="3"><TextCode X="100" Y="262">价税合计(大写)壹佰壹拾叁元整</TextCode></TextObject>
</Layer>
</Content>
</Page>`,
"Doc_0/Attachs/original_invoice.xml": `<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:cn:gov:tax:invoice">
<MachineNo>444000224046</MachineNo>
<InvoiceCode>033001900111</InvoiceCode>
<InvoiceNo>12345678</InvoiceNo>
<IssueDate>2026-09-18</IssueDate>
<InvoiceCheckCode>6A5B2C3D1E4F</InvoiceCheckCode>
<TaxExclusiveTotalAmount>100.00</TaxExclusiveTotalAmount>
<TaxTotalAmount>13.00</TaxTotalAmount>
<TaxInclusiveTotalAmount>113.00</TaxInclusiveTotalAmount>
<Payee>王五</Payee>
<Checker>赵六</Checker>
<InvoiceClerk>张三</InvoiceClerk>
<Buyer><BuyerName>买方公司</BuyerName><BuyerTaxID>911100000000000000</BuyerTaxID></Buyer>
<Seller><SellerName>卖方公司</SellerName><SellerTaxID>911100001111000000</SellerTaxID></Seller>
<GoodsInfos>
<G1>
<Item>办公用品*中性笔</Item>
<Specification>0.5mm</Specification>
<MeasurementDimension>支</MeasurementDimension>
<Quantity>10.00</Quantity>
<Price>2.00</Price>
<Amount>20.00</Amount>
<TaxScheme>13%</TaxScheme>
<TaxAmount>2.60</TaxAmount>
</G1>
</GoodsInfos>
</Invoice>`,
}
var data bytes.Buffer
archive := zip.NewWriter(&data)
for name, value := range files {
writer, err := archive.Create(name)
if err != nil {
panic(err)
}
if _, err := writer.Write([]byte(value)); err != nil {
panic(err)
}
}
if err := archive.Close(); err != nil {
panic(err)
}
return data.Bytes()
}
Output: 普通发票 033001900111 12345678 壹佰壹拾叁元整 买方公司 办公用品*中性笔 0.13
Example (CustomTags) ¶
ExampleExtract_customTags 演示没有发票附件、通过自定义标引引用页面字段的 OFD。
package main
import (
"bytes"
"fmt"
"github.com/klauspost/compress/zip"
"github.com/zc310/ofd/pkg/invoice"
)
func main() {
files := map[string]string{
"OFD.xml": `<OFD xmlns="http://www.ofdspec.org/2016" Version="1.0" DocType="OFD">
<DocBody><DocInfo><DocID>invoice</DocID></DocInfo><DocRoot>Doc_0/Document.xml</DocRoot></DocBody></OFD>`,
"Doc_0/Document.xml": `<Document xmlns="http://www.ofdspec.org/2016">
<CommonData><MaxUnitID>10</MaxUnitID><PageArea><PhysicalBox>0 0 210 140</PhysicalBox></PageArea></CommonData>
<Pages><Page ID="1" BaseLoc="Pages/Page_0/Content.xml"/></Pages><CustomTags>Tags/list.xml</CustomTags></Document>`,
"Doc_0/Tags/list.xml": `<CustomTags xmlns="http://www.ofdspec.org/2016">
<CustomTag TypeID="invoice"><FileLoc>invoice.xml</FileLoc></CustomTag></CustomTags>`,
"Doc_0/Tags/invoice.xml": `<root>
<InvoiceNo><ObjectRef PageRef="1">2</ObjectRef></InvoiceNo>
<TaxInclusiveTotalAmount><ObjectRef PageRef="1">3</ObjectRef></TaxInclusiveTotalAmount></root>`,
"Doc_0/Pages/Page_0/Content.xml": `<Page xmlns="http://www.ofdspec.org/2016"><Content><Layer ID="4">
<TextObject ID="1" Boundary="50 10 80 8" Font="1" Size="4"><TextCode>电子发票(普通发票)</TextCode></TextObject>
<TextObject ID="2" Boundary="150 20 40 5" Font="1" Size="3"><TextCode>12345678</TextCode></TextObject>
<TextObject ID="3" Boundary="150 100 40 5" Font="1" Size="3"><TextCode>113.00</TextCode></TextObject>
</Layer></Content></Page>`,
}
var data bytes.Buffer
archive := zip.NewWriter(&data)
for name, content := range files {
writer, err := archive.Create(name)
if err != nil {
panic(err)
}
if _, err := writer.Write([]byte(content)); err != nil {
panic(err)
}
}
if err := archive.Close(); err != nil {
panic(err)
}
got, err := invoice.Extract(data.Bytes())
if err != nil {
fmt.Println(err)
return
}
fmt.Println(got.Type)
fmt.Println(got.Number)
fmt.Println(got.TotalAmount.Fixed(invoice.MoneyScale))
fmt.Println(got.Attachment)
}
Output: 普通发票 12345678 113.00 Doc_0/Tags/invoice.xml
func (*Invoice) Check ¶ added in v0.1.5
Check 返回发票自身的自洽性问题描述,空切片表示没有发现异常。
这些检查不依赖外部数据,只比对同一张票内部各处的金额是否对得上: 价税合计是否等于不含税金额加税额、逐行明细汇总是否等于票面金额、开票日期 是否可识别。附件被替换或损坏时这些关系会先失衡,因此 Check 是入账前最廉价 的一道拦截。
func (*Invoice) ID ¶ added in v0.1.5
ID 返回发票的稳定标识。发票代码与号码都在时返回「代码-号码」,只有号码时返回 号码,两样都缺才返回空字符串。
全电发票(数电票)自落地起就没有发票代码,只有 20 位全电发票号码,铁路电子客票 同理——它的号码在附件的 context/entity/identifier 里就是这张票的标识。原先要求 代码与号码同时在场,这些票的 ID 全是空串:Check 逐条报「无法唯一标识」,去重也 永远认不出它们。两样皆无才是真的无法定位。
func (*Invoice) InvoiceDate ¶ added in v0.1.5
InvoiceDate 解析开票日期。附件未写日期时返回零值时间。
type Item ¶ added in v0.1.5
type Item struct {
Name string `json:"name"`
Invoice *Invoice `json:"invoice,omitempty"`
Error string `json:"error,omitempty"`
DuplicateOf string `json:"duplicate_of,omitempty"`
Issues []string `json:"issues,omitempty"`
Red bool `json:"red,omitempty"`
// contains filtered or unexported fields
}
Item 是单个输入的处理结果。抽取失败时 Invoice 为 nil、Error 说明原因; 抽取成功但与先出现的发票重复时 DuplicateOf 指向那张发票的来源名。
type MergeLimits ¶ added in v0.1.5
type MergeLimits struct {
// MaxInputBytes 是单个输入的最大字节数,0 表示默认 64MB。
MaxInputBytes int64
// MaxTotalBytes 是所有输入的总字节上限,0 表示默认 512MB。
MaxTotalBytes int64
// MaxInvoices 是最多合并的发票张数,0 表示默认 1000。
MaxInvoices int
}
MergeLimits 限制合并的规模。发票文件通常只有几十 KB,默认值主要用来挡住 误传进来的超大文件,而不是日常会碰到的限制。
type MergeOptions ¶ added in v0.1.5
type MergeOptions struct {
// BatchOptions 决定抽取失败与重复发票的处理方式,与汇总共用同一套口径:
// 合并 PDF 只会包含 ActiveItems 返回的那些票。
BatchOptions
// Sort 是页序排序方式,取 SortItems 的同名常量,默认按输入顺序。
Sort string
// Cover 为 true 时在首页插入一张汇总对账页。
Cover bool
// CoverTitle 是汇总页标题,为空时使用默认标题。
CoverTitle string
// Pages 决定每张票输出哪些页面:all(默认,全部页)、first(只取第 1 页)、
// face(只取票面页)或页码表达式(如 "1,3-5",按票内页码)。
Pages string
// Paper 是输出纸张名称(A4/A3/A5/Letter/Legal/B5/16开)。为空且 NUp 为 1 时
// 按每页自身尺寸出纸;拼版时默认 A4。
Paper string
// Orientation 是纸张方向:portrait(默认,纵向)与 landscape。缺省纵向是因为
// 发票多为宽而矮的版式,纵向拼接能保持原始尺寸、空白最少。
Orientation string
// NUp 是一张纸上放几页源页面,可取 1、2、3、4、6、8、9,0 与 1 等价。
NUp int
// NUpGap 是拼版格子之间的间距(毫米),默认 0。
NUpGap float64
// CellBorder 为真时给每个格子描一圈细线,方便打印后裁切。
CellBorder bool
// Fit 是源页放入格子的适配方式,目前只有 FitContain(等比缩放居中补白)。
Fit string
// Limits 限制输入规模,零值使用默认限制。
Limits MergeLimits
}
MergeOptions 控制多张发票合并为单个 PDF 的方式。
type Party ¶
type Party struct {
// Name 是名称。
Name string `json:"name,omitempty"`
// Code 是纳税人识别号。
Code string `json:"code,omitempty"`
// Address 是地址电话。
Address string `json:"address,omitempty"`
// Account 是开户行及账号。
Account string `json:"account,omitempty"`
}
Party 是发票一方的资料(购买方或销售方)。
type Source ¶ added in v0.1.5
Source 是一个待抽取的发票输入。Input 的类型与 Extract 相同(文件路径、 字节切片或 io.Reader);Name 用于报告与错误定位,为空时从路径推断。
type Summary ¶ added in v0.1.5
type Summary struct {
// Count 是参与汇总的张数。
Count int `json:"count"`
// Red 是其中红字(红冲)发票的张数。
Red int `json:"red,omitempty"`
// Amount 是不含税金额合计,TaxAmount 是税额合计,TotalAmount 是价税合计。
Amount *Decimal `json:"amount,omitempty"`
TaxAmount *Decimal `json:"tax_amount,omitempty"`
TotalAmount *Decimal `json:"total_amount,omitempty"`
// BySeller、ByType、ByMonth 是三个维度的分组合计,按价税合计降序排列。
BySeller []Group `json:"by_seller,omitempty"`
ByType []Group `json:"by_type,omitempty"`
ByMonth []Group `json:"by_month,omitempty"`
}
Summary 是一组发票的合计与分组。