feat(电子发票): 修复旧版发票读取与重复提示
build.yaml / build (push) Successful in 52s

This commit is contained in:
xiaoqidun committed 2026-10-16 14:42:59 +08:00
1 parent 7a0bbbe8df
commit b8d90174a0
5 files changed
+159 -24

No files matched your search

+5
View File
@@ -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%;
+25 -7
View File
@@ -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"));
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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")
}
+119 -15
View File
@@ -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 完整无命名空间路径
// 返回: 字段地址及数值标志