Help us shape the future of Go! Take the 2026 Go Developer Survey

invoice

package
v0.1.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: Apache-2.0 Imports: 29 Imported by: 1

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

Examples

Constants

View Source
const (
	// SortInput 保持输入顺序,是默认值。
	SortInput = "input"
	// SortDate 按开票日期升序。
	SortDate = "date"
	// SortCode 按发票代码与号码升序。
	SortCode = "code"
	// SortName 按来源名升序。
	SortName = "name"
)

SortItems 支持的排序方式。

View Source
const (
	// FitContain 等比缩放并居中补白,是唯一支持的适配方式。
	//
	// 不提供"填满裁切":裁切会切掉票面边缘的印章与价税合计,对发票来说等于毁票。
	FitContain = "contain"
	// PagesAll 保留每张票的全部页面。
	PagesAll = "all"
	// PagesFirst 只取每张票的第 1 页。
	PagesFirst = "first"
	// PagesFace 只取每张票的票面页(页面上有发票代码或价税合计字样)。
	PagesFace = "face"
	// OrientationAuto 按实际票源挑占用率更高的方向,是默认值。
	OrientationAuto = "auto"
	// OrientationPortrait 固定纵向。
	OrientationPortrait = "portrait"
	// OrientationLandscape 固定横向,格子宽高随纸张一起交换。
	OrientationLandscape = "landscape"
)

拼版与裁页的取值。

View Source
const MoneyScale = 2

MoneyScale 是金额保留的小数位数,即最小货币单位「分」。

Variables

View Source
var ErrNoAttachment = errors.New("未找到 OFD 发票结构化附件(Attachs 目录下的 XML)")

ErrNoAttachment 表示 OFD 包内未找到发票结构化附件。

View Source
var ErrNotInvoice = errors.New("附件不是发票数据:既无发票代码或铁路电子客票号,也无价税合计")

ErrNotInvoice 表示附件存在,但里面不是发票数据:既没有发票代码或铁路电子 客票号,也没有价税合计。

与 ErrNoAttachment 分开是因为两者的后续动作不同:找不到附件时厂商可能把附件放在了 别处(值得确认),而附件里没有发票数据就是这份文档本身不是发票(没什么可查)。

Functions

func SortItems added in v0.1.5

func SortItems(items []Item, key string) error

SortItems 按 key 就地重排 items,让同一批发票的汇总、报表和合并 PDF 保持同一次序。 支持 SortInput(保持输入顺序)、SortDate(开票日期)、SortCode(发票代码与号码) 和 SortName(来源名);无法排序的值排在最后,同值之间保持输入顺序。

func WriteCSV added in v0.1.5

func WriteCSV(w io.Writer, batch *Batch) error

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

func WriteXLSX(w io.Writer, batch *Batch) error

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))
	}
	// 分组按价税合计降序排列,因此十月在九月之前。
	

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

func (b *Batch) ActiveItems() []Item

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

func NewDecimal(text string) (*Decimal, bool)

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

func Sum(values []*Decimal) *Decimal

Sum 返回一组数值之和,逐项跳过 nil。全部为 nil 时返回 nil(区别于合计为零), 以便调用方判断「这批票根本没有金额」与「这批票金额都是 0」。

func (*Decimal) Abs added in v0.1.5

func (d *Decimal) Abs() *Decimal

Abs 返回绝对值,nil 返回 nil。

func (*Decimal) Add added in v0.1.5

func (d *Decimal) Add(other *Decimal) *Decimal

Add 返回两数之和。任一操作数为 nil 时返回 nil:这个数在票面上不存在, 把它当成 0 参与求和会让「缺字段」与「金额为零」无法区分。

func (*Decimal) Cmp added in v0.1.5

func (d *Decimal) Cmp(other *Decimal) int

Cmp 比较两个数值,返回 -1、0 或 1。nil 视为零,因此 nil 与 0 比较结果为 0。

func (*Decimal) Fixed added in v0.1.5

func (d *Decimal) Fixed(places int) string

Fixed 返回固定 places 位小数的字符串表示,用于报表定宽输出。 金额字段用 String() 即可(最少十进制形式),需要「1280.00」这种定宽形态时 用 Fixed(MoneyScale)。

func (*Decimal) IsNegative added in v0.1.5

func (d *Decimal) IsNegative() bool

IsNegative 判断金额是否为负。红字(红冲)发票的金额为负,是正常业务数据, 汇总时必须参与求和,不能当异常丢弃。

func (*Decimal) IsZero added in v0.1.5

func (d *Decimal) IsZero() bool

IsZero 判断数值是否为零;nil 与零值都返回 true。

func (*Decimal) MarshalJSON

func (d *Decimal) MarshalJSON() ([]byte, error)

MarshalJSON 把 Decimal 输出为十进制字符串。

func (*Decimal) Money added in v0.1.5

func (d *Decimal) Money() *Decimal

Money 返回按「分」四舍五入后的金额。

func (*Decimal) Mul added in v0.1.5

func (d *Decimal) Mul(other *Decimal) *Decimal

Mul 返回两数之积。任一操作数为 nil 时返回 nil。

func (*Decimal) Neg added in v0.1.5

func (d *Decimal) Neg() *Decimal

Neg 返回相反数,nil 返回 nil。

func (*Decimal) Round added in v0.1.5

func (d *Decimal) Round(places int) *Decimal

Round 返回按 places 位小数四舍五入的结果,恰好在中点时远离零(half away from zero),与财务票据的进位习惯一致。places 为负时按 0 处理,nil 返回 nil。

不做舍入的求和会输出 64 位小数(例如 1/3 展开成 0.333…),这种数字无法对账, 金额合计必须先过 Round(MoneyScale)。

func (*Decimal) Sign added in v0.1.5

func (d *Decimal) Sign() int

Sign 返回数值的符号:负数返回 -1,零返回 0,正数返回 1。nil 视为零。

func (*Decimal) String

func (d *Decimal) String() string

String 返回 Decimal 的十进制字符串表示。

func (*Decimal) Sub added in v0.1.5

func (d *Decimal) Sub(other *Decimal) *Decimal

Sub 返回 d-other。任一操作数为 nil 时返回 nil。

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

func Extract(input any) (*Invoice, error)

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

func (i *Invoice) Check() []string

Check 返回发票自身的自洽性问题描述,空切片表示没有发现异常。

这些检查不依赖外部数据,只比对同一张票内部各处的金额是否对得上: 价税合计是否等于不含税金额加税额、逐行明细汇总是否等于票面金额、开票日期 是否可识别。附件被替换或损坏时这些关系会先失衡,因此 Check 是入账前最廉价 的一道拦截。

func (*Invoice) ID added in v0.1.5

func (i *Invoice) ID() string

ID 返回发票的稳定标识。发票代码与号码都在时返回「代码-号码」,只有号码时返回 号码,两样都缺才返回空字符串。

全电发票(数电票)自落地起就没有发票代码,只有 20 位全电发票号码,铁路电子客票 同理——它的号码在附件的 context/entity/identifier 里就是这张票的标识。原先要求 代码与号码同时在场,这些票的 ID 全是空串:Check 逐条报「无法唯一标识」,去重也 永远认不出它们。两样皆无才是真的无法定位。

func (*Invoice) InvoiceDate added in v0.1.5

func (i *Invoice) InvoiceDate() (time.Time, error)

InvoiceDate 解析开票日期。附件未写日期时返回零值时间。

func (*Invoice) IsRed added in v0.1.5

func (i *Invoice) IsRed() bool

IsRed 判断是否为红字(红冲)发票:价税合计为负。

红字发票是正常业务数据,汇总时金额必须带符号参与求和,把它当异常过滤掉 会让合计凭空多出一笔。

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 指向那张发票的来源名。

func (Item) OK added in v0.1.5

func (i Item) OK() bool

OK 判断该输入是否抽取成功。

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 Skipped added in v0.1.5

type Skipped struct {
	Name   string `json:"name"`
	Reason string `json:"reason"`
}

Skipped 记录未参与合计的输入及其原因。

type Source added in v0.1.5

type Source struct {
	Name  string
	Input any
}

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 是一组发票的合计与分组。

func Summarize added in v0.1.5

func Summarize(invoices []*Invoice) Summary

Summarize 汇总一组发票的金额,并按销售方、发票类型和开票月份分组。

红字(红冲)发票的负金额照常参与求和。合计按「分」四舍五入后输出:不做舍入的 求和会展开成 64 位小数那样的长数字,财务无法对账。某项金额在所有发票里都缺失 时该字段为 nil,而不是 0。

func (Summary) String added in v0.1.5

func (s Summary) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL