From 2daae08a0f3b8dd7acfa95b4e2cf3448f67ea3ac Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Fri, 16 Oct 2026 13:15:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=94=B5=E5=AD=90=E5=8F=91=E7=A5=A8):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8F=91=E7=A5=A8=E7=BC=96=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 32 ++++++++--- ofdgo_editor_invoice.go | 116 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a9317ac..52b2ce1 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -5672,6 +5672,7 @@ async function loadInvoiceSettings(sequence, openSeq) { function invoiceDraft(detail, entry, scope) { return { detail, invoice: structuredClone(detail.invoice), entry, scope, + baselineFields: structuredClone(detail.fields || []), dirty: new Set(), changes: new Map(), addedItems: new Set(), bindings: structuredClone(detail.bindings || []), xml: detail.xml || "", page: state.pageIndex, object: "", field: "", @@ -5972,7 +5973,7 @@ function renderInvoiceItems(container, draft) { draft.item = next; invoiceDirty(draft, "model"); await invoiceRefreshFields(draft); - renderDocumentSettings(); + if (invoiceCurrent() === draft) renderDocumentSettings(); }; settingsButton("添加", async () => { const item = {}; @@ -5981,14 +5982,14 @@ function renderInvoiceItems(container, draft) { draft.item = items.length - 1; invoiceDirty(draft, "model"); await invoiceRefreshFields(draft); - renderDocumentSettings(); + if (invoiceCurrent() === draft) renderDocumentSettings(); }, !editable, "InvoiceItemAdd", actions); settingsButton("删除", async () => { if (draft.changes.size) throw new Error("请先应用原字段修改,再删除明细"); items.splice(draft.item, 1); invoiceDirty(draft, "model"); await invoiceRefreshFields(draft); - renderDocumentSettings(); + if (invoiceCurrent() === draft) renderDocumentSettings(); }, !editable || !items.length, "InvoiceItemDelete", actions); settingsButton("上移", () => reorder(-1), !editable || draft.item === 0 || !items.length, "InvoiceItemUp", actions); settingsButton("下移", () => reorder(1), !editable || draft.item >= items.length - 1, "InvoiceItemDown", actions); @@ -5998,7 +5999,7 @@ function renderInvoiceItems(container, draft) { async function invoiceRefreshFields(draft) { const invoice = structuredClone(draft.invoice); const detail = await callWASM("ofdgoInvoice", JSON.stringify({ invoice, ...draft.scope })); - if (invoiceCurrent() === draft && JSON.stringify(draft.invoice) === JSON.stringify(invoice)) draft.detail.fields = detail.fields; + if ([...invoicePanel.drafts.values()].includes(draft) && JSON.stringify(draft.invoice) === JSON.stringify(invoice)) draft.detail.fields = detail.fields; } function renderInvoiceFields(container, draft) { @@ -6206,13 +6207,17 @@ function renderInvoiceAppearance(container, draft) { settingsButton("生成", async () => { if (draft.dirty.size) throw new Error("请先应用草稿"); if (!draft.layout.font && !draft.fontChoice?.startsWith("file:")) throw new Error("请选择字体"); + const openSeq = state.openSeq, sequence = settings.sequence; + const fontChoice = draft.fontChoice, layout = structuredClone(draft.layout); let fontData; - if (draft.fontChoice?.startsWith("file:")) { - const font = libraryFonts.find(value => `file:${value.id}` === draft.fontChoice); + if (fontChoice?.startsWith("file:")) { + const font = libraryFonts.find(value => `file:${value.id}` === fontChoice); if (!font) throw new Error("所选字体不可用"); fontData = invoiceBase64(await readTextFont(font)); } - await commitInvoice(draft, { operation: "generate", layout: draft.layout, fontData }); + if (openSeq !== state.openSeq || sequence !== settings.sequence || invoiceCurrent() !== draft || !settingsElements.Panel.open || settingsElements.Kind.value !== "invoices") return; + if (draft.dirty.size || draft.fontChoice !== fontChoice || JSON.stringify(draft.layout) !== JSON.stringify(layout)) throw new Error("内容已变更,请重新生成"); + await commitInvoice(draft, { operation: "generate", layout, fontData }); }, !capabilities.generate, "InvoiceGenerate", details, capabilities.reasons?.generate || (draft.bindings.length ? "请先解除绑定" : "")); settingsButton("同步", async () => { if (draft.dirty.size) throw new Error("请先应用草稿"); @@ -6245,6 +6250,11 @@ function resetInvoiceDraft(draft) { else if (section === "bindings") draft.bindings = structuredClone(draft.detail.bindings || []); else { draft.invoice = structuredClone(draft.detail.invoice); + draft.detail.fields = structuredClone(draft.baselineFields); + draft.addedItems.clear(); + draft.item = 0; + draft.fact = 0; + draft.factOwner = -1; draft.options = { name: "", encoding: { schemaRef: "", context: {}, units: [] } }; draft.fragments = {}; } @@ -6309,6 +6319,7 @@ async function applyInvoiceDraft(draft) { function buildInvoiceRequest(draft) { if (draft.dirty.has("xml") && draft.dirty.size > 1) throw new Error("原文替换与其他修改冲突,草稿已保留,请先还原其中一项"); + if (draft.dirty.has("model") && draft.dirty.has("fields")) throw new Error("基本、明细与原字段不能同时应用,请先还原一项"); const section = draft.dirty.has("xml") ? "xml" : "model"; const request = { synchronize: draft.synchronize }; if (section === "xml") { @@ -6338,6 +6349,10 @@ function invoiceBase64(bytes) { async function commitInvoice(draft, request) { if (!state.editing || settings.busy || document.body.hasAttribute("aria-busy")) return; + if (!settingsElements.Panel.open || settingsElements.Kind.value !== "invoices" || invoiceCurrent() !== draft) return; + const scope = invoiceScope(); + if (draft.scope.documentIndex !== scope.documentIndex || draft.scope.version !== scope.version || draft.scope.revision !== scope.revision) throw new Error("文档已变更,请重新打开配置"); + const key = invoicePanel.key; settings.busy = true; settingsElements.Form.inert = true; settingsElements.Status.textContent = "正在保存修改"; @@ -6348,7 +6363,7 @@ async function commitInvoice(draft, request) { return; } const result = invoicePanel.result; - invoicePanel.drafts.delete(invoicePanel.key); + if (invoicePanel.drafts.get(key) === draft) invoicePanel.drafts.delete(key); const currentScope = invoiceScope(); for (const [key, value] of invoicePanel.drafts) { if (value.scope.documentIndex !== currentScope.documentIndex || value.scope.version !== currentScope.version) continue; @@ -6365,6 +6380,7 @@ async function commitInvoice(draft, request) { } } } + if (!settingsElements.Panel.open || settingsElements.Kind.value !== "invoices" || invoicePanel.key !== key) return; if (result?.source?.kind) invoicePanel.key = JSON.stringify([invoiceSourceKey(result.source), draft.entry?.index || 0]); await loadDocumentSettings(); const warning = invoiceIssues(result?.issues); diff --git a/ofdgo_editor_invoice.go b/ofdgo_editor_invoice.go index 12f1e40..caf8c31 100644 --- a/ofdgo_editor_invoice.go +++ b/ofdgo_editor_invoice.go @@ -15,10 +15,13 @@ package ofdgo import ( + "bytes" "context" "encoding/json" + "encoding/xml" "fmt" "path" + "reflect" "slices" "strconv" "strings" @@ -127,7 +130,10 @@ func (r *Reader) InvoiceCapabilities(ctx context.Context, source InvoiceSource, result.Sync = false result.Generate = false result.Bind = false - for _, operation := range []string{"sync", "generate", "bind"} { + result.Edit = false + result.Delete = false + result.Items = false + for _, operation := range []string{"edit", "delete", "items", "sync", "generate", "bind"} { result.Reasons[operation] = "票面关联损坏,请先修复关联" } return result, nil @@ -333,7 +339,7 @@ func (e *Editor) UpdateInvoice(ctx context.Context, source InvoiceSource, digest if err != nil { return err } - previous, err := parseInvoiceXML(ctx, original, source) + previous, err := parseInvoiceXML(ctx, original, InvoiceSource{}) if err != nil { return err } @@ -353,6 +359,16 @@ func (e *Editor) UpdateInvoice(ctx context.Context, source InvoiceSource, digest if synchronize && len(entries) == 0 { return fmt.Errorf("invoice has no appearance bindings") } + pages := make(map[string]string) + for _, entry := range entries { + for _, binding := range entry.bindings { + page, err := next.Page(binding.Page) + if err != nil { + return err + } + pages[fmt.Sprintf("%d/%s", entry.index, binding.ObjectID)] = page.ID + } + } updated, err := next.ReplaceInvoiceData(ctx, source, digest, data) if err != nil { return err @@ -374,11 +390,24 @@ func (e *Editor) UpdateInvoice(ctx context.Context, source InvoiceSource, digest } continue } - if synchronize { - if entry.record.Invoice >= len(previous.Invoices) { - return fmt.Errorf("bound invoice no longer exists") + if entry.record.Invoice >= len(previous.Invoices) { + return fmt.Errorf("bound invoice no longer exists") + } + originalBindings := slices.Clone(entry.bindings) + entry.bindings, err = invoiceRemapBindings(previous.Invoices[entry.record.Invoice], report.Invoices[entry.record.Invoice], entry.bindings) + if err != nil { + return err + } + for i := range entry.bindings { + id := pages[fmt.Sprintf("%d/%s", entry.index, entry.bindings[i].ObjectID)] + page := slices.IndexFunc(next.pages, func(page PageContent) bool { return sameResourceID(page.ID, id) }) + if page < 0 { + return fmt.Errorf("invoice appearance page no longer exists") } - if err := invoiceBindingCoverage(previous.Invoices[entry.record.Invoice], report.Invoices[entry.record.Invoice], entry.bindings); err != nil { + entry.bindings[i].Page = page + } + if synchronize { + if err := invoiceBindingCoverage(previous.Invoices[entry.record.Invoice], report.Invoices[entry.record.Invoice], originalBindings); err != nil { return err } if err := next.SyncInvoicePages(ctx, report.Invoices[entry.record.Invoice], entry.bindings); err != nil { @@ -389,7 +418,29 @@ func (e *Editor) UpdateInvoice(ctx context.Context, source InvoiceSource, digest if err != nil { return err } - if err := next.UpdateStructureNode(entry.index, nil, StructureNode{Name: "Invoice", Namespace: invoiceNamespace, Text: string(encoded)}); err != nil { + if err := next.editStructure(entry.index, func(data []byte, root *editorXML, _ CustomTag) ([]byte, error) { + var patches []editorXMLPatch + for i, binding := range entry.bindings { + if binding.Field == originalBindings[i].Field { + continue + } + patch, err := editorXMLAttributePatch(data, root.children[i], "Name", binding.Field) + if err != nil { + return nil, err + } + patches = append(patches, patch) + } + data = editorPatchXML(data, patches) + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return nil, err + } + var text bytes.Buffer + if err := xml.EscapeText(&text, encoded); err != nil { + return nil, err + } + return editorXMLDirectText(data, root, text.Bytes()) + }); err != nil { return err } } @@ -943,6 +994,57 @@ func invoicePayloadSignature(ctx context.Context, data []byte) (bool, error) { return false, nil } +// invoiceRemapBindings 按唯一事实身份维护索引关联,不限制未关联业务字段的修改 +// 入参: before 原发票, after 新发票, bindings 原关联 +// 返回: []InvoiceBinding 更新后的关联, error 身份缺失或歧义错误 +func invoiceRemapBindings(before, after Invoice, bindings []InvoiceBinding) ([]InvoiceBinding, error) { + result := slices.Clone(bindings) + for i := range result { + binding := &result[i] + oldFacts, newFacts, prefix := before.Facts, after.Facts, "facts." + if strings.HasPrefix(binding.Field, "items.") { + if binding.Item < 0 || binding.Item >= len(before.Items) || binding.Item >= len(after.Items) { + return nil, fmt.Errorf("bound invoice item no longer exists") + } + if len(before.Items) != len(after.Items) && !reflect.DeepEqual(before.Items[binding.Item], after.Items[binding.Item]) { + return nil, fmt.Errorf("bound invoice item identity changed") + } + oldFacts, newFacts, prefix = before.Items[binding.Item].Fields, after.Items[binding.Item].Fields, "items.fields." + } + if !strings.HasPrefix(binding.Field, prefix) { + continue + } + index, err := strconv.Atoi(strings.TrimPrefix(binding.Field, prefix)) + if err != nil || index < 0 || index >= len(oldFacts) { + return nil, fmt.Errorf("bound invoice fact no longer exists") + } + fact := oldFacts[index] + identity := func(candidate InvoiceFact) bool { + return candidate.Name == fact.Name && candidate.Namespace == fact.Namespace && candidate.Context == fact.Context && candidate.Unit == fact.Unit && slices.Equal(candidate.Dimensions, fact.Dimensions) + } + oldCount, match, count := 0, -1, 0 + for _, candidate := range oldFacts { + if identity(candidate) { + oldCount++ + } + } + for j, candidate := range newFacts { + if identity(candidate) { + count++ + match = j + } + } + if oldCount != 1 || count != 1 || fact.Source.NodePath == "" || newFacts[match].Source.NodePath != fact.Source.NodePath { + if reflect.DeepEqual(oldFacts, newFacts) { + continue + } + return nil, fmt.Errorf("bound invoice fact identity is missing or ambiguous") + } + binding.Field = fmt.Sprintf("%s%d", prefix, match) + } + return result, nil +} + // invoiceBindingCoverage 拒绝将未关联的字段修改误报为已同步,不猜测缺失图元 // 入参: before 原发票, after 新发票, bindings 显式关联 // 返回: error 未绑定字段错误