Files
ofdgo/ofdgo_invoice_xml.go
T
2026-10-16 12:07:42 +08:00

1103 lines
35 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 (
"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
}