mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
397 lines
15 KiB
Go
397 lines
15 KiB
Go
// 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与空文本分别表达
|
||
// References保留对象引用标引的原始页标识和有序图元标识,不推测明细归属
|
||
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"`
|
||
References []StructureReference `json:"references,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 := r.readInvoiceXML(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 || tag.TypeID == invoiceTagType && tag.NameSpace == invoiceNamespace {
|
||
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
|
||
}
|
||
payloads := make(map[[3]string]bool)
|
||
for _, value := range report.Invoices {
|
||
if value.Standard != ofdNamespace2016 && value.Number != "" {
|
||
payloads[[3]string{value.Kind, value.Code, value.Number}] = true
|
||
}
|
||
}
|
||
filtered := report.Invoices[:0]
|
||
for _, value := range report.Invoices {
|
||
if value.Standard != ofdNamespace2016 || !payloads[[3]string{value.Kind, value.Code, value.Number}] {
|
||
filtered = append(filtered, value)
|
||
}
|
||
}
|
||
report.Invoices = filtered
|
||
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
|
||
}
|