diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 9c54743..0195b4b 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1935,6 +1935,11 @@ body[data-hide-meta] .meta-panel { } @media (max-width: 600px) { + .invoice-dialog #settingsActions .button { + min-width: calc(2em + 18px); + padding-inline: 8px; + } + .invoice-dialog #settingsStatus { order: 3; flex-basis: 100%; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 8630a57..b213faa 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -5555,6 +5555,7 @@ async function loadDocumentSettings() { settings.busy = true; settingsElements.Form.inert = true; settingsElements.Status.textContent = "正在读取信息"; + settingsElements.Status.title = ""; try { if (kind === "invoices") { await loadInvoiceSettings(sequence, openSeq); return; } const data = ["bookmarks", "actions", "extensions"].includes(kind) ? await callWASM("ofdgoDocumentNavigation", kind) : {}; @@ -5742,12 +5743,24 @@ function invoiceSetValue(invoice, field, item, value) { target[keys.at(-1)] = value; } -function invoiceIssues(issues) { - return (issues || []).map(issue => typeof issue === "string" ? issue : [issue.field, issue.message || issue.code].filter(Boolean).join(":")).join("\n"); +function invoiceIssues(issues, details = false) { + const messages = { + "unsupported invoice XML root or version": "暂不支持此发票格式或版本", + "unsupported eInvoice namespace or version": "暂不支持此发票格式或版本", + "unsupported EInvoice version": "暂不支持此发票版本", + "EInvoice requires a unique versioned Header": "发票头信息不完整" + }; + return [...new Set((issues || []).map(issue => { + const message = typeof issue === "string" ? issue : issue.message || issue.code; + if (typeof issue === "string") return details ? message : messages[message] || message; + if (details) return [issue.source?.fileLoc || issue.source?.id || issue.source?.kind, issue.source?.nodePath, issue.field, message].filter(Boolean).join(":"); + return [issue.field, messages[message] || message].filter(Boolean).join(":"); + }).filter(Boolean))].join("\n"); } function renderInvoiceSettings() { const context = settingsElements.Context, content = settingsElements.Content; + settingsElements.Status.title = ""; const draft = invoiceCurrent(); const sources = [...new Map(invoicePanel.entries.map(entry => [invoiceSourceKey(entry.source), entry.source])).entries()]; if (sources.length) settingsSelect(context, "来源", "InvoiceSource", sources.map(([key, source]) => [key, source.fileLoc || source.id || source.kind]), draft?.entry ? invoiceSourceKey(draft.entry.source) : null, value => { @@ -5775,6 +5788,7 @@ function renderInvoiceSettings() { } if (!draft) { settingsElements.Status.textContent = invoiceIssues(invoicePanel.issues) || "暂无发票"; + settingsElements.Status.title = invoiceIssues(invoicePanel.issues, true); return; } const tabs = document.createElement("div"); @@ -5821,6 +5835,7 @@ function renderInvoiceSettings() { }, !draft.detail.capabilities?.delete, "InvoiceDelete"); } settingsElements.Status.textContent = draft.status || invoiceIssues(draft.detail.issues) || ""; + if (!draft.status) settingsElements.Status.title = invoiceIssues(draft.detail.issues, true); } async function newInvoiceDraft() { @@ -5854,10 +5869,12 @@ function renderInvoiceBasic(container, draft) { grid.className = "settings-grid"; container.append(title, grid); for (const field of fields.filter(field => field.group === group)) { - invoiceInput(grid, labels[field.field.split(".").at(-1)] || field.label, `Field_${field.field.replaceAll(".", "_")}`, invoiceValue(draft.invoice, field.field), value => { + const value = invoiceValue(draft.invoice, field.field); + const display = field.field === "standard" && value === "http://www.edrm.org.cn/schema/e-invoice/2019" ? "电子发票/2019" : 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"); - }, !state.editing || !(field.editable || !draft.entry && field.field === "currency" && draft.invoice.standard !== "EInvoice") || !!draft.entry && !draft.detail.capabilities?.edit).title = field.label; + }, !state.editing || !(field.editable || !draft.entry && field.field === "currency" && draft.invoice.standard !== "EInvoice") || !!draft.entry && !draft.detail.capabilities?.edit).title = display !== value ? value : field.label; } } if (draft.entry && state.editing) invoiceInput(container, "同步", "Synchronize", draft.synchronize, value => { draft.synchronize = value; }, !draft.detail.capabilities?.sync, "checkbox").title = draft.detail.capabilities?.reasons?.sync || "同步票面"; @@ -5926,7 +5943,7 @@ function renderInvoiceAdvanced(container, draft) { function renderInvoiceItems(container, draft) { const items = draft.invoice.items ||= []; - const editable = state.editing && (!draft.entry ? invoicePanel.formats.find(value => value.kind === draft.invoice.kind && value.standard === draft.invoice.standard && value.version === draft.invoice.version)?.items : draft.detail.capabilities?.items); + const editable = state.editing && (!draft.entry ? invoicePanel.formats.find(value => value.kind === draft.invoice.kind && value.standard === draft.invoice.standard && value.version === draft.invoice.version)?.items : draft.detail.capabilities?.edit && draft.detail.capabilities?.items); draft.item = Math.max(0, Math.min(draft.item, items.length - 1)); const columns = [["name", "名称"], ["specification", "规格"], ["unit", "单位"], ["quantity", "数量"], ["unitPrice", "单价"], ["amount", "金额"], ["taxRate", "税率"], ["taxAmount", "税额"]]; const scroll = document.createElement("div"), table = document.createElement("table"); @@ -6277,10 +6294,11 @@ function resetInvoiceDraft(draft) { } function renderInvoiceXML(container, draft) { + const writable = state.editing && (!draft.entry || draft.detail.capabilities?.edit); invoiceInput(container, "原文", "XML", draft.xml, value => { draft.xml = value; invoiceDirty(draft, "xml"); - }, !state.editing, "textarea").rows = 14; + }, !writable, "textarea").rows = 14; const actions = document.createElement("div"); actions.className = "invoice-actions"; container.append(actions); @@ -6289,7 +6307,7 @@ function renderInvoiceXML(container, draft) { if (!draft.entry) draft.importing = true; invoiceDirty(draft, "xml"); renderDocumentSettings(); - }), false, "InvoiceLoadXML", actions); + }), !writable, "InvoiceLoadXML", actions); settingsButton("导出", async () => { const result = await callWASM("ofdgoExportInvoice", JSON.stringify({ ...draft.scope, source: draft.entry.source, index: draft.entry.index })); downloadBytes(result.bytes, "application/xml", (draft.entry.source.fileLoc?.split("/").at(-1) || "invoice.xml")); diff --git a/ofdgo_editor_invoice.go b/ofdgo_editor_invoice.go index caf8c31..469a4ba 100644 --- a/ofdgo_editor_invoice.go +++ b/ofdgo_editor_invoice.go @@ -105,7 +105,7 @@ func (r *Reader) InvoiceCapabilities(ctx context.Context, source InvoiceSource, format, known := invoiceWriteFormat(report.Invoices[index]) result.Create = known && format.Create result.Edit = known && format.Edit - result.Items = known && format.Items + result.Items = result.Edit && format.Items result.Bind = known result.Generate = known result.Delete = true diff --git a/ofdgo_invoice_write.go b/ofdgo_invoice_write.go index 1b1aa5e..b58edb9 100644 --- a/ofdgo_invoice_write.go +++ b/ofdgo_invoice_write.go @@ -205,10 +205,11 @@ func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { return result } -// InvoiceFormats 返回独立格式能力副本,历史航空格式仅允许读取和原位修改 +// InvoiceFormats 返回独立格式能力副本,创建及编辑能力按业务版本分别声明 // 返回: 已适配格式列表 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}) 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"}) } @@ -914,6 +915,10 @@ func PatchInvoice(ctx context.Context, data []byte, changes []InvoiceFieldChange } allowed := map[string]bool{} for _, inv := range report.Invoices { + format, supported := invoiceWriteFormat(inv) + if !supported || !format.Edit { + continue + } for _, f := range inv.Facts { if f.Namespace != invoiceXBRL { allowed[f.Source.NodePath] = true @@ -1176,6 +1181,9 @@ func UpdateInvoiceXML(ctx context.Context, data []byte, invoiceIndex int, value if reflect.DeepEqual(old, value) { return bytes.Clone(data), nil } + if format, supported := invoiceWriteFormat(old); !supported || !format.Edit { + return nil, fmt.Errorf("invoice format does not support editing") + } if value.Kind != old.Kind || value.Standard != old.Standard || value.Version != old.Version { return nil, fmt.Errorf("invoice update cannot change format") } diff --git a/ofdgo_invoice_xml.go b/ofdgo_invoice_xml.go index e135308..4b852f5 100644 --- a/ofdgo_invoice_xml.go +++ b/ofdgo_invoice_xml.go @@ -31,9 +31,12 @@ import ( // invoiceXBRL 表示XBRL实例命名空间 const invoiceXBRL = "http://www.xbrl.org/2003/instance" -// invoiceXSI 表示XML Schema实例命名空间 +// invoiceXSI 表示XMLSchema实例命名空间 const invoiceXSI = "http://www.w3.org/2001/XMLSchema-instance" +// invoiceEDRM 表示2019版电子发票业务命名空间 +const invoiceEDRM = "http://www.edrm.org.cn/schema/e-invoice/2019" + // invoiceDecimalPattern 仅接受XML十进制词法 var invoiceDecimalPattern = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$`) @@ -249,7 +252,10 @@ func parseInvoiceXML(ctx context.Context, data []byte, source InvoiceSource) (In 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"}) { + if source.Kind == "tag" && invoiceObjectReferences(root) { + return report, nil + } + if root.name == (xml.Name{Local: "EInvoice"}) || root.name.Local == "eInvoice" && (root.name.Space == "" || root.name.Space == invoiceEDRM) { return invoicePlainXML(ctx, root, source) } if root.name != (xml.Name{Space: invoiceXBRL, Local: "xbrl"}) { @@ -955,22 +961,61 @@ func invoiceAmountCheck(inv *Invoice, report *InvoiceReport, source InvoiceSourc } } -// invoicePlainXML 解析官方数电发票指南中的无命名空间0.3版本 +// invoiceObjectReferences 识别由对象引用及对象数据构成的票面标引 +// 入参: root 标引根节点 +// 返回: bool 是否为纯对象映射 +func invoiceObjectReferences(root *invoiceXMLNode) bool { + count := 0 + var walk func(*invoiceXMLNode) bool + walk = func(n *invoiceXMLNode) bool { + if n.name.Space == ofdNamespace2016 || n.name.Space == ofdNamespaceLegacy || n.name.Space == "ofd" || n.name.Space == invoiceEDRM { + if n.name.Local == "ObjectRef" { + count++ + return len(n.children) == 0 && strings.TrimSpace(n.text.String()) != "" && invoiceXMLAttr(n, "", "PageRef") != "" + } + if n.name.Local == "ObjectData" { + return len(n.children) == 0 + } + } + if strings.TrimSpace(n.text.String()) != "" { + return false + } + for _, child := range n.children { + if !walk(child) { + return false + } + } + return true + } + return walk(root) && count > 0 +} + +// invoicePlainXML 解析数电发票0.3及2019版电子发票XML // 入参: 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}} + namespace, itemPath := "", "/EInvoice/EInvoiceData/IssuItemInformation" + if root.name.Local == "eInvoice" { + if invoiceXMLAttr(root, "", "Version") != "1.0" || len(invoiceChildren(root, invoiceEDRM, "InvoiceNo")) == 0 { + report.Issues = append(report.Issues, InvoiceIssue{Code: "unsupported", Message: "unsupported eInvoice namespace or version", Source: source}) + return report, nil + } + inv.Kind, inv.Standard, inv.Version = "inv", invoiceEDRM, "1.0" + namespace, itemPath = invoiceEDRM, "/eInvoice/GoodsInfos/GoodsInfo" + } else { + 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 + } + } seen := map[string]InvoiceFact{} blocked := map[string]bool{} var walk func(*invoiceXMLNode, string, *InvoiceItem) error @@ -979,10 +1024,10 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo return err } path += "/" + n.name.Local - if n.name.Space != "" { + if n != root && n.name.Space != namespace { path = "!" + path } - if path == "/EInvoice/EInvoiceData/IssuItemInformation" { + if path == itemPath { inv.Items = append(inv.Items, InvoiceItem{}) item = &inv.Items[len(inv.Items)-1] } @@ -1007,6 +1052,9 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo return nil } dst, numeric := invoicePlainField(&inv, item, path) + if inv.Standard == invoiceEDRM { + dst, numeric = invoiceEDRMField(&inv, item, path) + } if dst == nil { return nil } @@ -1039,11 +1087,67 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo if err := walk(root, "", nil); err != nil { return InvoiceReport{}, err } + if inv.Standard == invoiceEDRM && inv.Date != "" { + 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, nil } +// invoiceEDRMField 按2019版电子发票路径映射公共字段,合并信息保留原始事实 +// 入参: inv 发票,item 当前明细,path 命名空间已校验的路径 +// 返回: 字段地址及数值标志 +func invoiceEDRMField(inv *Invoice, item *InvoiceItem, path string) (*string, bool) { + switch path { + case "/eInvoice/InvoiceCode": + return &inv.Code, false + case "/eInvoice/InvoiceNo": + return &inv.Number, false + case "/eInvoice/IssueDate": + return &inv.Date, false + case "/eInvoice/Buyer/BuyerName": + return &inv.Buyer.Name, false + case "/eInvoice/Buyer/BuyerTaxID": + return &inv.Buyer.TaxID, false + case "/eInvoice/Seller/SellerName": + return &inv.Seller.Name, false + case "/eInvoice/Seller/SellerTaxID": + return &inv.Seller.TaxID, false + case "/eInvoice/TaxExclusiveTotalAmount": + return &inv.Amount, true + case "/eInvoice/TaxTotalAmount": + return &inv.TaxAmount, true + case "/eInvoice/TaxInclusiveTotalAmount": + return &inv.TotalAmount, true + case "/eInvoice/Note": + return &inv.Remarks, false + } + if item != nil && strings.HasPrefix(path, "/eInvoice/GoodsInfos/GoodsInfo/") { + switch strings.TrimPrefix(path, "/eInvoice/GoodsInfos/GoodsInfo/") { + case "Item": + return &item.Name, false + case "Specification": + return &item.Specification, false + case "MeasurementDimension": + return &item.Unit, false + case "Quantity": + return &item.Quantity, true + case "Price": + return &item.UnitPrice, true + case "Amount": + return &item.Amount, true + case "TaxScheme": + return &item.TaxRate, false + case "TaxAmount": + return &item.TaxAmount, true + } + } + return nil, false +} + // invoicePlainField 按官方样例中的完整路径映射字段 // 入参: inv 发票,item 当前明细,path 完整无命名空间路径 // 返回: 字段地址及数值标志