diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index b213faa..8b691e3 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -5857,6 +5857,7 @@ async function newInvoiceDraft() { function renderInvoiceBasic(container, draft) { const fields = (draft.detail.fields || []).filter(field => field.item < 0 && !field.field.startsWith("facts.") && !field.field.startsWith("items.")); + const standards = {"http://www.edrm.org.cn/schema/e-invoice/2019": "电子发票/2019", "http://www.ofdspec.org/2016": "发票标引"}; const labels = { kind: "票种", standard: "规范", version: "版本", number: "号码", code: "代码", date: "日期", currency: "币种", status: "状态", amount: "金额", taxAmount: "税额", totalAmount: "合计", remarks: "备注", @@ -5870,7 +5871,7 @@ function renderInvoiceBasic(container, draft) { container.append(title, grid); for (const field of fields.filter(field => field.group === group)) { const value = invoiceValue(draft.invoice, field.field); - const display = field.field === "standard" && value === "http://www.edrm.org.cn/schema/e-invoice/2019" ? "电子发票/2019" : value; + const display = field.field === "standard" ? standards[value] || value : value; invoiceInput(grid, labels[field.field.split(".").at(-1)] || field.label, `Field_${field.field.replaceAll(".", "_")}`, display, value => { invoiceSetValue(draft.invoice, field.field, -1, value); invoiceDirty(draft, "model"); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 365505d..3c30706 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -994,6 +994,9 @@ func documentInvoice(args []js.Value) (any, error) { return nil, err } report, err := ofdgo.DecodeInvoices(ctx, data) + if request.Source.Kind == "tag" && len(report.Invoices) == 0 { + report, err = r.ReadInvoice(ctx, request.Source) + } if err != nil { return nil, err } @@ -1114,20 +1117,23 @@ func validateInvoiceDraft(ctx context.Context, request invoiceRequest) (any, err if source.Kind == "" { source = request.Source } - data, err := editor.ExportInvoice(ctx, source) - if err != nil { - return nil, err - } - report, err := ofdgo.DecodeInvoices(ctx, data) - if err != nil { - return nil, err - } - issues = append(issues, report.Issues...) view, err := editor.ReaderContext(ctx) if err != nil { return nil, err } defer view.Close() + data, err := view.ExportInvoice(ctx, source) + if err != nil { + return nil, err + } + report, err := ofdgo.DecodeInvoices(ctx, data) + if source.Kind == "tag" && len(report.Invoices) == 0 { + report, err = view.ReadInvoice(ctx, source) + } + if err != nil { + return nil, err + } + issues = append(issues, report.Issues...) for index, invoice := range report.Invoices { validation, err := ofdgo.ValidateInvoice(ctx, invoice) if err != nil { @@ -1273,6 +1279,14 @@ func applyInvoiceBatch(ctx context.Context, editor *ofdgo.Editor, request invoic return result, fmt.Errorf("invalid invoice batch size") } report, err := ofdgo.DecodeInvoices(ctx, data) + if request.Source.Kind == "tag" && len(report.Invoices) == 0 { + view, viewErr := editor.ReaderContext(ctx) + if viewErr != nil { + return result, viewErr + } + defer view.Close() + report, err = view.ReadInvoice(ctx, request.Source) + } if err != nil { return result, err } diff --git a/ofdgo_editor_invoice.go b/ofdgo_editor_invoice.go index 469a4ba..7421e89 100644 --- a/ofdgo_editor_invoice.go +++ b/ofdgo_editor_invoice.go @@ -95,13 +95,19 @@ func (r *Reader) InvoiceCapabilities(ctx context.Context, source InvoiceSource, if err != nil { return result, err } - report, err := parseInvoiceXML(ctx, data, source) + report, err := r.readInvoiceXML(ctx, data, source) if err != nil { return result, err } if index < 0 || index >= len(report.Invoices) { return result, fmt.Errorf("invoice index out of range") } + if report.Invoices[index].Standard == ofdNamespace2016 { + for _, operation := range []string{"create", "edit", "items", "bind", "generate", "sync", "delete"} { + result.Reasons[operation] = "对象引用标引仅支持读取和导出" + } + return result, nil + } format, known := invoiceWriteFormat(report.Invoices[index]) result.Create = known && format.Create result.Edit = known && format.Edit @@ -669,7 +675,7 @@ func (r *Reader) InvoiceBindings(ctx context.Context, source InvoiceSource, inde if err != nil { return nil, err } - report, err := parseInvoiceXML(ctx, data, source) + report, err := r.readInvoiceXML(ctx, data, source) if err != nil { return nil, err } @@ -699,7 +705,7 @@ func (r *Reader) CheckInvoiceAppearance(ctx context.Context, source InvoiceSourc if err != nil { return nil, err } - report, err := parseInvoiceXML(ctx, data, source) + report, err := r.readInvoiceXML(ctx, data, source) if err != nil { return nil, err } diff --git a/ofdgo_invoice.go b/ofdgo_invoice.go index 4e7a205..2013b33 100644 --- a/ofdgo_invoice.go +++ b/ofdgo_invoice.go @@ -103,17 +103,19 @@ type InvoiceIssue struct { } // 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"` + 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前缀 @@ -165,7 +167,7 @@ func (r *Reader) InvoicesContext(ctx context.Context) (InvoiceReport, error) { report.Issues = append(report.Issues, InvoiceIssue{Code: "read-error", Message: readErr.Error(), Source: source}) return nil } - parsed, err := parseInvoiceXML(ctx, data, source) + parsed, err := r.readInvoiceXML(ctx, data, source) if err := ctx.Err(); err != nil { return err } @@ -266,6 +268,19 @@ func (r *Reader) InvoicesContext(ctx context.Context) (InvoiceReport, error) { 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 diff --git a/ofdgo_invoice_source.go b/ofdgo_invoice_source.go index a243672..18f3b5d 100644 --- a/ofdgo_invoice_source.go +++ b/ofdgo_invoice_source.go @@ -23,6 +23,7 @@ import ( "fmt" "strconv" "strings" + "time" ) // InvoiceDigest 计算原始导出数据的SHA256摘要 @@ -33,6 +34,158 @@ func InvoiceDigest(data []byte) string { return hex.EncodeToString(sum[:]) } +// ReadInvoice 读取当前文档及版本声明的发票,标引按对象引用提取文本 +// 入参: ctx 取消上下文, source 精确来源 +// 返回: InvoiceReport 发票及诊断, error 来源或读取错误 +func (r *Reader) ReadInvoice(ctx context.Context, source InvoiceSource) (InvoiceReport, error) { + ctx = invoiceWriteContext(ctx) + data, err := r.ExportInvoice(ctx, source) + if err != nil { + return InvoiceReport{}, err + } + report, err := r.readInvoiceXML(ctx, data, source) + report.DocumentIndex = r.DocumentIndex() + if r.versionInfo != nil { + report.Version = r.versionInfo.ID + } + return report, err +} + +// readInvoiceXML 在当前文档作用域解析业务载荷或对象引用标引 +// 入参: ctx 取消上下文, data 原始XML, source 来源 +// 返回: InvoiceReport 发票及诊断, error 读取错误 +func (r *Reader) readInvoiceXML(ctx context.Context, data []byte, source InvoiceSource) (InvoiceReport, error) { + report, err := parseInvoiceXML(ctx, data, source) + if err != nil || source.Kind != "tag" || len(report.Invoices) != 0 { + return report, err + } + root, err := invoiceXMLTree(ctx, data) + if err != nil { + return report, err + } + if root.name.Space != ofdNamespace2016 || root.name.Local != "root" || !invoiceObjectReferences(root) { + return report, nil + } + structure, err := parseStructure(ctx, data, CustomTag{TypeID: structureTypeOfficial, NameSpace: ofdNamespace2016}) + if err != nil { + return report, err + } + known := false + for _, node := range structure.Children { + known = known || node.Namespace == ofdNamespace2016 && node.Name == "InvoiceNo" + } + if !known { + return report, nil + } + doc, err := r.DocContext(ctx) + if err != nil { + return report, err + } + pages := signaturePageNumbers(doc) + cache := make(map[int]map[string][]string) + inv := Invoice{Kind: "inv", Standard: ofdNamespace2016, Version: "1.0", Sources: []InvoiceSource{source}} + assigned := make(map[string]bool) + total := 0 + var visit func(*StructureNode, string, string) error + visit = func(node *StructureNode, nodePath, fieldPath string) error { + if err := ctx.Err(); err != nil { + return err + } + if node.Namespace != ofdNamespace2016 { + return nil + } + var refs []StructureReference + for _, child := range node.Children { + refs = append(refs, child.References...) + } + if len(refs) != 0 { + factSource := source + factSource.NodePath = nodePath + fact := InvoiceFact{Name: node.Name, Namespace: node.Namespace, Source: factSource, References: refs} + valid := true + for _, ref := range refs { + page, _ := resourceValue(pages, ref.PageID) + if page != 0 && cache[page] == nil { + content, err := r.PageContentByIndexContext(ctx, page-1) + if err != nil { + return err + } + texts := make(map[string][]string) + var collect func(GraphicObject) + collect = func(object GraphicObject) { + if object.Type == "TextObject" { + id := editorResourceID(object.TextObject.ID) + texts[id] = append(texts[id], object.TextObject.Text()) + } + if object.Type == "CompositeGraphicUnit" { + for child := range object.CompositeGraphicUnit.objects() { + collect(child) + } + } + } + for _, layer := range content.Content.Layer { + for _, object := range layer.Objects { + collect(object) + } + } + cache[page] = texts + } + for _, id := range ref.ObjectIDs { + values := cache[page][editorResourceID(id)] + if len(values) != 1 { + valid = false + report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-reference", Message: "invoice text reference is missing or ambiguous", Source: factSource, Field: nodePath}) + continue + } + if len(values[0]) > invoiceXMLLimit-total { + return fmt.Errorf("invoice extraction limit exceeded") + } + total += len(values[0]) + fact.Value += values[0] + } + } + inv.Facts = append(inv.Facts, fact) + dst, numeric := invoiceEDRMField(&inv, nil, fieldPath) + if dst != nil && valid { + value := strings.TrimSpace(fact.Value) + if numeric { + value = strings.TrimSpace(strings.TrimLeft(value, "¥¥")) + if invoiceDecimal(value) == nil { + report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-decimal", Message: "invalid invoice amount", Source: factSource, Field: nodePath}) + return nil + } + } + if assigned[fieldPath] { + *dst = "" + report.Issues = append(report.Issues, InvoiceIssue{Code: "ambiguous-field", Message: "multiple invoice field references", Source: factSource, Field: nodePath}) + } else { + *dst = value + assigned[fieldPath] = true + } + } + return nil + } + counts := make(map[string]int) + for i := range node.Children { + child := &node.Children[i] + counts[child.Name]++ + if err := visit(child, fmt.Sprintf("%s/%s[%d]", nodePath, child.Name, counts[child.Name]), fieldPath+"/"+child.Name); err != nil { + return err + } + } + return nil + } + if err := visit(structure, "/root[1]", "/eInvoice"); err != nil { + return InvoiceReport{}, err + } + if date, err := time.Parse("2006年01月02日", inv.Date); err == nil { + inv.Date = date.Format("2006-01-02") + } + invoiceAmountCheck(&inv, &report, source) + report.Invoices = append(report.Invoices, inv) + return report, ctx.Err() +} + // ExportInvoice 导出当前文档及版本声明的发票,内联内容补齐继承的命名空间 // 相同选择器对应多个声明时拒绝操作,允许已识别的旧数据带解析诊断 // 入参: ctx 取消上下文, source 提取结果中的精确来源 @@ -112,6 +265,15 @@ func (e *Editor) changeInvoiceSource(ctx context.Context, source InvoiceSource, if InvoiceDigest(old) != digest { return InvoiceSource{}, fmt.Errorf("invoice source has changed") } + if source.Kind == "tag" { + root, err := invoiceXMLTree(ctx, old) + if err != nil { + return InvoiceSource{}, err + } + if root.name.Space == ofdNamespace2016 && root.name.Local == "root" && invoiceObjectReferences(root) { + return InvoiceSource{}, fmt.Errorf("object reference invoice is read-only") + } + } if !remove { if len(data) > invoiceXMLLimit { return InvoiceSource{}, fmt.Errorf("invoice data limit exceeded") diff --git a/ofdgo_invoice_write.go b/ofdgo_invoice_write.go index b58edb9..0e4aca3 100644 --- a/ofdgo_invoice_write.go +++ b/ofdgo_invoice_write.go @@ -148,7 +148,7 @@ func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { if strings.HasPrefix(field.field, "facts.") || strings.HasPrefix(field.field, "items.fields.") { continue } - d := InvoiceFieldDescriptor{Field: field.field, Label: field.label, Group: "basic", Type: "text", Item: field.item, Value: field.value, Bindable: true} + d := InvoiceFieldDescriptor{Field: field.field, Label: field.label, Group: "basic", Type: "text", Item: field.item, Value: field.value, Bindable: value.Standard != ofdNamespace2016} binding := InvoiceBinding{Field: field.field, Item: field.item} if field.item >= 0 { binding.Item = 0 @@ -178,7 +178,7 @@ func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { result = append(result, d) } appendFact := func(fact InvoiceFact, field string, item int) { - d := InvoiceFieldDescriptor{Field: field, Label: fact.Name, Group: "facts", Type: "text", Item: item, Value: fact.Value, Bindable: true} + d := InvoiceFieldDescriptor{Field: field, Label: fact.Name, Group: "facts", Type: "text", Item: item, Value: fact.Value, Bindable: value.Standard != ofdNamespace2016} if item >= 0 { d.Group = "items" } @@ -210,6 +210,7 @@ func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { func InvoiceFormats() []InvoiceFormat { result := []InvoiceFormat{{Kind: "einv", Standard: "EInvoice", 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: ofdNamespace2016, Version: "1.0", Read: true}) for _, kind := range []string{"inv", "einv", "atr", "rai"} { result = append(result, InvoiceFormat{Kind: kind, Standard: "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/" + kind, Version: "2023-12-31", Read: true, Create: true, Edit: true, Items: kind == "inv" || kind == "atr"}) } @@ -269,7 +270,7 @@ func ValidateInvoice(ctx context.Context, value Invoice) ([]InvoiceIssue, error) return issues, nil } for _, field := range []invoiceWriteField{{"Number", value.Number}, {"Date", value.Date}, {"TotalAmount", value.TotalAmount}} { - if strings.TrimSpace(field.value) == "" { + if value.Standard != ofdNamespace2016 && strings.TrimSpace(field.value) == "" { add("required", field.name, "missing invoice field") } } diff --git a/ofdgo_invoice_xml.go b/ofdgo_invoice_xml.go index 4b852f5..724d758 100644 --- a/ofdgo_invoice_xml.go +++ b/ofdgo_invoice_xml.go @@ -1062,6 +1062,9 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo report.Issues = append(report.Issues, InvoiceIssue{Code: "invalid-value", Field: f.Name, Message: "nil fact contains a value", Source: s}) return nil } + if numeric && inv.Standard == invoiceEDRM && strings.TrimSpace(f.Value) == "" && !f.Nil { + 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