feat(电子发票): 修复发票空值校验与标引读取
build.yaml / build (push) Successful in 52s

This commit is contained in:
xiaoqidun committed 2026-10-16 15:00:25 +08:00
1 parent b8d90174a0
commit 68d6a19656
7 files changed
+229 -27

No files matched your search

+2 -1
View File
@@ -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");
+23 -9
View File
@@ -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
}
+9 -3
View File
@@ -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
}
+26 -11
View File
@@ -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
+162
View File
@@ -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")
+4 -3
View File
@@ -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")
}
}
+3
View File
@@ -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