mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 14:53:44 +08:00
1 parent
2cf96a872a
commit
d3a713f504
4 files changed
+1534
-2
No files matched your search
@@ -261,5 +261,38 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
# 提取发票
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 打开OFD文件
|
||||
reader, err := ofdgo.Open("invoice.ofd")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
// 2. 提取发票信息
|
||||
report, err := reader.Invoices()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// 3. 输出提取结果
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 授权协议
|
||||
本项目使用 [Apache License 2.0](https://github.com/xiaoqidun/ofdgo/blob/main/LICENSE) 授权协议
|
||||
@@ -0,0 +1,381 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 发票读取限额,不扫描未声明资源或嵌套附件包
|
||||
const (
|
||||
invoiceXMLLimit = 16 << 20
|
||||
invoiceTotalLimit = 64 << 20
|
||||
invoiceSourceLimit = 256
|
||||
)
|
||||
|
||||
// InvoiceReport 保存当前文档及版本的提取结果,不代表验签或联网查验结论
|
||||
// 不同载荷中的记录保持独立,未识别格式及局部错误通过Issues报告
|
||||
// UnassignedFacts保留无法归属已知发票上下文的XBRL事实,不跨主体合并
|
||||
type InvoiceReport struct {
|
||||
DocumentIndex int `json:"documentIndex"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Invoices []Invoice `json:"invoices,omitempty"`
|
||||
UnassignedFacts []InvoiceFact `json:"unassignedFacts,omitempty"`
|
||||
Issues []InvoiceIssue `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
// Invoice 保存业务规范明确映射的发票字段,空字符串表示未提供或无法唯一确定
|
||||
// 金额、数量、税率保留十进制原文,号码和税号不转换为数字;Facts保留原始业务事实
|
||||
type Invoice struct {
|
||||
Kind string `json:"kind"`
|
||||
Standard string `json:"standard"`
|
||||
Version string `json:"version"`
|
||||
Number string `json:"number,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Amount string `json:"amount,omitempty"`
|
||||
TaxAmount string `json:"taxAmount,omitempty"`
|
||||
TotalAmount string `json:"totalAmount,omitempty"`
|
||||
Remarks string `json:"remarks,omitempty"`
|
||||
Buyer InvoiceParty `json:"buyer"`
|
||||
Seller InvoiceParty `json:"seller"`
|
||||
Items []InvoiceItem `json:"items,omitempty"`
|
||||
Sources []InvoiceSource `json:"sources"`
|
||||
Facts []InvoiceFact `json:"facts,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceParty 保存购销双方信息,不从名称或其他字段推测税号
|
||||
type InvoiceParty struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
TaxID string `json:"taxId,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Bank string `json:"bank,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceItem 保存明细公共字段,航段、旅客及其他票种特有字段通过Fields保留
|
||||
type InvoiceItem struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Specification string `json:"specification,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Quantity string `json:"quantity,omitempty"`
|
||||
UnitPrice string `json:"unitPrice,omitempty"`
|
||||
Amount string `json:"amount,omitempty"`
|
||||
TaxRate string `json:"taxRate,omitempty"`
|
||||
TaxAmount string `json:"taxAmount,omitempty"`
|
||||
Fields []InvoiceFact `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceSource 标识包内来源,Kind为tag、extension或attachment,NodePath为XML节点路径
|
||||
type InvoiceSource struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id,omitempty"`
|
||||
FileLoc string `json:"fileLoc"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceIssue 保存来源明确的解析诊断,Code为机器可读类别,不包含真伪判断
|
||||
type InvoiceIssue struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Source InvoiceSource `json:"source"`
|
||||
Field string `json:"field,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceFact 保存命名空间限定的业务事实、原始数值及精度属性,Nil与空文本分别表达
|
||||
type InvoiceFact struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Decimals string `json:"decimals,omitempty"`
|
||||
Precision string `json:"precision,omitempty"`
|
||||
Nil bool `json:"nil,omitempty"`
|
||||
Source InvoiceSource `json:"source"`
|
||||
Dimensions []InvoiceDimension `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceDimension 保存上下文维度,名称和成员的命名空间不依赖XML前缀
|
||||
type InvoiceDimension struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Invoices 提取当前文档及版本中的结构化发票,不执行票面识别、验签或网络请求
|
||||
// 返回: InvoiceReport 发票及诊断, error 文档读取错误
|
||||
func (r *Reader) Invoices() (InvoiceReport, error) {
|
||||
return r.InvoicesContext(context.Background())
|
||||
}
|
||||
|
||||
// InvoicesContext 按标引、扩展及附件入口提取发票,取消或整体限额失败时不返回部分结果
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: InvoiceReport 独立发票及诊断, error 文档、取消或限额错误
|
||||
func (r *Reader) InvoicesContext(ctx context.Context) (InvoiceReport, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
if r == nil {
|
||||
return InvoiceReport{}, fmt.Errorf("nil document reader")
|
||||
}
|
||||
doc, err := r.docStructureContext(ctx)
|
||||
if err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
report := InvoiceReport{DocumentIndex: r.DocumentIndex()}
|
||||
if r.versionInfo != nil {
|
||||
report.Version = r.versionInfo.ID
|
||||
}
|
||||
total, count := 0, 0
|
||||
seen := make(map[string][]int)
|
||||
consume := func(source InvoiceSource, data []byte, readErr error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
total += len(data)
|
||||
if count > invoiceSourceLimit || total > invoiceTotalLimit {
|
||||
return fmt.Errorf("invoice extraction limit exceeded")
|
||||
}
|
||||
if readErr != nil {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "read-error", Message: readErr.Error(), Source: source})
|
||||
return nil
|
||||
}
|
||||
parsed, err := parseInvoiceXML(ctx, data, source)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-data", Message: err.Error(), Source: source})
|
||||
return nil
|
||||
}
|
||||
report.Invoices = append(report.Invoices, parsed.Invoices...)
|
||||
report.UnassignedFacts = append(report.UnassignedFacts, parsed.UnassignedFacts...)
|
||||
report.Issues = append(report.Issues, parsed.Issues...)
|
||||
return nil
|
||||
}
|
||||
read := func(source InvoiceSource) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
resolved := r.ResPath(source.FileLoc)
|
||||
if !validPackagePath(resolved) {
|
||||
return consume(source, nil, fmt.Errorf("invalid invoice resource path"))
|
||||
}
|
||||
source.FileLoc = resolved
|
||||
if indexes, ok := seen[resolved]; ok {
|
||||
count++
|
||||
if count > invoiceSourceLimit {
|
||||
return fmt.Errorf("invoice extraction limit exceeded")
|
||||
}
|
||||
for _, index := range indexes {
|
||||
report.Invoices[index].Sources = append(report.Invoices[index].Sources, source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
before := len(report.Invoices)
|
||||
data, err := r.invoiceData(ctx, resolved)
|
||||
if err := consume(source, data, err); err != nil {
|
||||
return err
|
||||
}
|
||||
seen[resolved] = nil
|
||||
for i := before; i < len(report.Invoices); i++ {
|
||||
seen[resolved] = append(seen[resolved], i)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tags, err := r.CustomTagsContext(ctx)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "read-error", Message: err.Error(), Source: InvoiceSource{Kind: "tag"}})
|
||||
}
|
||||
for i, tag := range tags {
|
||||
if tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace {
|
||||
continue
|
||||
}
|
||||
if err := read(InvoiceSource{Kind: "tag", ID: strconv.Itoa(i), FileLoc: tag.FileLoc}); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
}
|
||||
extensions, err := r.ExtensionsContext(ctx)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "read-error", Message: err.Error(), Source: InvoiceSource{Kind: "extension"}})
|
||||
}
|
||||
inline := false
|
||||
for i, extension := range extensions {
|
||||
inline = inline || len(extension.Data) > 0
|
||||
for _, location := range extension.ExtendData {
|
||||
if err := read(InvoiceSource{Kind: "extension", ID: strconv.Itoa(i), FileLoc: location}); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if inline {
|
||||
location := r.documentRoot
|
||||
if strings.TrimSpace(doc.Extensions.Path) != "" {
|
||||
location = r.ResPath(doc.Extensions.Path)
|
||||
}
|
||||
data, err := r.invoiceData(ctx, location)
|
||||
if err == nil {
|
||||
err = invoiceExtensionData(ctx, data, location, consume)
|
||||
}
|
||||
if count > invoiceSourceLimit || total > invoiceTotalLimit {
|
||||
return InvoiceReport{}, fmt.Errorf("invoice extraction limit exceeded")
|
||||
}
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-data", Message: err.Error(), Source: InvoiceSource{Kind: "extension", FileLoc: location}})
|
||||
}
|
||||
}
|
||||
attachments, err := r.AttachmentsContext(ctx)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "read-error", Message: err.Error(), Source: InvoiceSource{Kind: "attachment"}})
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
format := strings.ToLower(strings.TrimSpace(attachment.Format))
|
||||
ext := strings.ToLower(path.Ext(attachment.FileLoc))
|
||||
if format != "xml" && format != "xbrl" && ext != ".xml" && ext != ".xbrl" {
|
||||
continue
|
||||
}
|
||||
if err := read(InvoiceSource{Kind: "attachment", ID: attachment.ID, FileLoc: attachment.FileLoc}); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
invoiceSourceConflicts(&report)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
if len(report.Invoices) == 0 && len(report.Issues) == 0 {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "missing-data", Message: "no structured invoice data"})
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// invoiceSourceConflicts 报告同票种、代码和号码的跨来源字段差异,不合并独立记录
|
||||
// 入参: report 当前文档及版本的发票报告
|
||||
func invoiceSourceConflicts(report *InvoiceReport) {
|
||||
seen := make(map[[3]string]map[string]string)
|
||||
for _, invoice := range report.Invoices {
|
||||
if invoice.Number == "" {
|
||||
continue
|
||||
}
|
||||
key := [3]string{invoice.Kind, invoice.Code, invoice.Number}
|
||||
previous := seen[key]
|
||||
if previous == nil {
|
||||
previous = make(map[string]string)
|
||||
seen[key] = previous
|
||||
}
|
||||
fields := []struct{ name, value string }{
|
||||
{"date", invoice.Date}, {"currency", invoice.Currency}, {"status", invoice.Status},
|
||||
{"amount", invoice.Amount}, {"taxAmount", invoice.TaxAmount}, {"totalAmount", invoice.TotalAmount},
|
||||
{"buyer.name", invoice.Buyer.Name}, {"buyer.taxId", invoice.Buyer.TaxID},
|
||||
{"seller.name", invoice.Seller.Name}, {"seller.taxId", invoice.Seller.TaxID},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.value == "" {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(field.value)
|
||||
if old, ok := previous[field.name]; ok && old != value {
|
||||
if (field.name == "amount" || field.name == "taxAmount" || field.name == "totalAmount") && invoiceDecimalEqual(old, value) {
|
||||
continue
|
||||
}
|
||||
source := InvoiceSource{}
|
||||
if len(invoice.Sources) > 0 {
|
||||
source = invoice.Sources[0]
|
||||
}
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "source-conflict", Message: "invoice field differs between sources", Source: source, Field: field.name})
|
||||
} else {
|
||||
previous[field.name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invoiceData 有界读取包内候选载荷,解压及读取期间响应取消
|
||||
// 入参: ctx 取消上下文, location 规范化包内路径
|
||||
// 返回: []byte XML数据, error 路径、限额、读取或取消错误
|
||||
func (r *Reader) invoiceData(ctx context.Context, location string) ([]byte, error) {
|
||||
if !validPackagePath(location) {
|
||||
return nil, fmt.Errorf("invalid invoice resource path")
|
||||
}
|
||||
stream, err := r.openFile(location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(imageInput{ReadCloser: stream, context: ctx}, invoiceXMLLimit+1))
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if len(data) > invoiceXMLLimit {
|
||||
return data, fmt.Errorf("invoice XML size limit exceeded")
|
||||
}
|
||||
return data, ctx.Err()
|
||||
}
|
||||
|
||||
// invoiceExtensionData 提取扩展Data直接子树,复用XML片段编码并保留继承的命名空间
|
||||
// 入参: ctx 取消上下文, data 扩展或文档XML, location 包内位置, consume 候选处理函数
|
||||
// 返回: error XML、限额或取消错误
|
||||
func invoiceExtensionData(ctx context.Context, data []byte, location string, consume func(InvoiceSource, []byte, error) error) error {
|
||||
if _, err := parseStructure(ctx, data, CustomTag{}); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if root.name.Local == "Document" {
|
||||
root = root.child("Extensions")
|
||||
}
|
||||
if root == nil || root.name.Local != "Extensions" || classifyOFDNamespace(root.name.Space) == ofdXMLUnknown {
|
||||
return fmt.Errorf("invalid invoice extension container")
|
||||
}
|
||||
i := 0
|
||||
for _, extension := range root.children {
|
||||
if extension.name.Local != "Extension" || !ofdXMLChild(root.name, extension.name) {
|
||||
continue
|
||||
}
|
||||
j := 0
|
||||
for _, entry := range extension.children {
|
||||
if entry.name.Local != "Data" || !ofdXMLChild(extension.name, entry.name) {
|
||||
continue
|
||||
}
|
||||
for k, node := range entry.children {
|
||||
source := InvoiceSource{Kind: "extension", ID: strconv.Itoa(i), FileLoc: location, NodePath: fmt.Sprintf("/Extensions/Extension[%d]/Data[%d]/*[%d]", i+1, j+1, k+1)}
|
||||
payload, err := editorXMLStandaloneContext(ctx, data[node.start:node.end], node)
|
||||
if err := consume(source, payload, err); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+18
-2
@@ -674,17 +674,27 @@ func (r *Reader) CustomDatas() ([]CustomData, error) {
|
||||
// 返回的文件路径可直接传入ResData读取
|
||||
// 返回: []Extension 扩展项列表, error 错误信息
|
||||
func (r *Reader) Extensions() ([]Extension, error) {
|
||||
doc, err := r.docStructure()
|
||||
return r.ExtensionsContext(context.Background())
|
||||
}
|
||||
|
||||
// ExtensionsContext 读取当前文档及版本的扩展项,取消或失败时不缓存未完成清单
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: []Extension 扩展项列表, error 读取、解析或取消错误
|
||||
func (r *Reader) ExtensionsContext(ctx context.Context) ([]Extension, error) {
|
||||
doc, err := r.docStructureContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(doc.Extensions.Path) != "" && doc.Extensions.Extension == nil {
|
||||
var extensions Extensions
|
||||
partPath, err := r.readDocumentPart(doc.Extensions.Path, &extensions)
|
||||
partPath, err := r.readDocumentPartContext(ctx, doc.Extensions.Path, &extensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range extensions.Extension {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extension := &extensions.Extension[i]
|
||||
for j := range extension.ExtendData {
|
||||
extension.ExtendData[j] = r.resourceLocation(partPath, "", extension.ExtendData[j])
|
||||
@@ -693,8 +703,14 @@ func (r *Reader) Extensions() ([]Extension, error) {
|
||||
if extensions.Extension == nil {
|
||||
extensions.Extension = []Extension{}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Extensions.Extension = extensions.Extension
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc.Extensions.Extension, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user