From d3a713f504fc24ac3f787df37457183b460cac56 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Fri, 16 Oct 2026 10:26:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=94=B5=E5=AD=90=E5=8F=91=E7=A5=A8):=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8F=91=E7=A5=A8=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=E6=8F=90=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 33 ++ ofdgo_invoice.go | 381 +++++++++++++++ ofdgo_invoice_xml.go | 1102 ++++++++++++++++++++++++++++++++++++++++++ ofdgo_reader.go | 20 +- 4 files changed, 1534 insertions(+), 2 deletions(-) create mode 100644 ofdgo_invoice.go create mode 100644 ofdgo_invoice_xml.go diff --git a/README.md b/README.md index 76e389d..678ce9f 100644 --- a/README.md +++ b/README.md @@ -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) 授权协议 diff --git a/ofdgo_invoice.go b/ofdgo_invoice.go new file mode 100644 index 0000000..49a9734 --- /dev/null +++ b/ofdgo_invoice.go @@ -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 +} diff --git a/ofdgo_invoice_xml.go b/ofdgo_invoice_xml.go new file mode 100644 index 0000000..f9da81b --- /dev/null +++ b/ofdgo_invoice_xml.go @@ -0,0 +1,1102 @@ +// 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 ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "math/big" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// invoiceXBRL 表示XBRL实例命名空间。 +const invoiceXBRL = "http://www.xbrl.org/2003/instance" + +// invoiceXSI 表示XML Schema实例命名空间。 +const invoiceXSI = "http://www.w3.org/2001/XMLSchema-instance" + +// invoiceDecimalPattern 仅接受XML十进制词法。 +var invoiceDecimalPattern = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$`) + +// invoiceXMLNode 保存有界XML节点及原始文本切片。 +type invoiceXMLNode struct { + name xml.Name + attrs []xml.Attr + text strings.Builder + children []*invoiceXMLNode + parent *invoiceXMLNode + ns map[string]string + path string + counts map[xml.Name]int + start int64 + inner []byte + parts []invoiceXMLPart +} + +// invoiceXMLPart 保留混合内容顺序,供类型化维度规范化使用。 +type invoiceXMLPart struct { + text string + child *invoiceXMLNode +} + +// invoiceDecimalEqual 精确比较十进制词法值,不接受指数或分数。 +// 入参: a、b 原始十进制文本。 +// 返回: bool 两个合法值是否相等。 +func invoiceDecimalEqual(a, b string) bool { + x, y := invoiceDecimal(a), invoiceDecimal(b) + return x != nil && y != nil && x.Cmp(y) == 0 +} + +// invoiceDecimal 解析有界十进制文本。 +// 入参: s 原始文本。 +// 返回: *big.Rat 精确数值,非法时为空。 +func invoiceDecimal(s string) *big.Rat { + s = strings.TrimSpace(s) + if len(s) > 256 || !invoiceDecimalPattern.MatchString(s) { + return nil + } + r, ok := new(big.Rat).SetString(s) + if !ok { + return nil + } + return r +} + +// invoiceXMLAttr 按命名空间读取属性。 +// 入参: n 节点,space 命名空间,name 属性名。 +// 返回: string 属性值。 +func invoiceXMLAttr(n *invoiceXMLNode, space, name string) string { + for _, a := range n.attrs { + if a.Name.Space == space && a.Name.Local == name { + return a.Value + } + } + return "" +} + +// invoiceXMLHasAttr 区分缺省属性与空字符串属性。 +// 入参: n 节点,space 命名空间,name 属性名。 +// 返回: 属性是否存在。 +func invoiceXMLHasAttr(n *invoiceXMLNode, space, name string) bool { + for _, a := range n.attrs { + if a.Name == (xml.Name{Space: space, Local: name}) { + return true + } + } + return false +} + +// invoiceXMLTree 读取受限XML树,不解析外部实体。 +// 入参: ctx 取消上下文,data 文档字节。 +// 返回: 根节点及解析错误。 +func invoiceXMLTree(ctx context.Context, data []byte) (*invoiceXMLNode, error) { + if len(data) > 16<<20 { + return nil, fmt.Errorf("invoice XML exceeds size limit") + } + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) + d := xml.NewDecoder(bytes.NewReader(data)) + var root, current *invoiceXMLNode + nodes, depth, bindings, pathBytes := 0, 0, 0, 0 + for { + if err := ctx.Err(); err != nil { + return nil, err + } + before := d.InputOffset() + t, err := d.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + switch v := t.(type) { + case xml.StartElement: + attributes := make(map[xml.Name]bool, len(v.Attr)) + for _, attr := range v.Attr { + if attributes[attr.Name] { + return nil, fmt.Errorf("duplicate invoice XML attribute") + } + attributes[attr.Name] = true + } + nodes++ + depth++ + if nodes > 100000 || depth > 128 { + return nil, fmt.Errorf("invoice XML exceeds structural limit") + } + for _, a := range v.Attr { + if (a.Name.Space == "xmlns" || a.Name.Space == "" && a.Name.Local == "xmlns") && len(a.Value) > 4096 { + return nil, fmt.Errorf("invoice XML exceeds namespace URI limit") + } + } + n := &invoiceXMLNode{name: v.Name, attrs: v.Attr, parent: current, ns: map[string]string{"xml": "http://www.w3.org/XML/1998/namespace"}, counts: map[xml.Name]int{}, start: d.InputOffset()} + index := 1 + if current != nil { + for k, v := range current.ns { + n.ns[k] = v + } + current.counts[n.name]++ + index = current.counts[n.name] + current.children = append(current.children, n) + current.parts = append(current.parts, invoiceXMLPart{child: n}) + n.path = current.path + } + for _, a := range v.Attr { + if a.Name.Space == "xmlns" { + n.ns[a.Name.Local] = a.Value + } + if a.Name.Space == "" && a.Name.Local == "xmlns" { + n.ns[""] = a.Value + } + } + attrs := map[xml.Name]bool{} + for _, a := range v.Attr { + if attrs[a.Name] { + return nil, fmt.Errorf("duplicate XML attribute") + } + attrs[a.Name] = true + } + bindings += len(n.ns) + if bindings > 1000000 { + return nil, fmt.Errorf("invoice XML exceeds namespace limit") + } + indexText := strconv.Itoa(index) + pathLength := len(n.path) + len(n.name.Space) + len(n.name.Local) + len(indexText) + 5 + if pathLength > 16<<10 || pathLength > (64<<20)-pathBytes { + return nil, fmt.Errorf("invoice XML exceeds path limit") + } + pathBytes += pathLength + n.path += "/{" + n.name.Space + "}" + n.name.Local + "[" + indexText + "]" + if current == nil { + if root != nil { + return nil, fmt.Errorf("multiple XML roots") + } + root = n + } + current = n + case xml.EndElement: + if before >= current.start { + current.inner = data[current.start:before] + } + current = current.parent + depth-- + case xml.CharData: + if current != nil { + current.text.Write(v) + current.parts = append(current.parts, invoiceXMLPart{text: string(v)}) + } else if strings.TrimSpace(string(v)) != "" { + return nil, fmt.Errorf("text outside XML root") + } + case xml.Directive: + return nil, fmt.Errorf("XML directives are unsupported") + } + } + if root == nil { + return nil, fmt.Errorf("empty XML document") + } + return root, nil +} + +// invoiceTaxonomy 识别已核实的官方命名空间。 +// 入参: ns 命名空间URI。 +// 返回: 票种及版本,未知时为空。 +func invoiceTaxonomy(ns string) (string, string) { + switch ns { + case "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/inv": + return "inv", "2023-12-31" + case "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/einv": + return "einv", "2023-12-31" + case "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/atr": + return "atr", "2023-12-31" + case "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/rai": + return "rai", "2023-12-31" + case "http://xbrl.mof.gov.cn/taxonomy/2021-11-30/atr": + return "atr", "2021-11-30" + } + return "", "" +} + +// parseInvoiceXML 解析明确识别的发票格式,未知格式仅产生诊断。 +// 入参: ctx 取消上下文,data XML数据,source 来源。 +// 返回: InvoiceReport 独立候选与诊断,error XML或限额错误。 +func parseInvoiceXML(ctx context.Context, data []byte, source InvoiceSource) (InvoiceReport, error) { + if ctx == nil { + ctx = context.Background() + } + var report InvoiceReport + root, err := invoiceXMLTree(ctx, data) + if err != nil { + return report, err + } + issue := func(code, field, msg string, s InvoiceSource) { + report.Issues = append(report.Issues, InvoiceIssue{Code: code, Field: field, Message: msg, Source: s}) + } + if root.name == (xml.Name{Local: "EInvoice"}) { + return invoicePlainXML(ctx, root, source) + } + if root.name != (xml.Name{Space: invoiceXBRL, Local: "xbrl"}) { + issue("unsupported", "", "unsupported invoice XML root or version", source) + return report, nil + } + contexts := map[string]*invoiceXMLNode{} + units := map[string]*invoiceXMLNode{} + definitionIDs := map[string]bool{} + for _, n := range root.children { + if n.name.Space != invoiceXBRL { + continue + } + var target map[string]*invoiceXMLNode + if n.name.Local == "context" { + target = contexts + } + if n.name.Local == "unit" { + target = units + } + if target == nil { + continue + } + id := invoiceXMLAttr(n, "", "id") + if definitionIDs[id] { + contexts[id] = nil + units[id] = nil + issue("invalid-reference", n.name.Local, "duplicate context or unit ID", source) + continue + } + definitionIDs[id] = true + if _, ok := target[id]; ok || id == "" { + target[id] = nil + issue("invalid-reference", n.name.Local, "missing or duplicate definition ID", source) + } else { + target[id] = n + } + } + indexes := map[string]int{} + seen := map[string]InvoiceFact{} + blocked := map[string]bool{} + dimensions := map[string][]InvoiceDimension{} + for _, id := range invoiceSortedNodes(contexts) { + n := contexts[id] + if n == nil { + continue + } + ds, valid := invoiceContext(n) + dimensions[id] = ds + if !valid { + contexts[id] = nil + issue("invalid-reference", "context", "invalid context entity, period or dimensions", source) + } + } + unitValues := map[string]string{} + for _, id := range invoiceSortedNodes(units) { + n := units[id] + if n == nil { + continue + } + v, valid := invoiceUnit(n) + if !valid { + units[id] = nil + issue("invalid-reference", "unit", "invalid unit measures or QName", source) + } else { + unitValues[id] = v + } + } + itemIndexes := map[string]int{} + unknown := map[string][]InvoiceFact{} + var unknownOrder []InvoiceFact + assignedContexts := map[string]bool{} + assign := func(dst *string, key string, f InvoiceFact, numeric bool) { + if dst == nil { + return + } + if old, ok := seen[key]; ok { + equal := old.Value == f.Value || numeric && invoiceDecimalEqual(old.Value, f.Value) + if !equal || old.Nil != f.Nil || unitValues[old.Unit] != unitValues[f.Unit] { + blocked[key] = true + *dst = "" + issue("conflict", f.Name, "conflicting facts in the same context and tuple", f.Source) + } + return + } + seen[key] = f + if !blocked[key] && !f.Nil { + *dst = f.Value + } + } + var visit func(*invoiceXMLNode) error + visit = func(n *invoiceXMLNode) error { + if err := ctx.Err(); err != nil { + return err + } + if n.name.Space == invoiceXBRL || n.name.Space == "http://www.xbrl.org/2003/linkbase" { + return nil + } + if len(n.children) > 0 && invoiceXMLAttr(n, "", "contextRef") == "" { + for _, c := range n.children { + if err := visit(c); err != nil { + return err + } + } + return nil + } + kind, version := invoiceTaxonomy(n.name.Space) + ref := invoiceXMLAttr(n, "", "contextRef") + s := source + s.NodePath += n.path + fact := InvoiceFact{Name: n.name.Local, Namespace: n.name.Space, Value: n.text.String(), Context: ref, Unit: invoiceXMLAttr(n, "", "unitRef"), Decimals: invoiceXMLAttr(n, "", "decimals"), Precision: invoiceXMLAttr(n, "", "precision"), Source: s, Nil: invoiceXMLAttr(n, invoiceXSI, "nil") == "true" || invoiceXMLAttr(n, invoiceXSI, "nil") == "1", Dimensions: dimensions[ref]} + if len(n.children) > 0 { + fact.Value = string(n.inner) + } + if kind == "" { + unknown[ref] = append(unknown[ref], fact) + unknownOrder = append(unknownOrder, fact) + return nil + } + key := n.name.Space + "\x00" + ref + i, ok := indexes[key] + if !ok { + i = len(report.Invoices) + indexes[key] = i + report.Invoices = append(report.Invoices, Invoice{Kind: kind, Standard: n.name.Space, Version: version, Sources: []InvoiceSource{source}}) + } + inv := &report.Invoices[i] + inv.Facts = append(inv.Facts, fact) + nilValue := invoiceXMLAttr(n, invoiceXSI, "nil") + if invoiceXMLHasAttr(n, invoiceXSI, "nil") && nilValue != "true" && nilValue != "false" && nilValue != "1" && nilValue != "0" { + issue("invalid-value", fact.Name, "invalid xsi:nil", s) + return nil + } + if fact.Nil && (strings.TrimSpace(fact.Value) != "" || len(n.children) > 0) { + issue("invalid-value", fact.Name, "nil fact contains a value", s) + return nil + } + if contexts[ref] == nil { + issue("invalid-reference", fact.Name, "undefined or ambiguous contextRef", s) + return nil + } + if fact.Unit != "" && units[fact.Unit] == nil { + issue("invalid-reference", fact.Name, "undefined or ambiguous unitRef", s) + return nil + } + numeric := invoiceNumeric(kind, fact.Name) + if numeric && !fact.Nil && invoiceDecimal(fact.Value) == nil { + issue("invalid-decimal", fact.Name, "invalid or oversized decimal lexical value", s) + return nil + } + hasDecimals, hasPrecision := invoiceXMLHasAttr(n, "", "decimals"), invoiceXMLHasAttr(n, "", "precision") + known := numeric || invoiceXMLField(&Invoice{}, kind, fact.Name) != nil || invoiceItemField(&InvoiceItem{}, kind, fact.Name) != nil + if hasDecimals && hasPrecision || fact.Nil && (hasDecimals || hasPrecision) || known && !numeric && (hasDecimals || hasPrecision) || numeric && !fact.Nil && !hasDecimals && !hasPrecision || hasDecimals && !invoiceAccuracy(fact.Decimals, false) || hasPrecision && !invoiceAccuracy(fact.Precision, true) { + issue("invalid-precision", fact.Name, "invalid, missing or mutually exclusive numeric accuracy attributes", s) + return nil + } + if numeric && fact.Unit == "" { + issue("invalid-reference", fact.Name, "numeric fact has no unitRef", s) + return nil + } + if numeric && invoiceMonetary(kind, fact.Name) && !invoiceCurrencyUnit(unitValues[fact.Unit]) { + issue("invalid-reference", fact.Name, "monetary fact requires a simple ISO 4217 unit", s) + return nil + } + if numeric && invoiceMonetary(kind, fact.Name) && invoiceCurrencyUnit(unitValues[fact.Unit]) { + currency := strings.TrimPrefix(unitValues[fact.Unit], "{http://www.xbrl.org/2003/iso4217}") + cf := fact + cf.Value = currency + cf.Unit = "" + cf.Nil = false + assign(&inv.Currency, key+"\x00currency", cf, false) + } + if n.parent == root && len(fact.Dimensions) == 0 { + assign(invoiceXMLField(inv, kind, fact.Name), key+"\x00"+fact.Name, fact, numeric) + } + tuple := "" + if n.parent != root && n.parent.name.Space == n.name.Space && invoiceItemTuple(kind, n.parent.name.Local) { + tuple = n.parent.path + } + if tuple != "" || len(fact.Dimensions) > 0 { + ik := key + "\x00" + tuple + idx, ok := itemIndexes[ik] + if !ok { + idx = len(inv.Items) + itemIndexes[ik] = idx + inv.Items = append(inv.Items, InvoiceItem{}) + } + item := &inv.Items[idx] + item.Fields = append(item.Fields, fact) + assign(invoiceItemField(item, kind, fact.Name), ik+"\x00"+fact.Name, fact, numeric) + } + return nil + } + for _, n := range root.children { + if err := visit(n); err != nil { + return InvoiceReport{}, err + } + } + if len(report.Invoices) == 0 { + issue("unsupported", "", "no recognized invoice taxonomy facts", source) + } + for i := range report.Invoices { + if err := ctx.Err(); err != nil { + return InvoiceReport{}, err + } + inv := &report.Invoices[i] + ref := "" + if len(inv.Facts) > 0 { + ref = inv.Facts[0].Context + } + if n := contexts[ref]; n != nil { + inv.Facts = append(inv.Facts, unknown[ref]...) + assignedContexts[ref] = true + s := source + s.NodePath += n.path + inv.Facts = append(inv.Facts, InvoiceFact{Name: "context", Namespace: invoiceXBRL, Context: ref, Value: string(n.inner), Source: s, Dimensions: dimensions[ref]}) + } + used := map[string]bool{} + for _, f := range inv.Facts { + if f.Unit != "" { + used[f.Unit] = true + } + } + ids := make([]string, 0, len(used)) + for id := range used { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + if n := units[id]; n != nil { + s := source + s.NodePath += n.path + inv.Facts = append(inv.Facts, InvoiceFact{Name: "unit", Namespace: invoiceXBRL, Unit: id, Value: unitValues[id], Source: s}) + } + } + invoiceAmountCheck(inv, &report, source) + } + for _, fact := range unknownOrder { + if err := ctx.Err(); err != nil { + return InvoiceReport{}, err + } + if !assignedContexts[fact.Context] { + report.UnassignedFacts = append(report.UnassignedFacts, fact) + } + } + return report, nil +} + +// invoiceXMLField 映射官方分类标准明确规定的概念。 +// 入参: inv 发票,kind 标准类别,name 概念名。 +// 返回: 对应字段地址,未映射时为空。 +func invoiceXMLField(inv *Invoice, kind, name string) *string { + if kind == "inv" { + switch name { + case "NumberOfInvoice": + return &inv.Number + case "CodeOfInvoice": + return &inv.Code + case "DateOfIssue": + return &inv.Date + case "TotalAmountExcludingTax": + return &inv.Amount + case "TotalTaxAmount": + return &inv.TaxAmount + case "TaxIncludedAmountInFigures": + return &inv.TotalAmount + case "TaxpayerIdentificationNumberUnifiedSocialCreditCodeOfPurchaser": + return &inv.Buyer.TaxID + case "TaxpayerIdentificationNumberUnifiedSocialCreditCodeOfSeller": + return &inv.Seller.TaxID + } + } + if kind == "einv" { + switch name { + case "InvoiceNumber": + return &inv.Number + case "SellerName": + return &inv.Seller.Name + case "SellerIdNum": + return &inv.Seller.TaxID + case "TotalAmWithoutTax": + return &inv.Amount + case "TotalTaxAm": + return &inv.TaxAmount + case "TotalTax-includedAmount": + return &inv.TotalAmount + case "RequestTime": + return &inv.Date + } + return nil + } + if kind == "atr" { + switch name { + case "ElectronicInvoiceAirTransportReceiptNumber": + return &inv.Number + case "IssueDate": + return &inv.Date + case "VatTaxAmount": + return &inv.TaxAmount + case "TotalAmount": + return &inv.TotalAmount + case "IssuingStatus": + return &inv.Status + } + } + if kind == "rai" { + switch name { + case "ElectronicInvoiceRailwayETicketNumber": + return &inv.Number + case "DateOfIssue": + return &inv.Date + case "TotalAmountExcludingTax": + return &inv.Amount + case "TaxAmount": + return &inv.TaxAmount + case "Fare": + return &inv.TotalAmount + case "UnifiedSocialCreditCodeOfSeller": + return &inv.Seller.TaxID + } + } + switch name { + case "NameOfPurchaser": + return &inv.Buyer.Name + case "NameOfSeller": + return &inv.Seller.Name + case "UnifiedSocialCreditCodeOfPurchaser": + if kind == "atr" || kind == "rai" { + return &inv.Buyer.TaxID + } + case "Remarks": + if kind == "inv" || kind == "rai" { + return &inv.Remarks + } + } + return nil +} + +// invoiceSortedNodes 为定义生成稳定顺序。 +// 入参: nodes 定义索引。 +// 返回: 排序后的标识。 +func invoiceSortedNodes(nodes map[string]*invoiceXMLNode) []string { + ids := make([]string, 0, len(nodes)) + for id := range nodes { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// invoiceQName 依据节点处的前缀绑定展开QName。 +// 入参: n 节点,value QName文本。 +// 返回: 展开名称及有效标志。 +func invoiceQName(n *invoiceXMLNode, value string) (xml.Name, bool) { + value = strings.TrimSpace(value) + parts := strings.Split(value, ":") + prefix, local := "", value + if len(parts) > 2 || value == "" { + return xml.Name{}, false + } + if len(parts) == 2 { + prefix, local = parts[0], parts[1] + if prefix == "" { + return xml.Name{}, false + } + } + if !invoiceNCName(local) || prefix != "" && !invoiceNCName(prefix) { + return xml.Name{}, false + } + ns, ok := n.ns[prefix] + if prefix != "" && (!ok || ns == "") { + return xml.Name{}, false + } + return xml.Name{Space: ns, Local: local}, true +} + +// invoiceNCName 验证XML 1.0不含冒号的名称。 +// 入参: name 名称文本。 +// 返回: 是否满足NCName词法。 +func invoiceNCName(name string) bool { + if name == "" { + return false + } + for i, r := range name { + start := r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= 0xc0 && r <= 0xd6 || r >= 0xd8 && r <= 0xf6 || r >= 0xf8 && r <= 0x2ff || r >= 0x370 && r <= 0x37d || r >= 0x37f && r <= 0x1fff || r >= 0x200c && r <= 0x200d || r >= 0x2070 && r <= 0x218f || r >= 0x2c00 && r <= 0x2fef || r >= 0x3001 && r <= 0xd7ff || r >= 0xf900 && r <= 0xfdcf || r >= 0xfdf0 && r <= 0xfffd || r >= 0x10000 && r <= 0xeffff + if !start && (i == 0 || !(r == '-' || r == '.' || r >= '0' && r <= '9' || r == 0xb7 || r >= 0x300 && r <= 0x36f || r >= 0x203f && r <= 0x2040)) { + return false + } + } + return true +} + +// invoicePeriodTime 解析标准期间的日期或日期时间。 +// 入参: value 日期文本。 +// 返回: 时刻及有效标志。 +func invoicePeriodTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if len(value) > 64 { + return time.Time{}, false + } + if len(value) >= 6 { + suffix := value[len(value)-6:] + if (suffix[0] == '+' || suffix[0] == '-') && suffix[3] == ':' { + h, e := strconv.Atoi(suffix[1:3]) + m, e2 := strconv.Atoi(suffix[4:]) + if e != nil || e2 != nil || suffix[3] != ':' || h > 14 || m > 59 || h == 14 && m != 0 { + return time.Time{}, false + } + } + } + for _, layout := range []string{"2006-01-02", "2006-01-02Z07:00", time.RFC3339Nano, "2006-01-02T15:04:05.999999999"} { + if t, err := time.Parse(layout, value); err == nil && t.Year() > 0 { + return t, true + } + } + return time.Time{}, false +} + +// invoiceExpanded 返回不依赖前缀的名称表示。 +// 入参: name 展开名称。 +// 返回: Clark名称。 +func invoiceExpanded(name xml.Name) string { return "{" + name.Space + "}" + name.Local } + +// invoiceCanonicalXML 以展开名称序列化类型化维度,保留混合文本顺序。 +// 入参: n 类型化维度域元素。 +// 返回: 不依赖输入元素前缀的XML。 +func invoiceCanonicalXML(n *invoiceXMLNode) string { + var b bytes.Buffer + e := xml.NewEncoder(&b) + var walk func(*invoiceXMLNode) + walk = func(c *invoiceXMLNode) { + a := make([]xml.Attr, 0, len(c.attrs)) + for _, attr := range c.attrs { + if attr.Name.Space != "xmlns" && attr.Name != (xml.Name{Local: "xmlns"}) { + a = append(a, attr) + } + } + sort.Slice(a, func(i, j int) bool { + if a[i].Name.Space != a[j].Name.Space { + return a[i].Name.Space < a[j].Name.Space + } + return a[i].Name.Local < a[j].Name.Local + }) + start := xml.StartElement{Name: c.name, Attr: a} + _ = e.EncodeToken(start) + for _, p := range c.parts { + if p.child != nil { + walk(p.child) + } else { + _ = e.EncodeToken(xml.CharData(p.text)) + } + } + _ = e.EncodeToken(start.End()) + } + walk(n) + _ = e.Flush() + return b.String() +} + +// invoiceChildren 按展开名称选择直接子节点。 +// 入参: n 父节点,space 命名空间,local 本地名。 +// 返回: 匹配节点。 +func invoiceChildren(n *invoiceXMLNode, space, local string) []*invoiceXMLNode { + var result []*invoiceXMLNode + for _, c := range n.children { + if c.name == (xml.Name{Space: space, Local: local}) { + result = append(result, c) + } + } + return result +} + +// invoiceContext 校验主体、期间及维度,不把不同上下文合并。 +// 入参: n 上下文节点。 +// 返回: 维度及有效标志。 +func invoiceContext(n *invoiceXMLNode) ([]InvoiceDimension, bool) { + entities, periods := invoiceChildren(n, invoiceXBRL, "entity"), invoiceChildren(n, invoiceXBRL, "period") + if len(entities) != 1 || len(periods) != 1 { + return nil, false + } + ids := invoiceChildren(entities[0], invoiceXBRL, "identifier") + if len(ids) != 1 || strings.TrimSpace(ids[0].text.String()) == "" || invoiceXMLAttr(ids[0], "", "scheme") == "" { + return nil, false + } + p := periods[0] + instant, start, end, forever := invoiceChildren(p, invoiceXBRL, "instant"), invoiceChildren(p, invoiceXBRL, "startDate"), invoiceChildren(p, invoiceXBRL, "endDate"), invoiceChildren(p, invoiceXBRL, "forever") + if !((len(instant) == 1 && len(p.children) == 1 && strings.TrimSpace(instant[0].text.String()) != "") || (len(start) == 1 && len(end) == 1 && len(p.children) == 2 && strings.TrimSpace(start[0].text.String()) != "" && strings.TrimSpace(end[0].text.String()) != "") || (len(forever) == 1 && len(p.children) == 1)) { + return nil, false + } + if len(instant) == 1 { + if _, ok := invoicePeriodTime(instant[0].text.String()); !ok { + return nil, false + } + } + if len(start) == 1 { + a, ok := invoicePeriodTime(start[0].text.String()) + b, ok2 := invoicePeriodTime(end[0].text.String()) + if !ok || !ok2 || a.After(b) { + return nil, false + } + } + var dims []InvoiceDimension + valid := true + seen := map[xml.Name]bool{} + var walk func(*invoiceXMLNode) + walk = func(c *invoiceXMLNode) { + if c.name.Space == "http://xbrl.org/2006/xbrldi" && (c.name.Local == "explicitMember" || c.name.Local == "typedMember") { + name, ok := invoiceQName(c, invoiceXMLAttr(c, "", "dimension")) + if !ok || name.Space == "" || seen[name] { + valid = false + return + } + seen[name] = true + value := "" + if c.name.Local == "explicitMember" { + v, ok := invoiceQName(c, c.text.String()) + if !ok || v.Space == "" || len(c.children) != 0 { + valid = false + return + } + value = invoiceExpanded(v) + } else { + if len(c.children) != 1 { + valid = false + return + } + value = invoiceExpanded(c.children[0].name) + ":" + invoiceCanonicalXML(c.children[0]) + } + dims = append(dims, InvoiceDimension{Name: name.Local, Namespace: name.Space, Value: value}) + } + for _, child := range c.children { + walk(child) + } + } + walk(n) + return dims, valid +} + +// invoiceUnit 校验单位结构并展开所有度量QName。 +// 入参: n 单位定义。 +// 返回: 规范单位表达及有效标志。 +func invoiceUnit(n *invoiceXMLNode) (string, bool) { + measures := func(c *invoiceXMLNode) (string, bool) { + if len(c.children) == 0 { + return "", false + } + values := make([]string, 0, len(c.children)) + for _, m := range c.children { + if m.name != (xml.Name{Space: invoiceXBRL, Local: "measure"}) || len(m.children) > 0 { + return "", false + } + q, ok := invoiceQName(m, m.text.String()) + if !ok || q.Space == "" { + return "", false + } + values = append(values, invoiceExpanded(q)) + } + sort.Strings(values) + return strings.Join(values, "*"), true + } + if len(n.children) == 1 && n.children[0].name == (xml.Name{Space: invoiceXBRL, Local: "divide"}) { + d := n.children[0] + num, den := invoiceChildren(d, invoiceXBRL, "unitNumerator"), invoiceChildren(d, invoiceXBRL, "unitDenominator") + if len(num) != 1 || len(den) != 1 || len(d.children) != 2 { + return "", false + } + a, ok := measures(num[0]) + b, ok2 := measures(den[0]) + return "(" + a + ")/(" + b + ")", ok && ok2 + } + return measures(n) +} + +// invoiceNumeric 判断已核实标准中的十进制概念。 +// 入参: kind 标准类别,name 概念名称。 +// 返回: 是否为数值概念。 +func invoiceNumeric(kind, name string) bool { + if name == "RecordedAmount" { + return true + } + if (kind == "inv" || kind == "einv") && name == "AmountOfTransferredOutInputVat" { + return true + } + if kind == "inv" { + switch name { + case "TotalAmountExcludingTax", "TotalTaxAmount", "TaxIncludedAmountInFigures", "QuantityOfGoodsOrServices", "UnitPriceOfGoodsOrServices", "AmountExcludingTax", "TaxAmount": + return true + } + } + if kind == "einv" { + switch name { + case "TotalAmWithoutTax", "TotalTaxAm", "TotalTax-includedAmount": + return true + } + } + if kind == "atr" { + switch name { + case "Fare", "FuelSurcharge", "VatRate", "VatTaxAmount", "CivilAviationDevelopmentFund", "OtherTaxes", "TotalAmount": + return true + } + } + if kind == "rai" { + switch name { + case "Fare", "AmountRefunded", "FareOfOriginalRailwayTicket", "TotalAmountExcludingTax", "TaxRate", "TaxAmount": + return true + } + } + return false +} + +// invoiceAccuracy 校验XBRL精度属性,precision允许零。 +// 入参: value 原文,nonnegative 是否要求非负整数。 +// 返回: 是否为整数或INF。 +func invoiceAccuracy(value string, nonnegative bool) bool { + value = strings.TrimSpace(value) + if value == "INF" { + return true + } + if len(value) == 0 || len(value) > 256 { + return false + } + negative := value[0] == '-' + if value[0] == '+' || negative { + value = value[1:] + } + if value == "" { + return false + } + nonzero := false + for _, r := range value { + if r < '0' || r > '9' { + return false + } + nonzero = nonzero || r != '0' + } + return !nonnegative || !negative || !nonzero +} + +// invoiceMonetary 区分货币事实与数量、百分率。 +// 入参: kind 类别,name 概念名称。 +// 返回: 是否为已映射货币概念。 +func invoiceMonetary(kind, name string) bool { + return invoiceNumeric(kind, name) && name != "QuantityOfGoodsOrServices" && name != "VatRate" && name != "TaxRate" +} + +// invoiceCurrencyUnit 检查单一ISO币种度量,不猜测币种。 +// 入参: unit 展开的单位表示。 +// 返回: 是否为三位大写币种度量。 +func invoiceCurrencyUnit(unit string) bool { + const prefix = "{http://www.xbrl.org/2003/iso4217}" + if !strings.HasPrefix(unit, prefix) { + return false + } + code := strings.TrimPrefix(unit, prefix) + return len(code) == 3 && code[0] >= 'A' && code[0] <= 'Z' && code[1] >= 'A' && code[1] <= 'Z' && code[2] >= 'A' && code[2] <= 'Z' +} + +// invoiceItemTuple 识别明细元组,不把会计分录当作商品。 +// 入参: kind 标准类别,name 元组名。 +// 返回: 是否为明细。 +func invoiceItemTuple(kind, name string) bool { + return kind == "atr" && name == "DetailInformationOfAirTicketTuple" || kind == "inv" && (name == "InformationOfTaxableGoodsOrServicesDetailItemsTuple" || name == "InformationOfTollFeeDetailItemsTuple") +} + +// invoiceItemField 映射增值税商品明细公共字段。 +// 入参: item 明细,kind 类别,name 概念名。 +// 返回: 字段地址,其他明细仅保留事实。 +func invoiceItemField(item *InvoiceItem, kind, name string) *string { + if kind != "inv" { + return nil + } + switch name { + case "NameOfTaxableGoodsOrServices", "NameOfTollFeeItems": + return &item.Name + case "Specification": + return &item.Specification + case "UnitOfGoodsOrServices": + return &item.Unit + case "QuantityOfGoodsOrServices": + return &item.Quantity + case "UnitPriceOfGoodsOrServices": + return &item.UnitPrice + case "AmountExcludingTax": + return &item.Amount + case "TaxRate": + return &item.TaxRate + case "TaxAmount": + return &item.TaxAmount + } + return nil +} + +// invoiceAmountCheck 精确检查明确的价税合计关系,不推算航空费用。 +// 入参: inv 发票,report 诊断目标,source 来源。 +// 返回: 无。 +func invoiceAmountCheck(inv *Invoice, report *InvoiceReport, source InvoiceSource) { + if inv.Kind != "inv" && inv.Kind != "einv" { + return + } + a, b, c := invoiceDecimal(inv.Amount), invoiceDecimal(inv.TaxAmount), invoiceDecimal(inv.TotalAmount) + if a != nil && b != nil && c != nil && new(big.Rat).Add(a, b).Cmp(c) != 0 { + report.Issues = append(report.Issues, InvoiceIssue{Code: "amount-mismatch", Field: "TotalAmount", Message: "amount plus tax differs from total amount", Source: source}) + } +} + +// invoicePlainXML 解析官方数电发票指南中的无命名空间0.3版本。 +// 入参: ctx 上下文,root 根节点,source 来源。 +// 返回: 发票及诊断,取消错误。 +func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSource) (InvoiceReport, error) { + report := InvoiceReport{} + headers := invoiceChildren(root, "", "Header") + if len(headers) != 1 { + report.Issues = append(report.Issues, InvoiceIssue{Code: "unsupported", Message: "EInvoice requires a unique versioned Header", Source: source}) + return report, nil + } + versions := invoiceChildren(headers[0], "", "Version") + if len(versions) != 1 || versions[0].text.String() != "0.3" { + report.Issues = append(report.Issues, InvoiceIssue{Code: "unsupported", Field: "Version", Message: "unsupported EInvoice version", Source: source}) + return report, nil + } + inv := Invoice{Kind: "einv", Standard: "EInvoice", Version: "0.3", Sources: []InvoiceSource{source}} + seen := map[string]InvoiceFact{} + blocked := map[string]bool{} + var walk func(*invoiceXMLNode, string, *InvoiceItem) error + walk = func(n *invoiceXMLNode, path string, item *InvoiceItem) error { + if err := ctx.Err(); err != nil { + return err + } + path += "/" + n.name.Local + if n.name.Space != "" { + path = "!" + path + } + if path == "/EInvoice/EInvoiceData/IssuItemInformation" { + inv.Items = append(inv.Items, InvoiceItem{}) + item = &inv.Items[len(inv.Items)-1] + } + if len(n.children) > 0 { + for _, c := range n.children { + if err := walk(c, path, item); err != nil { + return err + } + } + return nil + } + s := source + s.NodePath += n.path + f := InvoiceFact{Name: n.name.Local, Namespace: n.name.Space, Value: n.text.String(), Source: s, Nil: invoiceXMLAttr(n, invoiceXSI, "nil") == "true" || invoiceXMLAttr(n, invoiceXSI, "nil") == "1"} + inv.Facts = append(inv.Facts, f) + if item != nil { + item.Fields = append(item.Fields, f) + } + nilValue := invoiceXMLAttr(n, invoiceXSI, "nil") + if invoiceXMLHasAttr(n, invoiceXSI, "nil") && nilValue != "true" && nilValue != "false" && nilValue != "1" && nilValue != "0" { + report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-value", Field: f.Name, Message: "invalid xsi:nil", Source: s}) + return nil + } + dst, numeric := invoicePlainField(&inv, item, path) + if dst == nil { + return nil + } + if f.Nil && strings.TrimSpace(f.Value) != "" { + report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-value", Field: f.Name, Message: "nil fact contains a value", Source: s}) + return nil + } + if numeric && !f.Nil && invoiceDecimal(f.Value) == nil { + report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-decimal", Field: f.Name, Message: "invalid decimal lexical value", Source: s}) + return nil + } + key := path + if item != nil { + key = n.parent.path + "/" + n.name.Local + } + if old, ok := seen[key]; ok { + if old.Nil != f.Nil || old.Value != f.Value && !(numeric && invoiceDecimalEqual(old.Value, f.Value)) { + blocked[key] = true + *dst = "" + report.Issues = append(report.Issues, InvoiceIssue{Code: "conflict", Field: f.Name, Message: "conflicting values at the same official field path", Source: s}) + } + return nil + } + seen[key] = f + if !blocked[key] && !f.Nil { + *dst = f.Value + } + return nil + } + if err := walk(root, "", nil); err != nil { + return InvoiceReport{}, err + } + invoiceAmountCheck(&inv, &report, source) + report.Invoices = append(report.Invoices, inv) + return report, nil +} + +// invoicePlainField 按官方样例中的完整路径映射字段。 +// 入参: inv 发票,item 当前明细,path 完整无命名空间路径。 +// 返回: 字段地址及数值标志。 +func invoicePlainField(inv *Invoice, item *InvoiceItem, path string) (*string, bool) { + switch path { + case "/EInvoice/TaxSupervisionInfo/InvoiceNumber": + return &inv.Number, false + case "/EInvoice/TaxSupervisionInfo/IssueTime": + return &inv.Date, false + case "/EInvoice/EInvoiceData/SellerInformation/SellerName": + return &inv.Seller.Name, false + case "/EInvoice/EInvoiceData/SellerInformation/SellerIdNum": + return &inv.Seller.TaxID, false + case "/EInvoice/EInvoiceData/SellerInformation/SellerAddr": + return &inv.Seller.Address, false + case "/EInvoice/EInvoiceData/SellerInformation/SellerTelNum": + return &inv.Seller.Phone, false + case "/EInvoice/EInvoiceData/SellerInformation/SellerBankName": + return &inv.Seller.Bank, false + case "/EInvoice/EInvoiceData/SellerInformation/SellerBankAccNum": + return &inv.Seller.Account, false + case "/EInvoice/EInvoiceData/BuyerInformation/BuyerName": + return &inv.Buyer.Name, false + case "/EInvoice/EInvoiceData/BuyerInformation/BuyerIdNum": + return &inv.Buyer.TaxID, false + case "/EInvoice/EInvoiceData/BasicInformation/TotalAmWithoutTax": + return &inv.Amount, true + case "/EInvoice/EInvoiceData/BasicInformation/TotalTaxAm": + return &inv.TaxAmount, true + case "/EInvoice/EInvoiceData/BasicInformation/TotalTax-includedAmount": + return &inv.TotalAmount, true + case "/EInvoice/EInvoiceData/AdditionalInformation/Remark": + return &inv.Remarks, false + } + if item != nil && strings.HasPrefix(path, "/EInvoice/EInvoiceData/IssuItemInformation/") { + switch strings.TrimPrefix(path, "/EInvoice/EInvoiceData/IssuItemInformation/") { + case "ItemName": + return &item.Name, false + case "SpecMod": + return &item.Specification, false + case "MeaUnits": + return &item.Unit, false + case "Quantity": + return &item.Quantity, true + case "UnPrice": + return &item.UnitPrice, true + case "Amount": + return &item.Amount, true + case "TaxRate": + return &item.TaxRate, true + case "ComTaxAm": + return &item.TaxAmount, true + } + } + return nil, false +} diff --git a/ofdgo_reader.go b/ofdgo_reader.go index c33a167..7b1fdf2 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -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 }