diff --git a/ofdgo_editor_invoice.go b/ofdgo_editor_invoice.go index 07048cd..827820d 100644 --- a/ofdgo_editor_invoice.go +++ b/ofdgo_editor_invoice.go @@ -1088,6 +1088,11 @@ func invoiceBindingCoverage(before, after Invoice, bindings []InvoiceBinding) er if a.Name != b.Name || a.Namespace != b.Namespace || a.Context != b.Context || a.Unit != b.Unit { return fmt.Errorf("bound invoice fact identity changed") } + if mapped, ok := invoiceEDRMFactBinding(before, a); ok { + if next, valid := invoiceEDRMFactBinding(after, b); valid && mapped.Field == next.Field && mapped.Item == next.Item { + bound[fieldKey{mapped.Field, mapped.Item}] = true + } + } } } for key, value := range old { diff --git a/ofdgo_invoice_layout.go b/ofdgo_invoice_layout.go index d976264..5b20b35 100644 --- a/ofdgo_invoice_layout.go +++ b/ofdgo_invoice_layout.go @@ -23,6 +23,7 @@ import ( "slices" "strconv" "strings" + "time" ) // InvoiceLayout 指定发票信息页的字体资源及毫米尺寸,不代表税务机关的固定票样 @@ -123,6 +124,18 @@ func invoiceLayoutFactVisible(invoice Invoice, fact InvoiceFact) bool { if fact.Nil || fact.Value == "" || fact.Namespace == invoiceXBRL && (fact.Name == "context" || fact.Name == "unit") { return false } + if binding, ok := invoiceEDRMFactBinding(invoice, fact); ok { + value, err := invoiceBindingValue(invoice, binding) + raw := fact.Value + if binding.Field == "date" { + if date, err := time.Parse("2006年01月02日", raw); err == nil { + raw = date.Format("2006-01-02") + } + } + if err == nil && value == raw { + return false + } + } kind, _ := invoiceTaxonomy(fact.Namespace) if kind != "" && kind == invoice.Kind { if field := invoiceXMLField(&invoice, kind, fact.Name); field != nil && *field == fact.Value { @@ -163,6 +176,46 @@ func invoiceLayoutFactVisible(invoice Invoice, fact InvoiceFact) bool { return true } +// invoiceEDRMFactBinding 按2019版完整路径识别摘要与原始字段的同一语义 +// 入参: value 发票,fact 原始字段 +// 返回: 公共字段关联及是否明确映射 +func invoiceEDRMFactBinding(value Invoice, fact InvoiceFact) (InvoiceBinding, bool) { + if value.Standard != invoiceEDRM || fact.Namespace != invoiceEDRM { + return InvoiceBinding{}, false + } + var path strings.Builder + for _, segment := range strings.Split(strings.TrimPrefix(strings.ReplaceAll(fact.Source.NodePath, "{"+invoiceEDRM+"}", "{}"), "/"), "/") { + name, index, ok := strings.Cut(segment, "[") + if !ok || !strings.HasPrefix(name, "{}") || !strings.HasSuffix(index, "]") { + return InvoiceBinding{}, false + } + path.WriteByte('/') + path.WriteString(strings.TrimPrefix(name, "{}")) + } + probe := Invoice{Items: []InvoiceItem{{}}} + field, _ := invoiceEDRMField(&probe, &probe.Items[0], path.String()) + if field == nil { + return InvoiceBinding{}, false + } + *field = "mapped" + for _, candidate := range invoiceLayoutFields(probe) { + if candidate.value != "mapped" { + continue + } + if candidate.item < 0 { + return InvoiceBinding{Field: candidate.field, Item: -1}, true + } + for i, item := range value.Items { + for _, existing := range item.Fields { + if existing.Source.NodePath == fact.Source.NodePath { + return InvoiceBinding{Field: candidate.field, Item: i}, true + } + } + } + } + return InvoiceBinding{}, false +} + // invoiceBindingValue 读取明确字段与明细索引,不接受未知字段或隐式索引 // 入参: invoice 发票数据, binding 字段绑定 // 返回: string 字段原文, error 字段或索引错误 diff --git a/ofdgo_invoice_write.go b/ofdgo_invoice_write.go index be472a6..dccd9f8 100644 --- a/ofdgo_invoice_write.go +++ b/ofdgo_invoice_write.go @@ -27,6 +27,7 @@ import ( "slices" "sort" "strings" + "time" "unicode/utf8" ) @@ -117,7 +118,13 @@ type invoiceXMLPatch struct { func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { format, supported := invoiceWriteFormat(value) probe := Invoice{Items: []InvoiceItem{{}}} - if value.Standard == InvoiceStandardEInvoice { + if value.Standard == invoiceEDRM { + for _, path := range invoiceEDRMPaths() { + if p, _ := invoiceEDRMField(&probe, &probe.Items[0], path); p != nil { + *p = "editable" + } + } + } else if value.Standard == InvoiceStandardEInvoice { probe.Number, probe.Date = "editable", "editable" for _, group := range invoicePlainGroups() { for _, name := range strings.Fields(group.value) { @@ -209,7 +216,7 @@ func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { // 返回: 已适配格式列表 func InvoiceFormats() []InvoiceFormat { result := []InvoiceFormat{{Kind: "einv", Standard: InvoiceStandardEInvoice, Version: "0.3", Read: true, Create: true, Edit: true, Items: true}} - result = append(result, InvoiceFormat{Kind: "inv", Standard: invoiceEDRM, Version: "1.0", Read: true, Items: true}) + result = append(result, InvoiceFormat{Kind: "inv", Standard: invoiceEDRM, Version: "1.0", Read: true, Edit: true, Items: true}) result = append(result, InvoiceFormat{Kind: "inv", Standard: ofdNamespace2016, Version: "1.0", Read: true}) for _, standard := range []string{InvoiceStandardVAT2023, InvoiceStandardDigital2023, InvoiceStandardAir2023, InvoiceStandardRail2023} { kind, version := invoiceTaxonomy(standard) @@ -1058,14 +1065,20 @@ func invoiceWriteXMLProblems(ctx context.Context, data []byte, tuples bool) (map empty := nilValue == "true" || nilValue == "1" date := kind != "" && invoiceWriteDateFact(kind, n.name.Local) plainDate := root.name == (xml.Name{Local: "EInvoice"}) && path == "/EInvoice/TaxSupervisionInfo/IssueTime" - if len(n.children) == 0 && !empty && (date || plainDate) { + edrmDate := n.parent == root && n.name == (xml.Name{Space: invoiceEDRM, Local: "IssueDate"}) + if len(n.children) == 0 && !empty && (date || plainDate || edrmDate) { value := n.text.String() parseValue := value if plainDate { parseValue = strings.Replace(value, " ", "T", 1) } + if edrmDate { + if d, err := time.Parse("2006年01月02日", value); err == nil { + parseValue = d.Format("2006-01-02") + } + } _, valid := invoicePeriodTime(parseValue) - if !valid || date && strings.ContainsAny(value, "T ") { + if !valid || date && strings.ContainsAny(value, "T ") || edrmDate && len(parseValue) != 10 { add(InvoiceIssue{Code: "invalid-date", Field: n.name.Local, Message: "invalid invoice date", Source: InvoiceSource{NodePath: n.path}}, value) } } @@ -1092,6 +1105,18 @@ func invoiceWriteXMLProblems(ctx context.Context, data []byte, tuples bool) (map if err := walk(root, ""); err != nil { return nil, err } + for _, value := range report.Invoices { + if value.Standard != invoiceEDRM { + continue + } + issues, err := ValidateInvoice(ctx, value) + if err != nil { + return nil, err + } + for _, issue := range issues { + add(issue, "") + } + } for _, issue := range report.Issues { value := "" if n := nodes[issue.Source.NodePath]; n != nil { @@ -1212,6 +1237,9 @@ func UpdateInvoiceXML(ctx context.Context, data []byte, invoiceIndex int, value if err = invoiceWriteNewIssues(filter(oldIssues), filter(newIssues)); err != nil { return nil, err } + if value.Standard == invoiceEDRM { + return invoiceUpdateEDRM(ctx, data, old, value) + } root, spans, err := invoiceWriteSpans(ctx, data) if err != nil { return nil, err @@ -1453,6 +1481,180 @@ func UpdateInvoiceXML(ctx context.Context, data []byte, invoiceIndex int, value return result, nil } +// invoiceEDRMPaths 列出2019版摘要与明细的映射路径 +// 入参: 无 +// 返回: 2019版可映射字段路径 +func invoiceEDRMPaths() []string { + return strings.Fields("/eInvoice/InvoiceCode /eInvoice/InvoiceNo /eInvoice/IssueDate /eInvoice/Buyer/BuyerName /eInvoice/Buyer/BuyerTaxID /eInvoice/Seller/SellerName /eInvoice/Seller/SellerTaxID /eInvoice/TaxInclusiveTotalAmount /eInvoice/Note /eInvoice/TaxTotalAmount /eInvoice/TaxExclusiveTotalAmount /eInvoice/GoodsInfos/GoodsInfo/Item /eInvoice/GoodsInfos/GoodsInfo/Specification /eInvoice/GoodsInfos/GoodsInfo/MeasurementDimension /eInvoice/GoodsInfos/GoodsInfo/Quantity /eInvoice/GoodsInfos/GoodsInfo/Price /eInvoice/GoodsInfos/GoodsInfo/Amount /eInvoice/GoodsInfos/GoodsInfo/TaxScheme /eInvoice/GoodsInfos/GoodsInfo/TaxAmount") +} + +// invoiceUpdateEDRM 按2019版原节点写回摘要及明细,保留扩展与未修改字节 +// 入参: ctx 上下文,data 原文,old 原发票,value 目标发票 +// 返回: 修改后XML及错误 +func invoiceUpdateEDRM(ctx context.Context, data []byte, old, value Invoice) ([]byte, error) { + if err := invoiceWriteUnmapped(old.Facts, value.Facts); err != nil { + return nil, err + } + root, spans, err := invoiceWriteSpans(ctx, data) + if err != nil { + return nil, err + } + for _, name := range []string{"Buyer", "Seller", "GoodsInfos"} { + changed := name == "GoodsInfos" && !reflect.DeepEqual(old.Items, value.Items) || name == "Buyer" && old.Buyer != value.Buyer || name == "Seller" && old.Seller != value.Seller + groups := invoiceChildren(root, invoiceEDRM, name) + if len(groups) > 1 { + return nil, fmt.Errorf("ambiguous invoice group %s", name) + } + if changed && len(groups) == 1 && spans[groups[0].path].empty { + patch, err := invoiceWriteInsert(spans[groups[0].path], nil, data) + if err != nil { + return nil, err + } + normalized, err := invoiceApplyXMLPatches(ctx, data, []invoiceXMLPatch{patch}) + if err != nil { + return nil, err + } + return invoiceUpdateEDRM(ctx, normalized, old, value) + } + } + var patches []invoiceXMLPatch + order := strings.Fields("DocID InvoiceCode InvoiceNo TypeCode IssueDate InvoiceCheckCode MachineNo TaxControlCode Buyer Tpvn Seller TaxInclusiveTotalAmount Note InvoiceClerk Payee Checker TaxTotalAmount TaxExclusiveTotalAmount GraphCode InvoiceSIA1 InvoiceSIA2 Signature GoodsInfos") + for _, group := range []string{"", "Buyer", "Seller"} { + parent := root + if group != "" { + parents := invoiceChildren(root, invoiceEDRM, group) + if len(parents) > 0 { + parent = parents[0] + } else { + parent = nil + } + } + desired := &invoiceXMLNode{name: xml.Name{Space: invoiceEDRM, Local: group}} + var names []string + for _, path := range invoiceEDRMPaths() { + parts := strings.Split(path, "/") + if group == "" && len(parts) != 3 || group != "" && (len(parts) != 4 || parts[2] != group) { + continue + } + previous, _ := invoiceEDRMField(&old, nil, path) + target, _ := invoiceEDRMField(&value, nil, path) + if *previous == *target { + continue + } + name := parts[len(parts)-1] + names = append(names, name) + node := &invoiceXMLNode{name: xml.Name{Space: invoiceEDRM, Local: name}} + text := *target + if name == "IssueDate" && parent != nil { + existing := invoiceChildren(parent, invoiceEDRM, name) + if len(existing) == 1 && strings.Contains(existing[0].text.String(), "年") && text != "" { + date, err := time.Parse("2006-01-02", text) + if err != nil { + return nil, err + } + text = date.Format("2006年01月02日") + } + } + node.text.WriteString(text) + node.parts = []invoiceXMLPart{{text: text}} + desired.children = append(desired.children, node) + desired.parts = append(desired.parts, invoiceXMLPart{child: node}) + } + if len(names) == 0 { + continue + } + if parent == nil { + raw, err := invoiceWriteNode(desired) + if err != nil { + return nil, err + } + patch, err := invoiceWriteOrderedInsert(data, spans, root, invoiceEDRM, group, order, raw) + if err != nil { + return nil, err + } + patches = append(patches, patch) + } else { + for _, name := range names { + if len(invoiceChildren(parent, invoiceEDRM, name)) == 0 && group == "" { + raw, err := invoiceWriteNode(invoiceChildren(desired, invoiceEDRM, name)[0]) + if err != nil { + return nil, err + } + patch, err := invoiceWriteOrderedInsert(data, spans, parent, invoiceEDRM, name, order, raw) + if err != nil { + return nil, err + } + patches = append(patches, patch) + continue + } + changes, err := invoiceWriteMergeChildren(data, spans, parent, desired, invoiceEDRM, []string{name}) + if err != nil { + return nil, err + } + patches = append(patches, changes...) + } + } + } + if !reflect.DeepEqual(old.Items, value.Items) { + generated := &invoiceXMLNode{name: xml.Name{Space: invoiceEDRM, Local: "GoodsInfos"}} + for _, item := range value.Items { + node := &invoiceXMLNode{name: xml.Name{Space: invoiceEDRM, Local: "GoodsInfo"}} + for _, path := range invoiceEDRMPaths() { + if !strings.HasPrefix(path, "/eInvoice/GoodsInfos/GoodsInfo/") { + continue + } + p, _ := invoiceEDRMField(&value, &item, path) + leaf := &invoiceXMLNode{name: xml.Name{Space: invoiceEDRM, Local: path[strings.LastIndexByte(path, '/')+1:]}} + leaf.text.WriteString(*p) + leaf.parts = []invoiceXMLPart{{text: *p}} + node.children = append(node.children, leaf) + node.parts = append(node.parts, invoiceXMLPart{child: leaf}) + } + generated.children = append(generated.children, node) + generated.parts = append(generated.parts, invoiceXMLPart{child: node}) + } + parents := invoiceChildren(root, invoiceEDRM, "GoodsInfos") + if len(parents) == 0 { + raw, err := invoiceWriteNode(generated) + if err != nil { + return nil, err + } + patch, err := invoiceWriteInsert(spans[root.path], raw, data) + if err != nil { + return nil, err + } + patches = append(patches, patch) + } else { + changes, err := invoiceWriteMergeItems(ctx, data, spans, parents[0], generated, old, value) + if err != nil { + return nil, err + } + patches = append(patches, changes...) + } + } + result, err := invoiceApplyXMLPatches(ctx, data, patches) + if err != nil { + return nil, err + } + report, err := DecodeInvoices(ctx, result) + if err != nil { + return nil, err + } + if len(report.Invoices) != 1 { + return nil, fmt.Errorf("invoice update changed candidate count") + } + if err := invoiceWriteRepresented(value, report.Invoices[0]); err != nil { + return nil, err + } + if err := invoiceWriteRepresented(report.Invoices[0], value); err != nil { + return nil, err + } + if err := invoiceValidateXMLWrite(ctx, data, result); err != nil { + return nil, err + } + return result, nil +} + // invoiceWriteContains 判断字段是否位于已知顺序表 // 入参: names 字段列表,name 字段 // 返回: 是否存在 @@ -1685,7 +1887,10 @@ func invoiceWriteMergeItems(ctx context.Context, data []byte, spans map[string]i } space, name := value.Standard, invoiceWriteTuple(value.Kind) names := invoiceWriteItemNames(value.Kind) - if value.Standard == InvoiceStandardEInvoice { + if value.Standard == invoiceEDRM { + name = "GoodsInfo" + names = strings.Fields("Item Specification MeasurementDimension Quantity Price Amount TaxScheme TaxAmount") + } else if value.Standard == InvoiceStandardEInvoice { space = "" name = "IssuItemInformation" names = strings.Fields("ItemName SpecMod MeaUnits Quantity UnPrice Amount TaxRate ComTaxAm") @@ -1695,6 +1900,7 @@ func invoiceWriteMergeItems(ctx context.Context, data []byte, spans map[string]i return nil, fmt.Errorf("unsupported invoice item shape") } original := map[string]*invoiceXMLNode{} + originalItems := map[string]InvoiceItem{} for _, item := range old.Items { for _, f := range item.Fields { s := spans[f.Source.NodePath] @@ -1706,6 +1912,7 @@ func invoiceWriteMergeItems(ctx context.Context, data []byte, spans map[string]i } } original[s.node.parent.path] = s.node.parent + originalItems[s.node.parent.path] = item break } } @@ -1732,7 +1939,7 @@ func invoiceWriteMergeItems(ctx context.Context, data []byte, spans map[string]i if path == "" { for _, f := range item.Fields { mapped := invoiceItemField(&InvoiceItem{}, value.Kind, f.Name) - if value.Standard == InvoiceStandardEInvoice || !invoiceWriteContains(names, f.Name) { + if value.Standard == InvoiceStandardEInvoice || value.Standard == invoiceEDRM || !invoiceWriteContains(names, f.Name) { return nil, fmt.Errorf("unsupported new invoice item fact %s", f.Name) } if mapped != nil { @@ -1763,6 +1970,29 @@ func invoiceWriteMergeItems(ctx context.Context, data []byte, spans map[string]i } } n := original[path] + if value.Standard == invoiceEDRM { + previous := originalItems[path] + if err := invoiceWriteUnmapped(previous.Fields, item.Fields); err != nil { + return nil, err + } + var retained []*invoiceXMLNode + for _, child := range targets[i].children { + field := "/eInvoice/GoodsInfos/GoodsInfo/" + child.name.Local + before, _ := invoiceEDRMField(&old, &previous, field) + after, _ := invoiceEDRMField(&value, &item, field) + if before != nil && after != nil && *before == *after { + children := invoiceChildren(n, invoiceEDRM, child.name.Local) + if len(children) == 0 { + continue + } + if len(children) == 1 { + child = children[0] + } + } + retained = append(retained, child) + } + targets[i].children = retained + } ps, err := invoiceWriteMergeChildren(data, spans, n, targets[i], space, names) if err != nil { return nil, err