From 30f4f762606901f4943b25230e14e865fcf8e2e6 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Fri, 16 Oct 2026 13:00:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=94=B5=E5=AD=90=E5=8F=91=E7=A5=A8):=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8F=91=E7=A5=A8=E7=BC=96=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E7=A5=A8=E9=9D=A2=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 2 +- assets/webui/ofdgo.css | 121 +++++++ assets/webui/ofdgo.js | 776 +++++++++++++++++++++++++++++++++++++++- internal/webui/wasm.go | 542 ++++++++++++++++++++++++++++ ofdgo_editor_invoice.go | 158 +++++++- ofdgo_invoice_write.go | 108 ++++++ 6 files changed, 1697 insertions(+), 10 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index 3fc66e0..8d71506 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -411,7 +411,7 @@

文档配置

- +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index dbcbe74..25ee2f0 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1810,6 +1810,127 @@ body[data-hide-meta] .meta-panel { overflow: hidden; } +.settings-dialog.invoice-dialog { + width: 820px; + max-width: calc(100vw - 26px); +} + +.invoice-tabs { + display: flex; + gap: 16px; + margin-block: 12px; + border-bottom: 1px solid var(--line); +} + +.invoice-tabs > .button { + flex: none; + min-width: 2em; + padding: 8px 0; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; +} + +.invoice-tabs > .button[aria-selected="true"] { + border-bottom-color: currentColor; + color: var(--accent); +} + +.invoice-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-block: 8px; +} + +.invoice-group-title { + margin: 12px 0 8px; + font-size: 1em; + font-weight: 600; +} + +.invoice-advanced { + margin-block: 12px 0; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.invoice-advanced > summary { + cursor: pointer; + margin-bottom: 12px; +} + +.invoice-advanced > .button { + margin: 0 8px 8px 0; +} + +.invoice-unit { + padding-block: 8px; + border-bottom: 1px solid var(--line); +} + +.invoice-table-scroll { + max-width: 100%; + overflow: auto; +} + +.invoice-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + table-layout: fixed; +} + +.invoice-table th, +.invoice-table td { + padding: 4px; + border-bottom: 1px solid var(--line); + text-align: left; + font-weight: normal; +} + +.invoice-table th:first-child { + width: 3em; +} + +.invoice-table th:nth-child(2) { + width: 8em; +} + +.invoice-table input:not([type="radio"]) { + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.invoice-dialog #settingsInvoiceXML { + font-family: monospace; + white-space: pre; +} + +.invoice-dialog .settings-footer { + flex-wrap: nowrap; +} + +.invoice-dialog #settingsStatus { + min-width: 0; + max-height: 6em; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.invoice-dialog #settingsActions { + flex: 1 0 auto; +} + +@media (max-width: 600px) { + .invoice-dialog #settingsActions { + flex: 1 1 auto; + } +} + .settings-dialog :focus-visible { outline-offset: -2px; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 96995c9..a9317ac 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -27,6 +27,7 @@ const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "K const resourceManager = { items: [], selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null }; const settingsElements = Object.fromEntries(["Button", "Panel", "Form", "Title", "Kind", "Context", "Content", "Actions", "Status"].map(name => [name, document.querySelector(`#settings${name}`)])); const settings = { sequence: 0, busy: false, page: 0, scope: "document", object: "", path: [], selected: -1, data: null, actions: [], drafts: new Map(), view: "", baseline: {}, suspended: false }; +const invoicePanel = { tab: "basic", entries: [], formats: [], drafts: new Map(), key: "", result: null }; let wasmPromise = null; let remoteDownload = null; @@ -1531,7 +1532,7 @@ editorClick(settingsElements.Button, openDocumentSettings); settingsElements.Kind.addEventListener("change", () => { rememberSettingsDraft(); settings.selected = -1; loadDocumentSettings(); }); settingsElements.Form.addEventListener("submit", event => event.preventDefault()); settingsElements.Panel.addEventListener("cancel", event => { event.preventDefault(); closeDocumentSettings(); }); -settingsElements.Panel.addEventListener("close", () => { if (!settings.suspended) { settings.sequence++; settings.drafts.clear(); settings.view = ""; } }); +settingsElements.Panel.addEventListener("close", () => { if (!settings.suspended) { settings.sequence++; settings.drafts.clear(); settings.view = ""; invoicePanel.drafts.clear(); invoicePanel.key = ""; } }); document.querySelector("#objectActionsButton").addEventListener("click", async () => { const item = canvasEditor.selected; if (!item || item.scoped || item.items) return; @@ -3640,6 +3641,7 @@ async function changeDocument(name, item, ...args) { ? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args) : annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args) : await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); + if (name === "ofdgoChangeInvoice") invoicePanel.result = doc.invoiceResult; if (openSeq !== state.openSeq) { return; } @@ -4622,6 +4624,7 @@ async function openSelectedFonts(event) { if (changed) { await applyFontChange(); if (resourceElements.Panel.open) await resourceTask(loadManagedResources); + if (settingsElements.Panel.open && settingsElements.Kind.value === "invoices") { await loadEditorFonts(); renderDocumentSettings(); } } setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用"); } catch (err) { @@ -5427,6 +5430,7 @@ function settingsSnapshot() { } function rememberSettingsDraft() { + if (settings.renderedKind === "invoices") return; if (!state.editing || !settings.view || settings.busy) return; const values = settingsSnapshot(); const changed = Object.keys(values).some(id => { @@ -5475,7 +5479,7 @@ function restoreSettingsDraft() { function closeDocumentSettings() { if (settings.busy) return; rememberSettingsDraft(); - if (settings.drafts.size && !window.confirm("配置尚未应用,放弃更改?")) return; + if ((settings.drafts.size || [...invoicePanel.drafts.values()].some(draft => draft.dirty.size)) && !window.confirm("配置尚未应用,放弃更改?")) return; settingsElements.Panel.close(); } @@ -5499,6 +5503,8 @@ function settingsValue(id) { } async function openDocumentSettings() { + invoicePanel.drafts.clear(); + invoicePanel.key = ""; if (!state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return; if (resourceManager.pick?.settings) { finishResourcePick(); return; } settings.page = state.pageIndex; @@ -5538,6 +5544,7 @@ async function loadDocumentSettings() { settingsElements.Form.inert = true; settingsElements.Status.textContent = "正在读取信息"; try { + if (kind === "invoices") { await loadInvoiceSettings(sequence, openSeq); return; } const data = ["bookmarks", "actions", "extensions"].includes(kind) ? await callWASM("ofdgoDocumentNavigation", kind) : {}; if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return; data.resources = ["bookmarks", "extensions"].includes(kind) ? [] : await callWASM("ofdgoResources") || []; @@ -5588,6 +5595,7 @@ function renderDocumentSettings() { settingsElements.Content.replaceChildren(); settingsElements.Actions.replaceChildren(); const kind = settingsElements.Kind.value; + settings.renderedKind = kind; const location = document.querySelector("#settingsLocation"), doc = state.doc; if (location) { const current = doc?.documents?.find(entry => entry.index === doc.documentIndex) || doc?.documents?.[doc?.documentIndex || 0]; @@ -5595,6 +5603,12 @@ function renderDocumentSettings() { location.hidden = !(doc?.documents?.length > 1 || doc?.documentVersion); } settingsElements.Title.textContent = {ColorSpace: "色彩设置", DrawParam: "样式设置"}[kind] || "文档配置"; + settingsElements.Panel.classList.toggle("invoice-dialog", kind === "invoices"); + if (kind === "invoices") { + renderInvoiceSettings(); + settingsButton("关闭", closeDocumentSettings, false, "Close").classList.add("settings-close"); + return; + } if (kind === "actions") renderActionSettings(); else if (kind === "bookmarks") renderBookmarkSettings(); else if (kind === "extensions") renderExtensionSettings(); @@ -5612,6 +5626,758 @@ function renderDocumentSettings() { settingsButton("关闭", closeDocumentSettings, false, "Close").classList.add("settings-close"); } +function invoiceSourceKey(source = {}) { + return JSON.stringify([source.kind || "", source.id || "", source.fileLoc || "", source.nodePath || ""]); +} + +function invoiceScope() { + return { + documentIndex: state.doc.documentIndex || 0, + version: state.doc.documentVersion || "", + ...(state.editing ? { revision: state.editorInfo.revision } : {}) + }; +} + +function invoiceCandidates(invoices) { + const counts = new Map(), result = []; + for (const invoice of invoices || []) { + for (const source of new Map((invoice.sources || []).map(value => [invoiceSourceKey(value), value])).values()) { + const sourceKey = invoiceSourceKey(source); + const index = counts.get(sourceKey) || 0; + counts.set(sourceKey, index + 1); + result.push({ invoice, source, index, key: JSON.stringify([sourceKey, index]) }); + } + } + return result; +} + +async function loadInvoiceSettings(sequence, openSeq) { + const data = await callWASM("ofdgoInvoices"); + if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return; + invoicePanel.entries = invoiceCandidates(data.report?.invoices); + invoicePanel.formats = data.formats || []; + invoicePanel.issues = data.report?.issues || []; + if (!invoicePanel.drafts.has(invoicePanel.key) && !invoicePanel.entries.some(entry => entry.key === invoicePanel.key)) invoicePanel.key = invoicePanel.entries[0]?.key || ""; + const entry = invoicePanel.entries.find(entry => entry.key === invoicePanel.key); + if (entry && !invoicePanel.drafts.has(entry.key)) { + const scope = invoiceScope(); + const detail = await callWASM("ofdgoInvoice", JSON.stringify({ source: entry.source, index: entry.index, ...scope })); + if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return; + invoicePanel.drafts.set(entry.key, invoiceDraft(detail, entry, scope)); + } + settingsElements.Status.textContent = ""; + renderDocumentSettings(); +} + +function invoiceDraft(detail, entry, scope) { + return { + detail, invoice: structuredClone(detail.invoice), entry, scope, + dirty: new Set(), changes: new Map(), + addedItems: new Set(), + bindings: structuredClone(detail.bindings || []), xml: detail.xml || "", page: state.pageIndex, object: "", field: "", + layout: { font: "", fontSize: 4, pageWidth: 210, pageHeight: 297, margin: 15 }, synchronize: !!detail.bindings?.length && !!detail.capabilities?.sync, + options: { name: "", encoding: { schemaRef: "", context: {}, units: [] } }, + fragments: {}, item: 0 + }; +} + +function invoiceCurrent() { + return invoicePanel.drafts.get(invoicePanel.key); +} + +function invoiceDirty(draft, section) { + draft.dirty.add(section); + const button = document.querySelector("#settingsInvoiceApply"); + if (button) button.disabled = !state.editing; + const reset = document.querySelector("#settingsInvoiceReset"); + if (reset) reset.disabled = !state.editing; +} + +function invoiceInput(container, label, id, value, change, disabled = false, type = "text", unit = "") { + const field = settingsField(container, label, `Invoice${id}`, value, type, unit); + field.disabled = disabled; + field.addEventListener("input", () => change(type === "checkbox" ? field.checked : field.value)); + if (!state.editing && type !== "textarea") { + const text = document.createElement("span"); + text.className = "settings-readonly"; + text.id = field.id; + text.textContent = type === "checkbox" ? value ? "是" : "否" : value || "-"; + field.replaceWith(text); + return text; + } + if (!state.editing && type === "textarea") { + field.disabled = false; + field.readOnly = true; + } + return field; +} + +function invoiceValue(invoice, field, item = -1) { + const keys = field.split("."); + let value = keys[0] === "items" ? invoice.items?.[item] : invoice; + if (keys[0] === "items") keys.shift(); + for (const key of keys) value = value?.[key]; + return value ?? ""; +} + +function invoiceSetValue(invoice, field, item, value) { + const keys = field.split("."); + let target = keys[0] === "items" ? invoice.items[item] : invoice; + if (keys[0] === "items") keys.shift(); + for (const key of keys.slice(0, -1)) target = target[key] ||= {}; + 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 renderInvoiceSettings() { + const context = settingsElements.Context, content = settingsElements.Content; + 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 => { + invoicePanel.key = invoicePanel.entries.find(entry => invoiceSourceKey(entry.source) === value).key; + loadDocumentSettings(); + }); + const candidates = invoicePanel.entries.filter(entry => !draft?.entry || invoiceSourceKey(entry.source) === invoiceSourceKey(draft.entry.source)); + const choices = candidates.map(entry => [entry.key, entry.invoice.number || entry.invoice.code || `${entry.invoice.kind} · ${entry.index + 1}`]); + if (invoicePanel.drafts.has("new")) choices.push(["new", "新建"]); + if (choices.length) settingsSelect(context, "发票", "InvoiceEntry", choices, invoicePanel.key, value => { invoicePanel.key = value; loadDocumentSettings(); }); + const actions = document.createElement("div"); + actions.className = "invoice-actions"; + context.append(actions); + if (state.editing) { + settingsButton("新建", newInvoiceDraft, false, "InvoiceNew", actions); + settingsButton("导入", () => readInvoiceFile(async text => { + const value = await newInvoiceDraft(); + if (!value) return; + value.xml = text; + value.importing = true; + value.dirty.add("xml"); + invoicePanel.tab = "xml"; + renderDocumentSettings(); + }), false, "InvoiceImport", actions); + } + if (!draft) { + settingsElements.Status.textContent = invoiceIssues(invoicePanel.issues) || "暂无发票"; + return; + } + const tabs = document.createElement("div"); + tabs.className = "invoice-tabs"; + tabs.setAttribute("role", "tablist"); + tabs.setAttribute("aria-label", "发票信息"); + for (const [key, label, id] of [["basic", "基本", "Basic"], ["items", "明细", "Items"], ["fields", "字段", "Fields"], ["appearance", "票面", "Appearance"], ["xml", "原文", "XML"]]) { + const button = settingsButton(label, () => { invoicePanel.tab = key; renderDocumentSettings(); }, false, `InvoiceTab${id}`, tabs); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(invoicePanel.tab === key)); + button.setAttribute("aria-controls", "invoiceTabContent"); + button.tabIndex = invoicePanel.tab === key ? 0 : -1; + button.addEventListener("keydown", event => { + const buttons = [...tabs.children]; + const index = buttons.indexOf(button); + const next = event.key === "ArrowRight" ? (index + 1) % buttons.length : event.key === "ArrowLeft" ? (index + buttons.length - 1) % buttons.length : event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : -1; + if (next >= 0) { + event.preventDefault(); + const target = buttons[next].id; + buttons[next].click(); + document.getElementById(target)?.focus(); + } + }); + } + content.append(tabs); + const body = document.createElement("div"); + body.id = "invoiceTabContent"; + body.setAttribute("role", "tabpanel"); + body.setAttribute("aria-labelledby", tabs.querySelector('[aria-selected="true"]').id); + content.append(body); + if (invoicePanel.tab === "basic") renderInvoiceBasic(body, draft); + else if (invoicePanel.tab === "items") renderInvoiceItems(body, draft); + else if (invoicePanel.tab === "fields") renderInvoiceFields(body, draft); + else if (invoicePanel.tab === "appearance") renderInvoiceAppearance(body, draft); + else renderInvoiceXML(body, draft); + settingsButton("校验", () => validateInvoiceDraft(draft), false, "InvoiceValidate"); + settingsButton("导出", () => downloadBytes(new TextEncoder().encode(JSON.stringify(draft.invoice, null, 2)), "application/json", "invoice.json"), false, "InvoiceExportJSON").title = "导出JSON"; + if (state.editing) { + settingsButton("应用", () => applyInvoiceDraft(draft), !draft.dirty.size, "InvoiceApply"); + settingsButton("还原", () => resetInvoiceDraft(draft), !draft.dirty.size, "InvoiceReset"); + if (draft.entry) settingsButton("删除", async () => { + if (draft.dirty.size) throw new Error("请先应用或还原草稿"); + if (window.confirm("删除此来源中的发票数据?页面将保留" + invoiceOtherDraftWarning(draft))) await commitInvoice(draft, { operation: "delete" }); + }, !draft.detail.capabilities?.delete, "InvoiceDelete"); + } + settingsElements.Status.textContent = draft.status || invoiceIssues(draft.detail.issues) || ""; +} + +async function newInvoiceDraft() { + if (!state.editing) return; + if (invoicePanel.drafts.get("new")?.dirty.size && !window.confirm("放弃尚未应用的新建发票?")) return; + const format = invoicePanel.formats.find(value => value.create); + if (!format) throw new Error("暂无可新建票种"); + const invoice = { kind: format.kind, standard: format.standard, version: format.version, buyer: {}, seller: {}, items: [], facts: [] }; + const scope = invoiceScope(); + const sequence = settings.sequence; + const detail = await callWASM("ofdgoInvoice", JSON.stringify({ invoice, ...scope })); + if (sequence !== settings.sequence || !settingsElements.Panel.open) return; + invoicePanel.drafts.set("new", invoiceDraft(detail, null, scope)); + invoicePanel.key = "new"; + invoicePanel.tab = "basic"; + renderDocumentSettings(); + return invoiceCurrent(); +} + +function renderInvoiceBasic(container, draft) { + const fields = (draft.detail.fields || []).filter(field => field.item < 0 && !field.field.startsWith("facts.") && !field.field.startsWith("items.")); + const labels = { + kind: "票种", standard: "规范", version: "版本", number: "号码", code: "代码", date: "日期", + currency: "币种", status: "状态", amount: "金额", taxAmount: "税额", totalAmount: "合计", remarks: "备注", + name: "名称", taxId: "税号", address: "地址", phone: "电话", bank: "银行", account: "账号" + }; + for (const [group, label] of [["basic", "票据"], ["buyer", "购方"], ["seller", "销方"]]) { + const title = document.createElement("h3"), grid = document.createElement("div"); + title.className = "invoice-group-title"; + title.textContent = label; + 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 => { + 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; + } + } + 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 || "同步票面"; + if (!draft.entry) renderInvoiceAdvanced(container, draft); +} + +function renderInvoiceAdvanced(container, draft) { + const details = document.createElement("details"), summary = document.createElement("summary"); + summary.textContent = "高级"; + details.className = "invoice-advanced"; + details.open = draft.advanced || false; + details.addEventListener("toggle", () => { draft.advanced = details.open; }); + details.append(summary); + container.append(details); + const formats = invoicePanel.formats.filter(value => value.create); + const selected = formats.findIndex(value => value.kind === draft.invoice.kind && value.standard === draft.invoice.standard && value.version === draft.invoice.version); + settingsSelect(details, "票种", "InvoiceFormat", formats.map((value, index) => [index, `${{ inv: "增值税", einv: "电子票", atr: "航空票", rai: "铁路票" }[value.kind] || value.kind} · ${value.version} · ${value.standard === "EInvoice" ? "XML" : "XBRL"}`]), selected, async value => { + try { + const format = formats[Number(value)]; + Object.assign(draft.invoice, { kind: format.kind, standard: format.standard, version: format.version }); + invoiceDirty(draft, "model"); + await invoiceRefreshFields(draft); + if (invoiceCurrent() === draft) renderDocumentSettings(); + } catch (error) { + settingsElements.Status.textContent = error.message; + } + }); + invoiceInput(details, "名称", "Name", draft.options.name, value => { draft.options.name = value; invoiceDirty(draft, "model"); }); + const encoding = draft.options.encoding; + if (draft.invoice.standard !== "EInvoice") { + invoiceInput(details, "模式", "SchemaRef", encoding.schemaRef, value => { encoding.schemaRef = value; invoiceDirty(draft, "model"); }).title = "schemaRef"; + for (const [key, label] of [["id", "标识"], ["identifier", "主体"], ["scheme", "方案"], ["instant", "时点"], ["startDate", "起日"], ["endDate", "止日"]]) { + invoiceInput(details, label, `Context_${key}`, encoding.context[key], value => { encoding.context[key] = value; invoiceDirty(draft, "model"); }).title = `context.${key}`; + } + for (const [index, unit] of encoding.units.entries()) { + const row = document.createElement("div"); + row.className = "invoice-unit"; + details.append(row); + for (const [key, label] of [["id", "标识"], ["namespace", "空间"], ["name", "单位"]]) { + invoiceInput(row, label, `Unit_${index}_${key}`, unit[key], value => { unit[key] = value; invoiceDirty(draft, "model"); }).title = key === "namespace" ? "命名空间" : label; + } + settingsButton("删除", () => { + encoding.units.splice(index, 1); + invoiceDirty(draft, "model"); + renderDocumentSettings(); + }, false, `InvoiceUnitDelete${index}`, row); + } + settingsButton("添加", () => { + encoding.units.push({ id: "", namespace: "", name: "" }); + invoiceDirty(draft, "model"); + renderDocumentSettings(); + }, false, "InvoiceUnitAdd", details).title = "添加单位"; + } + for (const [key, label] of [["headerXML", "票头"], ["sellerAuthenticationXML", "认证"], ["taxSupervisionXML", "监制"], ["taxBureauSignatureXML", "签名"]]) { + invoiceInput(details, label, `Fragment_${key}`, draft.fragments[key], value => { + draft.fragments[key] = value; + invoiceDirty(draft, "model"); + }, false, "textarea"); + settingsButton("导入", () => readInvoiceFile(text => { + draft.fragments[key] = text; + invoiceDirty(draft, "model"); + renderDocumentSettings(); + }), false, `InvoiceFragmentImport_${key}`, details).title = `导入${label}XML`; + } +} + +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); + 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"); + const head = table.createTHead().insertRow(); + scroll.className = "invoice-table-scroll"; + table.className = "invoice-table"; + table.id = "settingsInvoiceItems"; + for (const label of ["选择", ...columns.map(entry => entry[1])]) { + const cell = document.createElement("th"); + cell.scope = "col"; + cell.textContent = label; + head.append(cell); + } + const body = table.createTBody(); + for (const [index, item] of items.entries()) { + const row = body.insertRow(), radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "invoiceItem"; + radio.checked = index === draft.item; + radio.setAttribute("aria-label", `明细${index + 1}`); + radio.addEventListener("change", () => { + draft.item = index; + renderDocumentSettings(); + }); + row.insertCell().append(radio); + for (const [key, label] of columns) { + const input = document.createElement("input"); + input.type = "text"; + input.value = item[key] || ""; + input.id = `settingsInvoiceItem_${index}_${key}`; + input.setAttribute("aria-label", `${label}${index + 1}`); + const descriptor = draft.detail.fields?.find(field => field.field === `items.${key}` && field.item === index); + input.disabled = !editable || !descriptor?.editable; + input.addEventListener("input", () => { + item[key] = input.value; + invoiceDirty(draft, "model"); + }); + const cell = row.insertCell(); + if (!state.editing) { + const text = document.createElement("span"); + text.className = "settings-readonly"; + text.textContent = item[key] || "-"; + cell.append(text); + } else cell.append(input); + } + } + scroll.append(table); + container.append(scroll); + if (state.editing) { + const actions = document.createElement("div"); + actions.className = "invoice-actions"; + container.append(actions); + const reorder = async (direction) => { + if (draft.changes.size) throw new Error("请先应用原字段修改,再调整明细顺序"); + const next = draft.item + direction; + [items[next], items[draft.item]] = [items[draft.item], items[next]]; + draft.item = next; + invoiceDirty(draft, "model"); + await invoiceRefreshFields(draft); + renderDocumentSettings(); + }; + settingsButton("添加", async () => { + const item = {}; + items.push(item); + draft.addedItems.add(item); + draft.item = items.length - 1; + invoiceDirty(draft, "model"); + await invoiceRefreshFields(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(); + }, !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); + } +} + +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; +} + +function renderInvoiceFields(container, draft) { + const facts = (draft.invoice.facts || []).map((fact, index) => ({ fact, field: `facts.${index}`, item: -1 })); + for (const [item, value] of (draft.invoice.items || []).entries()) { + for (const [index, fact] of (value.fields || []).entries()) { + facts.push({ fact, field: `items.fields.${index}`, item }); + } + } + const options = facts.map(({ fact, item }, index) => [index, `${item >= 0 ? `明细${item + 1} · ` : ""}${fact.name || "未命名"}${fact.context ? ` · ${fact.context}` : ""} · ${index + 1}`]); + const owners = [...(!draft.entry ? [[-1, "基本"]] : []), ...(draft.invoice.items || []).flatMap((item, index) => !draft.entry || draft.addedItems.has(item) ? [[index, `明细${index + 1}`]] : [])]; + if (state.editing && owners.length) { + if (!owners.some(([index]) => index === draft.factOwner)) draft.factOwner = owners[0][0]; + settingsSelect(container, "归属", "InvoiceFactOwner", owners, draft.factOwner, value => { draft.factOwner = Number(value); }); + settingsButton("添加", () => { + const owner = draft.factOwner; + const target = owner < 0 ? draft.invoice.facts ||= [] : draft.invoice.items[owner].fields ||= []; + target.push({ name: "", namespace: "", value: "", context: "", unit: "" }); + draft.fact = owner < 0 ? target.length - 1 : (draft.invoice.facts || []).length + draft.invoice.items.slice(0, owner).reduce((sum, item) => sum + (item.fields || []).length, 0) + target.length - 1; + invoiceDirty(draft, "model"); + renderDocumentSettings(); + }, false, "InvoiceFactAdd", container); + } + if (!options.length) return; + draft.fact = Math.min(draft.fact || 0, facts.length - 1); + settingsSelect(container, "字段", "InvoiceFact", options, draft.fact, value => { + draft.fact = Number(value); + renderDocumentSettings(); + }); + const { fact, field, item } = facts[draft.fact]; + const patchKey = invoiceSourceKey(fact.source); + const change = draft.changes.get(patchKey); + const descriptor = draft.detail.fields?.find(value => value.field === field && value.item === item); + const fresh = !draft.entry || item >= 0 && draft.addedItems.has(draft.invoice.items[item]); + const writable = state.editing && (fresh || descriptor?.editable && !!fact.source?.nodePath); + for (const [key, label] of [["name", "名称"], ["namespace", "空间"], ["context", "语境"], ["unit", "单位"], ["decimals", "小数"], ["precision", "精度"]]) { + invoiceInput(container, label, `FactMeta_${key}`, fact[key], value => { + fact[key] = value; + invoiceDirty(draft, "model"); + }, !fresh || !state.editing).title = key === "namespace" ? "命名空间" : key === "context" ? "上下文标识" : label; + } + if (fact.source?.nodePath) invoiceInput(container, "路径", "FactPath", fact.source.nodePath, () => {}, true); + const update = (key, value) => { + if (fresh) { + fact[key] = value; + if (key === "nil" && value) { + fact.value = ""; + document.querySelector("#settingsInvoiceFactValue").value = ""; + } + if (key === "value") { + fact.nil = false; + document.querySelector("#settingsInvoiceFactNil").checked = false; + } + invoiceDirty(draft, "model"); + return; + } + const patch = draft.changes.get(patchKey) || { + source: structuredClone(fact.source), value: fact.value || "", nil: !!fact.nil, + expectedValue: fact.value || "", expectedNil: !!fact.nil + }; + patch[key] = value; + if ((key === "nil" || key === "delete") && value) { + patch.value = ""; + document.querySelector("#settingsInvoiceFactValue").value = ""; + } + if (key === "nil" && value) { + patch.delete = false; + document.querySelector("#settingsInvoiceFactDelete").checked = false; + } + if (key === "delete" && value) { + patch.nil = false; + document.querySelector("#settingsInvoiceFactNil").checked = false; + } + if (key === "value") { + patch.nil = false; + patch.delete = false; + document.querySelector("#settingsInvoiceFactNil").checked = false; + document.querySelector("#settingsInvoiceFactDelete").checked = false; + } + draft.changes.set(patchKey, patch); + invoiceDirty(draft, "fields"); + }; + invoiceInput(container, "数值", "FactValue", change?.value ?? fact.value, value => update("value", value), !writable, "textarea"); + invoiceInput(container, "空值", "FactNil", change?.nil ?? fact.nil, value => update("nil", value), !writable, "checkbox"); + if (!fresh) invoiceInput(container, "删除", "FactDelete", change?.delete || false, value => update("delete", value), !writable, "checkbox"); + else if (state.editing) settingsButton("删除", () => { + const index = Number(field.split(".").at(-1)); + (item < 0 ? draft.invoice.facts : draft.invoice.items[item].fields).splice(index, 1); + invoiceDirty(draft, "model"); + renderDocumentSettings(); + }, false, "InvoiceFactDelete", container); +} + +function renderInvoiceAppearance(container, draft) { + const capabilities = draft.detail.capabilities || {}; + const writable = state.editing && !!draft.entry; + const fields = (draft.detail.fields || []).filter(field => field.bindable); + const choices = fields.map(field => [JSON.stringify([field.field, field.item]), `${field.item >= 0 ? `明细${field.item + 1} · ` : ""}${field.label}`]); + if (!choices.some(([key]) => key === draft.field)) { + draft.field = choices[0]?.[0] || ""; + const binding = draft.bindings.find(binding => JSON.stringify([binding.field, binding.item]) === draft.field); + draft.binding = 0; + draft.page = binding?.page ?? state.pageIndex; + draft.object = binding?.objectId || ""; + } + settingsSelect(container, "字段", "InvoiceBindingField", choices, draft.field, value => { + draft.field = value; + draft.binding = 0; + const binding = draft.bindings.find(binding => JSON.stringify([binding.field, binding.item]) === value); + draft.page = binding?.page ?? state.pageIndex; + draft.object = binding?.objectId || ""; + renderDocumentSettings(); + }); + const bound = draft.bindings.filter(binding => JSON.stringify([binding.field, binding.item]) === draft.field); + if (bound.length) { + draft.binding = Math.min(draft.binding || 0, bound.length - 1); + settingsSelect(container, "引用", "InvoiceBinding", bound.map((binding, index) => [index, `第${binding.page + 1}页 · ${binding.objectId}`]), draft.binding, value => { + draft.binding = Number(value); + draft.page = bound[draft.binding].page; + draft.object = bound[draft.binding].objectId; + renderDocumentSettings(); + }); + } + settingsPages(container, "InvoicePage", draft.page, value => { + draft.page = Number(value); + draft.object = ""; + renderDocumentSettings(); + }); + const object = settingsSelect(container, "图元", "InvoiceObject", [[draft.object, draft.object || "选择图元"]], draft.object, value => { + draft.object = value; + renderDocumentSettings(); + }); + object.disabled = !writable || !capabilities.bind; + const actions = document.createElement("div"); + actions.className = "invoice-actions"; + container.append(actions); + settingsButton("定位", async () => { + if (!draft.object) return; + settings.page = draft.page; + await locateSettingsObjects([draft.object]); + }, !draft.object, "InvoiceLocate", actions); + if (state.editing) { + settingsButton("选取", async () => { + settings.page = draft.page; + await beginSettingsPick("invoice"); + }, !writable || !capabilities.bind, "InvoicePick", actions, capabilities.reasons?.bind); + settingsButton("绑定", () => { + if (!draft.object || !draft.field) throw new Error("请选择字段和图元"); + const [field, item] = JSON.parse(draft.field); + if (draft.bindings.some(binding => binding.page === draft.page && binding.objectId === draft.object && (binding.field !== field || binding.item !== item))) throw new Error("此图元已绑定其他字段"); + draft.bindings = draft.bindings.filter(binding => binding.field !== field || binding.item !== item); + draft.bindings.push({ field, item, page: draft.page, objectId: draft.object }); + invoiceDirty(draft, "bindings"); + renderDocumentSettings(); + }, !writable || !capabilities.bind || !draft.field, "InvoiceBind", actions, capabilities.reasons?.bind); + settingsButton("解绑", () => { + draft.bindings = draft.bindings.filter(binding => !(JSON.stringify([binding.field, binding.item]) === draft.field && binding.page === draft.page && binding.objectId === draft.object)); + invoiceDirty(draft, "bindings"); + renderDocumentSettings(); + }, !writable || !bound.length, "InvoiceUnbind", actions); + settingsButton("清空", () => { + draft.bindings = []; + invoiceDirty(draft, "bindings"); + renderDocumentSettings(); + }, !writable || !draft.bindings.length, "InvoiceUnbindAll", actions).title = "解除全部绑定"; + } + if (writable) { + const details = document.createElement("details"), summary = document.createElement("summary"); + summary.textContent = "生成"; + details.className = "invoice-advanced"; + details.append(summary); + container.append(details); + details.open = draft.layoutOpen || false; + details.addEventListener("toggle", () => { draft.layoutOpen = details.open; }); + const fonts = state.doc.fonts || []; + const libraryFonts = fontManager.editorFonts().filter(font => !font.disabled); + settingsSelect(details, "字体", "InvoiceFont", [["", "选择字体"], ...fonts.map(font => [font.id, font.fontName || font.familyName || font.id]), ...libraryFonts.map(font => [`file:${font.id}`, font.fullName || font.name])], draft.fontChoice || draft.layout.font, value => { + draft.fontChoice = value; + draft.layout.font = value.startsWith("file:") ? "" : value; + renderDocumentSettings(); + }); + settingsButton("添加", () => openFontFile(el.fontInput), false, "InvoiceAddFont", details).title = "添加字体"; + settingsButton("嵌入", async () => { + const font = fonts.find(value => value.id === draft.layout.font); + if (!font) throw new Error("请选择字体"); + const before = invoiceScope(); + const refresh = () => { + const next = invoiceScope(); + if (next.documentIndex !== before.documentIndex || next.version !== before.version) return; + for (const value of invoicePanel.drafts.values()) { + if (value.scope.documentIndex === before.documentIndex && value.scope.version === before.version && value.scope.revision === before.revision) { + value.scope.revision = next.revision; + } + } + if (settingsElements.Panel.open && settingsElements.Kind.value === "invoices") renderDocumentSettings(); + }; + await openFontBinding(font); + if (fontBindingElements.Panel.open) fontBindingElements.Panel.addEventListener("close", refresh, { once: true }); + else refresh(); + }, !draft.layout.font || !!fonts.find(font => font.id === draft.layout.font)?.embedded, "InvoiceEmbedFont", details); + for (const [key, label] of [["fontSize", "字号"], ["pageWidth", "页宽"], ["pageHeight", "页高"], ["margin", "边距"]]) { + const input = invoiceInput(details, label, `Layout_${key}`, draft.layout[key], value => { draft.layout[key] = Number(value); }, false, "number", "mm"); + input.min = key === "margin" ? "0" : "0.01"; + } + settingsButton("生成", async () => { + if (draft.dirty.size) throw new Error("请先应用草稿"); + if (!draft.layout.font && !draft.fontChoice?.startsWith("file:")) throw new Error("请选择字体"); + let fontData; + if (draft.fontChoice?.startsWith("file:")) { + const font = libraryFonts.find(value => `file:${value.id}` === draft.fontChoice); + if (!font) throw new Error("所选字体不可用"); + fontData = invoiceBase64(await readTextFont(font)); + } + await commitInvoice(draft, { operation: "generate", layout: draft.layout, fontData }); + }, !capabilities.generate, "InvoiceGenerate", details, capabilities.reasons?.generate || (draft.bindings.length ? "请先解除绑定" : "")); + settingsButton("同步", async () => { + if (draft.dirty.size) throw new Error("请先应用草稿"); + await commitInvoice(draft, { operation: "sync" }); + }, !capabilities.sync, "InvoiceSync", actions, capabilities.reasons?.sync); + } + loadInvoiceObjects(draft).catch(error => { if (invoiceCurrent() === draft) settingsElements.Status.textContent = error.message; }); +} + +async function loadInvoiceObjects(draft) { + const page = draft.page, sequence = settings.sequence; + const field = document.querySelector("#settingsInvoiceObject"); + if (!field || !state.doc.pages[page]) return; + const data = await loadPageData(page); + if (invoiceCurrent() !== draft || draft.page !== page || sequence !== settings.sequence || !field.isConnected) return; + const objects = (data.objects || []).filter(item => !item.scoped && item.type === "TextObject"); + field.replaceChildren(new Option("选择图元", ""), ...objects.map(item => new Option(`${item.id} · ${(item.text || "文字").slice(0, 60)}`, item.id))); + field.value = draft.object; +} + +function invoiceOtherDraftWarning(draft) { + return [...invoicePanel.drafts.values()].some(value => value !== draft && value.entry && value.dirty.size && invoiceSourceKey(value.entry.source) === invoiceSourceKey(draft.entry.source)) ? "\n该来源其他发票的未应用修改也将放弃" : ""; +} + +function resetInvoiceDraft(draft) { + const section = { xml: "xml", fields: draft.entry ? "fields" : "model", appearance: "bindings" }[invoicePanel.tab] || "model"; + if (!draft.dirty.has(section) || !window.confirm("放弃当前编辑项的未应用修改?")) return; + if (section === "xml") draft.xml = draft.detail.xml || ""; + else if (section === "fields") draft.changes.clear(); + else if (section === "bindings") draft.bindings = structuredClone(draft.detail.bindings || []); + else { + draft.invoice = structuredClone(draft.detail.invoice); + draft.options = { name: "", encoding: { schemaRef: "", context: {}, units: [] } }; + draft.fragments = {}; + } + draft.dirty.delete(section); + draft.status = ""; + renderDocumentSettings(); +} + +function renderInvoiceXML(container, draft) { + invoiceInput(container, "原文", "XML", draft.xml, value => { + draft.xml = value; + invoiceDirty(draft, "xml"); + }, !state.editing, "textarea").rows = 14; + const actions = document.createElement("div"); + actions.className = "invoice-actions"; + container.append(actions); + if (state.editing) settingsButton("导入", () => readInvoiceFile(text => { + draft.xml = text; + if (!draft.entry) draft.importing = true; + invoiceDirty(draft, "xml"); + renderDocumentSettings(); + }), false, "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")); + }, !draft.entry, "InvoiceExportXML", actions).title = "导出来源原文"; +} + +function readInvoiceFile(accept) { + const sequence = settings.sequence, openSeq = state.openSeq; + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".xml,.xbrl,application/xml,text/xml"; + input.addEventListener("change", async () => { + const file = input.files[0]; + if (!file) return; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(await file.arrayBuffer()); + if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return; + await accept(text); + } catch (error) { + settingsElements.Status.textContent = error.message; + } + }); + input.click(); +} + +async function validateInvoiceDraft(draft) { + const request = draft.dirty.size ? buildInvoiceRequest(draft) : { invoice: draft.invoice }; + const result = await callWASM("ofdgoValidateInvoice", JSON.stringify({ ...draft.scope, source: draft.entry?.source, index: draft.entry?.index, digest: draft.detail.digest, ...request })); + if (invoiceCurrent() !== draft) return; + draft.status = invoiceIssues([...(result.issues || []), ...(result.appearance || [])]); + settingsElements.Status.textContent = draft.status; +} + +async function applyInvoiceDraft(draft) { + if (!state.editing || !draft.dirty.size) return; + const request = buildInvoiceRequest(draft); + if (request.operation === "replace" && !window.confirm("替换此来源中的全部发票原文?" + invoiceOtherDraftWarning(draft))) return; + await commitInvoice(draft, request); +} + +function buildInvoiceRequest(draft) { + if (draft.dirty.has("xml") && draft.dirty.size > 1) throw new Error("原文替换与其他修改冲突,草稿已保留,请先还原其中一项"); + const section = draft.dirty.has("xml") ? "xml" : "model"; + const request = { synchronize: draft.synchronize }; + if (section === "xml") { + Object.assign(request, { operation: draft.entry ? "replace" : "import", xml: draft.xml, options: { name: draft.options.name } }); + } else { + const options = structuredClone(draft.options); + for (const [key, text] of Object.entries(draft.fragments)) { + options.encoding[key] = invoiceBase64(new TextEncoder().encode(text)); + } + if (!draft.entry) Object.assign(request, { operation: "create", invoice: draft.invoice, options }); + else { + const operations = []; + if (draft.dirty.has("model")) operations.push({ operation: "set", index: draft.entry.index, invoice: draft.invoice }); + if (draft.dirty.has("fields")) operations.push({ operation: "patch", index: draft.entry.index, changes: [...draft.changes.values()] }); + if (draft.dirty.has("bindings")) operations.push({ operation: "bindings", index: draft.entry.index, bindings: draft.bindings }); + Object.assign(request, operations.length === 1 ? operations[0] : { operation: "batch", operations }); + } + } + return request; +} + +function invoiceBase64(bytes) { + let value = ""; + for (let offset = 0; offset < bytes.length; offset += 8192) value += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + return btoa(value); +} + +async function commitInvoice(draft, request) { + if (!state.editing || settings.busy || document.body.hasAttribute("aria-busy")) return; + settings.busy = true; + settingsElements.Form.inert = true; + settingsElements.Status.textContent = "正在保存修改"; + try { + invoicePanel.result = null; + if (!await changeDocument("ofdgoChangeInvoice", null, JSON.stringify({ ...draft.scope, source: draft.entry?.source, index: draft.entry?.index || 0, digest: draft.detail.digest, ...request }))) { + if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent; + return; + } + const result = invoicePanel.result; + invoicePanel.drafts.delete(invoicePanel.key); + const currentScope = invoiceScope(); + for (const [key, value] of invoicePanel.drafts) { + if (value.scope.documentIndex !== currentScope.documentIndex || value.scope.version !== currentScope.version) continue; + value.scope.revision = currentScope.revision; + if (value.entry && draft.entry && invoiceSourceKey(value.entry.source) === invoiceSourceKey(draft.entry.source)) { + if (!value.dirty.size) invoicePanel.drafts.delete(key); + else value.status = "同源数据已修改,应用时将检查来源冲突"; + } + } + if (request.operation === "delete" || request.operation === "replace") { + for (const [key, value] of invoicePanel.drafts) { + if (value.entry && invoiceSourceKey(value.entry.source) === invoiceSourceKey(draft.entry.source)) { + invoicePanel.drafts.delete(key); + } + } + } + if (result?.source?.kind) invoicePanel.key = JSON.stringify([invoiceSourceKey(result.source), draft.entry?.index || 0]); + await loadDocumentSettings(); + const warning = invoiceIssues(result?.issues); + if (warning) { + if (invoiceCurrent()) invoiceCurrent().status = warning; + settingsElements.Status.textContent = warning; + } + } finally { + settings.busy = false; + settingsElements.Form.inert = false; + } +} + function settingsPages(container, id, value, change) { return settingsSelect(container, "页码", id, state.doc.pages.map((page, index) => [index, `第${index + 1}页`]), value, change); } @@ -6098,13 +6864,17 @@ function suspendSettings(kind) { async function pickSettingsObject(event, picking) { if (picking.settings === "locate") { finishResourcePick(); return; } const available = canvasEditor.itemsAt(event).filter(item => !item.scoped && !item.draft && item.type !== "Annotation" + && (picking.settings !== "invoice" || item.type === "TextObject") && (picking.settings !== "style" || ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type)) && (picking.settings !== "layer" || item.index === settings.page && !settings.data.page.layers[settings.selected]?.ObjectIDs?.includes(item.id))); if (!available.length) return; const choose = async item => { if (resourceManager.pick !== picking || picking.sequence !== state.openSeq) return; finishResourcePick(); - if (picking.settings === "action") { + if (picking.settings === "invoice") { + const draft = invoiceCurrent(); + if (draft) { draft.page = item.index; draft.object = item.id; renderDocumentSettings(); } + } else if (picking.settings === "action") { settings.page = item.index; settings.object = item.id; settings.selected = -1; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index d72c9c0..365505d 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -114,6 +114,33 @@ type editorOutlineInfo struct { OutlinePath []int `json:"outlinePath"` } +// invoiceRequest 发票操作参数 +type invoiceRequest struct { + Operation string `json:"operation"` + Operations []invoiceRequest `json:"operations"` + Source ofdgo.InvoiceSource `json:"source"` + Index int `json:"index"` + Digest string `json:"digest"` + Invoice ofdgo.Invoice `json:"invoice"` + Changes []ofdgo.InvoiceFieldChange `json:"changes"` + Bindings []ofdgo.InvoiceBinding `json:"bindings"` + XML string `json:"xml"` + Options ofdgo.InvoiceCreateOptions `json:"options"` + Layout ofdgo.InvoiceLayout `json:"layout"` + FontData []byte `json:"fontData"` + Synchronize bool `json:"synchronize"` + DocumentIndex *int `json:"documentIndex"` + Version *string `json:"version"` + Revision *uint64 `json:"revision"` + indexProvided bool +} + +// invoiceEditorInfo 发票操作结果与编辑状态 +type invoiceEditorInfo struct { + editorInfo + InvoiceResult ofdgo.InvoiceUpdateResult `json:"invoiceResult"` +} + // annotationOptions 保存注解面板的输入,资源字节单独传输 type annotationOptions struct { Kind, ID, Text, Old, Creator, Pages, URI, Color string @@ -199,6 +226,11 @@ func RunWASM() { registerAsyncCallback("ofdgoExportVersion", exportVersion) registerCallback("ofdgoAnnotationInfos", annotationInfos) registerCallback("ofdgoDocumentNavigation", documentNavigation) + registerCallback("ofdgoInvoices", documentInvoices) + registerCallback("ofdgoInvoice", documentInvoice) + registerCallback("ofdgoExportInvoice", exportDocumentInvoice) + registerCallback("ofdgoValidateInvoice", validateDocumentInvoice) + registerCallback("ofdgoChangeInvoice", changeDocumentInvoice) registerCallback("ofdgoChangeBookmark", changeBookmark) registerCallback("ofdgoActionList", actionList) registerCallback("ofdgoChangeActions", changeActions) @@ -854,6 +886,516 @@ func resourceTargets(args []js.Value) (any, error) { return currentEditor.ResourceTargets(context.Background(), args[1].String(), args[2].String(), args[0].Int()) } +// documentInvoices 获取发票列表与格式能力 +// 入参: args 空参数 +// 返回: any 发票提取结果与格式列表, error 读取错误 +func documentInvoices(args []js.Value) (any, error) { + if currentSession == nil || len(args) != 0 { + return nil, fmt.Errorf("ofd document is not opened") + } + r, close, err := currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + report, err := r.Invoices() + if err != nil { + return nil, err + } + return map[string]any{"report": report, "formats": ofdgo.InvoiceFormats()}, nil +} + +// currentInvoiceReader 获取当前发票读取器 +// 返回: *ofdgo.Reader 当前读取器, func() 快照释放函数, error 快照错误 +func currentInvoiceReader() (*ofdgo.Reader, func(), error) { + if currentEditor != nil { + r, err := currentEditor.Reader() + if err != nil { + return nil, nil, err + } + return r, func() { _ = r.Close() }, nil + } + if currentSession == nil { + return nil, nil, fmt.Errorf("ofd document is not opened") + } + return currentSession.Reader, func() {}, nil +} + +// decodeInvoiceRequest 解析发票参数并校验文档状态 +// 入参: args 单个JSON操作对象 +// 返回: invoiceRequest 操作参数, error 参数或过期状态错误 +func decodeInvoiceRequest(args []js.Value) (invoiceRequest, error) { + var request invoiceRequest + if currentSession == nil || len(args) != 1 || args[0].Type() != js.TypeString { + return request, fmt.Errorf("invalid invoice arguments") + } + data := args[0].String() + if len(data) > 64<<20 { + return request, fmt.Errorf("invoice request exceeds size limit") + } + if err := json.Unmarshal([]byte(data), &request); err != nil { + return request, err + } + var presence struct { + Operations []struct { + Index *int `json:"index"` + } `json:"operations"` + } + if err := json.Unmarshal([]byte(data), &presence); err != nil { + return request, err + } + for i := range presence.Operations { + request.Operations[i].indexProvided = presence.Operations[i].Index != nil + } + index := currentSession.Reader.DocumentIndex() + if currentEditor != nil { + index = currentEditor.DocumentIndex() + } + if request.DocumentIndex != nil && *request.DocumentIndex != index { + return request, fmt.Errorf("invoice document has changed") + } + if request.Version != nil { + version, err := currentSession.Reader.DocumentVersionID() + if currentEditor != nil { + version, err = currentEditor.DocumentVersionID() + } + if err != nil { + return request, err + } + if *request.Version != version { + return request, fmt.Errorf("invoice document version has changed") + } + } + if request.Revision != nil && (currentEditor == nil || *request.Revision != currentEditor.Revision()) { + return request, fmt.Errorf("invoice editing revision has changed") + } + return request, nil +} + +// documentInvoice 获取发票详情与票面关联 +// 入参: args 来源及来源内发票索引,新建时提供发票模型 +// 返回: any 发票详情, error 来源、索引或读取错误 +func documentInvoice(args []js.Value) (any, error) { + request, err := decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + ctx := context.Background() + if request.Source.Kind == "" { + return map[string]any{"invoice": request.Invoice, "fields": ofdgo.InvoiceFields(request.Invoice)}, nil + } + r, close, err := currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + data, err := r.ExportInvoice(ctx, request.Source) + if err != nil { + return nil, err + } + report, err := ofdgo.DecodeInvoices(ctx, data) + if err != nil { + return nil, err + } + if request.Index < 0 || request.Index >= len(report.Invoices) { + return nil, fmt.Errorf("invoice index out of range") + } + value := report.Invoices[request.Index] + capabilities, err := r.InvoiceCapabilities(ctx, request.Source, request.Index) + if err != nil { + return nil, err + } + bindings, bindingErr := r.InvoiceBindings(ctx, request.Source, request.Index) + if bindingErr != nil { + report.Issues = append(report.Issues, ofdgo.InvoiceIssue{Code: "binding-error", Message: bindingErr.Error(), Source: request.Source}) + } + return map[string]any{"invoice": value, "fields": ofdgo.InvoiceFields(value), "capabilities": capabilities, "bindings": bindings, "digest": ofdgo.InvoiceDigest(data), "xml": string(data), "issues": report.Issues}, nil +} + +// exportDocumentInvoice 导出发票原文 +// 入参: args 发票来源 +// 返回: any 原始字节, error 来源或读取错误 +func exportDocumentInvoice(args []js.Value) (any, error) { + request, err := decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + r, close, err := currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + data, err := r.ExportInvoice(context.Background(), request.Source) + if err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil +} + +// validateDocumentInvoice 校验发票草稿 +// 入参: args 发票模型或来源、草稿操作与原始摘要 +// 返回: any 字段与票面诊断, error 校验或读取错误 +func validateDocumentInvoice(args []js.Value) (any, error) { + request, err := decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + ctx := context.Background() + if request.Operation == "" { + switch { + case len(request.Operations) != 0: + request.Operation = "batch" + case len(request.Changes) != 0: + request.Operation = "patch" + case request.XML != "": + request.Operation = "replace" + if request.Source.Kind == "" { + request.Operation = "import" + } + } + } + if request.Operation != "" { + return validateInvoiceDraft(ctx, request) + } + issues, err := ofdgo.ValidateInvoice(ctx, request.Invoice) + if err != nil { + return nil, err + } + var appearance []ofdgo.InvoiceIssue + if request.Source.Kind != "" { + r, close, err := currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + appearance, err = r.CheckInvoiceAppearance(ctx, request.Source, request.Index) + if err != nil { + appearance = append(appearance, ofdgo.InvoiceIssue{Code: "appearance-error", Message: err.Error(), Source: request.Source}) + } + } + return map[string]any{"issues": issues, "appearance": appearance}, nil +} + +// validateInvoiceDraft 在独立快照中校验发票修改 +// 入参: ctx 取消上下文, request 已核对当前文档状态的操作 +// 返回: any 草稿发票及诊断, error 演练或读取错误 +func validateInvoiceDraft(ctx context.Context, request invoiceRequest) (any, error) { + r, close, err := currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + editor, err := r.EditorContext(ctx) + if err != nil { + return nil, err + } + request.Operations = append([]invoiceRequest(nil), request.Operations...) + for i := range request.Operations { + operation := &request.Operations[i] + if operation.Revision != nil && (currentEditor == nil || *operation.Revision != currentEditor.Revision()) { + return nil, fmt.Errorf("invoice editing revision has changed") + } + operation.Revision = nil + } + var result ofdgo.InvoiceUpdateResult + if err := editor.Transaction(func(next *ofdgo.Editor) error { + var err error + result, err = applyInvoiceRequest(ctx, next, request) + return err + }); err != nil { + return nil, err + } + issues := append([]ofdgo.InvoiceIssue(nil), result.Issues...) + var appearance []ofdgo.InvoiceIssue + if request.Operation == "delete" { + return map[string]any{"issues": issues, "appearance": appearance}, nil + } + source := result.Source + 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() + for index, invoice := range report.Invoices { + validation, err := ofdgo.ValidateInvoice(ctx, invoice) + if err != nil { + return nil, err + } + for i := range validation { + validation[i].Source = source + } + issues = append(issues, validation...) + checks, err := view.CheckInvoiceAppearance(ctx, source, index) + if err != nil { + appearance = append(appearance, ofdgo.InvoiceIssue{Code: "appearance-error", Message: err.Error(), Source: source}) + } else { + appearance = append(appearance, checks...) + } + } + return map[string]any{"issues": issues, "appearance": appearance, "invoices": report.Invoices}, nil +} + +// changeDocumentInvoice 提交发票修改 +// 入参: args 操作及业务参数 +// 返回: any 编辑状态与签名诊断, error 校验或提交错误 +func changeDocumentInvoice(args []js.Value) (any, error) { + request, err := decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + ctx := context.Background() + var result ofdgo.InvoiceUpdateResult + info, err := changeAtomicObjects(func() error { + return currentEditor.Transaction(func(editor *ofdgo.Editor) error { + var err error + result, err = applyInvoiceRequest(ctx, editor, request) + return err + }) + }) + if err != nil { + return nil, err + } + return invoiceEditorInfo{editorInfo: info.(editorInfo), InvoiceResult: result}, nil +} + +// applyInvoiceRequest 校验来源摘要并执行发票操作 +// 入参: ctx 取消上下文, editor 事务编辑器, request 操作参数 +// 返回: ofdgo.InvoiceUpdateResult 提交结果, error 校验或写入错误 +func applyInvoiceRequest(ctx context.Context, editor *ofdgo.Editor, request invoiceRequest) (ofdgo.InvoiceUpdateResult, error) { + var result ofdgo.InvoiceUpdateResult + if request.Operation == "batch" { + return applyInvoiceBatch(ctx, editor, request) + } + if len(request.Operations) != 0 { + return result, fmt.Errorf("invoice operations require batch action") + } + err := func() error { + if request.Operation != "create" && request.Operation != "import" { + data, err := editor.ExportInvoice(ctx, request.Source) + if err != nil { + return err + } + if request.Digest == "" || ofdgo.InvoiceDigest(data) != request.Digest { + return fmt.Errorf("invoice source has changed") + } + } + switch request.Operation { + case "create", "import": + var source ofdgo.InvoiceSource + var err error + if request.Operation == "create" { + source, err = editor.CreateInvoice(ctx, request.Invoice, request.Options) + } else { + source, err = editor.ImportInvoice(ctx, []byte(request.XML), request.Options) + } + result.Source = source + return err + case "set": + var err error + result, err = editor.SetInvoice(ctx, request.Source, request.Digest, request.Index, request.Invoice, request.Synchronize) + return err + case "patch": + for i := range request.Changes { + request.Changes[i].Source.Kind = request.Source.Kind + request.Changes[i].Source.ID = request.Source.ID + request.Changes[i].Source.FileLoc = request.Source.FileLoc + if request.Source.NodePath != "" { + request.Changes[i].Source.NodePath = request.Source.NodePath + request.Changes[i].Source.NodePath + } + } + var err error + result, err = editor.EditInvoice(ctx, request.Source, request.Digest, request.Changes, request.Synchronize) + return err + case "replace": + var err error + result, err = editor.UpdateInvoice(ctx, request.Source, request.Digest, []byte(request.XML), request.Synchronize) + return err + case "delete": + return editor.DeleteInvoice(ctx, request.Source, request.Digest) + case "bindings": + return editor.SetInvoiceBindings(ctx, request.Source, request.Index, request.Bindings) + case "generate": + if len(request.FontData) != 0 { + file := ofdgo.FontFile{Data: request.FontData} + if request.Layout.Font == "" { + id, err := editor.AddFont(file, 0) + if err != nil { + return err + } + request.Layout.Font = id + } else if err := editor.EmbedFont(ctx, request.Layout.Font, file, 0); err != nil { + return err + } + } + return editor.GenerateInvoicePages(ctx, request.Source, request.Index, request.Layout) + case "sync": + data, err := editor.ExportInvoice(ctx, request.Source) + if err != nil { + return err + } + result, err = editor.UpdateInvoice(ctx, request.Source, request.Digest, data, true) + return err + default: + return fmt.Errorf("unsupported invoice action") + } + }() + if err != nil { + return ofdgo.InvoiceUpdateResult{}, err + } + return result, nil +} + +// applyInvoiceBatch 合并发票修改并同步票面 +// 入参: ctx 取消上下文, editor 事务编辑器, request 批次及原始来源 +// 返回: ofdgo.InvoiceUpdateResult 提交结果, error 冲突或写入错误 +func applyInvoiceBatch(ctx context.Context, editor *ofdgo.Editor, request invoiceRequest) (ofdgo.InvoiceUpdateResult, error) { + var result ofdgo.InvoiceUpdateResult + data, err := editor.ExportInvoice(ctx, request.Source) + if err != nil { + return result, err + } + if request.Digest == "" || ofdgo.InvoiceDigest(data) != request.Digest { + return result, fmt.Errorf("invoice source has changed") + } + if len(request.Operations) == 0 || len(request.Operations) > 256 { + return result, fmt.Errorf("invalid invoice batch size") + } + report, err := ofdgo.DecodeInvoices(ctx, data) + if err != nil { + return result, err + } + if request.Index < 0 || request.Index >= len(report.Invoices) { + return result, fmt.Errorf("invoice index out of range") + } + paths := make(map[string]bool) + for _, fact := range report.Invoices[request.Index].Facts { + paths[fact.Source.NodePath] = true + } + for _, item := range report.Invoices[request.Index].Items { + for _, fact := range item.Fields { + paths[fact.Source.NodePath] = true + } + } + request.Operations = append([]invoiceRequest(nil), request.Operations...) + for i := range request.Operations { + operation := &request.Operations[i] + if (operation.indexProvided || operation.Index != 0) && operation.Index != request.Index { + return result, fmt.Errorf("invoice batch cannot span candidates") + } + operation.Index = request.Index + } + document := editor.DocumentIndex() + version, err := editor.DocumentVersionID() + if err != nil { + return result, err + } + revision := editor.Revision() + models, bindings := make(map[int]bool), make(map[int]bool) + patched := make(map[string]bool) + hasSet, hasPatch := false, false + for _, operation := range request.Operations { + if err := ctx.Err(); err != nil { + return result, err + } + if len(operation.Operations) != 0 || operation.Operation == "batch" || operation.Synchronize { + return result, fmt.Errorf("nested invoice batch or synchronization is not supported") + } + if operation.Source != (ofdgo.InvoiceSource{}) && operation.Source != request.Source { + return result, fmt.Errorf("invoice batch cannot span sources") + } + if operation.Digest != "" && operation.Digest != request.Digest { + return result, fmt.Errorf("invoice batch requires one original digest") + } + if operation.DocumentIndex != nil && *operation.DocumentIndex != document || operation.Version != nil && *operation.Version != version || operation.Revision != nil && *operation.Revision != revision { + return result, fmt.Errorf("invoice batch scope has changed") + } + switch operation.Operation { + case "set": + if models[operation.Index] { + return result, fmt.Errorf("duplicate invoice model update") + } + models[operation.Index], hasSet = true, true + case "patch": + if len(operation.Changes) == 0 { + return result, fmt.Errorf("empty invoice field update") + } + hasPatch = true + for _, change := range operation.Changes { + if change.Source.Kind != "" && change.Source.Kind != request.Source.Kind || change.Source.ID != "" && change.Source.ID != request.Source.ID || change.Source.FileLoc != "" && change.Source.FileLoc != request.Source.FileLoc { + return result, fmt.Errorf("invoice patch cannot span sources") + } + if change.Source.NodePath == "" || patched[change.Source.NodePath] { + return result, fmt.Errorf("duplicate or missing invoice field path") + } + if !paths[change.Source.NodePath] { + return result, fmt.Errorf("invoice patch cannot span candidates") + } + if change.Revision != "" && change.Revision != request.Digest { + return result, fmt.Errorf("invoice field revision has changed") + } + patched[change.Source.NodePath] = true + } + case "bindings": + if bindings[operation.Index] { + return result, fmt.Errorf("duplicate invoice binding update") + } + bindings[operation.Index] = true + default: + return result, fmt.Errorf("unsupported invoice batch action") + } + } + if hasSet && hasPatch { + return result, fmt.Errorf("invoice batch cannot mix model and raw field updates") + } + var changes []ofdgo.InvoiceFieldChange + for _, operation := range request.Operations { + if err := ctx.Err(); err != nil { + return result, err + } + switch operation.Operation { + case "set": + data, err = ofdgo.UpdateInvoiceXML(ctx, data, operation.Index, operation.Invoice) + case "patch": + changes = append(changes, operation.Changes...) + } + if err != nil { + return result, err + } + } + if len(changes) != 0 { + for i := range changes { + changes[i].Revision = request.Digest + changes[i].Source.Kind = "" + changes[i].Source.ID = "" + changes[i].Source.FileLoc = "" + } + data, err = ofdgo.PatchInvoice(ctx, data, changes) + if err != nil { + return result, err + } + } + for _, operation := range request.Operations { + if operation.Operation == "bindings" { + if err := editor.SetInvoiceBindings(ctx, request.Source, operation.Index, operation.Bindings); err != nil { + return result, err + } + } + } + return editor.UpdateInvoice(ctx, request.Source, request.Digest, data, request.Synchronize) +} + // resourceLocations 定位当前文档与版本中的资源引用实例 // 入参: args 资源类型、标识和引用索引 // 返回: any 页面图元范围, error 引用或度量错误 diff --git a/ofdgo_editor_invoice.go b/ofdgo_editor_invoice.go index 0b59e18..12f1e40 100644 --- a/ofdgo_editor_invoice.go +++ b/ofdgo_editor_invoice.go @@ -45,6 +45,19 @@ type InvoiceUpdateResult struct { Issues []InvoiceIssue `json:"issues,omitempty"` } +// InvoiceCapabilities 发票操作能力 +// Create表示同格式创建能力,Delete表示整源删除,Reasons按操作小写名称索引 +type InvoiceCapabilities struct { + Create bool `json:"create"` + Edit bool `json:"edit"` + Items bool `json:"items"` + Bind bool `json:"bind"` + Generate bool `json:"generate"` + Sync bool `json:"sync"` + Delete bool `json:"delete"` + Reasons map[string]string `json:"reasons"` +} + // invoiceBindingRecord 保存当前文档内的来源选择器,资源路径变化时仍以入口和摘要核对载荷 type invoiceBindingRecord struct { Kind string `json:"kind"` @@ -69,6 +82,138 @@ type invoiceBindingEntry struct { bindings []InvoiceBinding } +// InvoiceCapabilities 获取当前文档版本的发票操作能力 +// 入参: ctx 取消上下文, source 精确来源, index 来源内从0开始的发票索引 +// 返回: InvoiceCapabilities 操作能力, error 来源、索引或读取错误 +func (r *Reader) InvoiceCapabilities(ctx context.Context, source InvoiceSource, index int) (InvoiceCapabilities, error) { + ctx = invoiceWriteContext(ctx) + result := InvoiceCapabilities{Reasons: make(map[string]string)} + data, err := r.ExportInvoice(ctx, source) + if err != nil { + return result, err + } + report, err := parseInvoiceXML(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") + } + format, known := invoiceWriteFormat(report.Invoices[index]) + result.Create = known && format.Create + result.Edit = known && format.Edit + result.Items = known && format.Items + result.Bind = known + result.Generate = known + result.Delete = true + if !result.Create { + result.Reasons["create"] = "当前格式不支持创建" + } + if !result.Edit { + result.Reasons["edit"] = "当前格式不支持编辑" + } + if !result.Items { + result.Reasons["items"] = "当前格式不支持明细增删" + } + if !known { + result.Reasons["bind"] = "当前格式未适配" + result.Reasons["generate"] = "当前格式未适配" + } + entries, err := r.invoiceBindings(ctx, source, InvoiceDigest(data), true) + if err != nil { + if ctx.Err() != nil { + return result, ctx.Err() + } + result.Sync = false + result.Generate = false + result.Bind = false + for _, operation := range []string{"sync", "generate", "bind"} { + result.Reasons[operation] = "票面关联损坏,请先修复关联" + } + return result, nil + } + for _, entry := range entries { + if entry.record.Invoice != index { + continue + } + result.Generate = false + result.Reasons["generate"] = "请先解除已有票面关联" + result.Sync = result.Edit && len(entry.bindings) > 0 + if result.Sync { + editor, err := r.EditorContext(ctx) + if err != nil { + return result, err + } + for _, binding := range entry.bindings { + if err := ctx.Err(); err != nil { + return result, err + } + _, fieldErr := invoiceBindingValue(report.Invoices[index], binding) + object, objectErr := editor.Object(binding.Page, binding.ObjectID) + if fieldErr != nil || objectErr != nil || object.Type != "TextObject" { + result.Sync = false + result.Reasons["sync"] = "票面关联字段或图元已失效" + } + } + if entry.record.Layout != nil { + ids := make([]string, len(entry.record.Pages)) + for i, page := range entry.record.Pages { + ids[i] = page.ID + } + current, err := editor.invoiceOwnedPages(ctx, ids) + if err != nil || len(ids) == 0 || !slices.Equal(current, entry.record.Pages) || editor.source.document.Signatures != "" { + result.Sync = false + result.Reasons["sync"] = "生成票面已修改或签名,请重新绑定" + } + } + } + } + if !result.Sync && result.Reasons["sync"] == "" { + result.Reasons["sync"] = "当前发票没有可同步的票面关联" + } + return result, ctx.Err() +} + +// GenerateInvoicePages 为已有发票生成信息页并保存字段关联,失败时整体回滚 +// 入参: ctx 取消上下文, source 精确来源, index 来源内发票索引, layout 排版选项 +// 返回: error 来源、关联、排版或提交错误 +func (e *Editor) GenerateInvoicePages(ctx context.Context, source InvoiceSource, index int, layout InvoiceLayout) error { + ctx = invoiceWriteContext(ctx) + return e.Transaction(func(next *Editor) error { + r, err := next.ReaderContext(ctx) + if err != nil { + return err + } + defer r.Close() + capabilities, err := r.InvoiceCapabilities(ctx, source, index) + if err != nil { + return err + } + if !capabilities.Generate { + return fmt.Errorf("invoice appearance cannot be generated: %s", capabilities.Reasons["generate"]) + } + data, err := r.ExportInvoice(ctx, source) + if err != nil { + return err + } + report, err := parseInvoiceXML(ctx, data, source) + if err != nil { + return err + } + bindings, err := next.AppendInvoicePages(ctx, report.Invoices[index], layout) + if err != nil { + return err + } + if err := next.SetInvoiceBindings(ctx, source, index, bindings); err != nil { + return err + } + if err := next.markInvoicePages(ctx, source, index, layout); err != nil { + return err + } + return ctx.Err() + }) +} + // Invoices 获取编辑状态的发票提取结果,不修改文档 // 返回: InvoiceReport 发票及诊断, error 快照或解析错误 func (e *Editor) Invoices() (InvoiceReport, error) { @@ -159,7 +304,7 @@ func (e *Editor) ImportInvoice(ctx context.Context, data []byte, options Invoice if err := next.SetInvoiceBindings(ctx, source, 0, bindings); err != nil { return err } - if err := next.markInvoicePages(ctx, source, *options.Layout); err != nil { + if err := next.markInvoicePages(ctx, source, 0, *options.Layout); err != nil { return err } } @@ -894,9 +1039,9 @@ func (e *Editor) invoiceOwnedPages(ctx context.Context, ids []string) ([]invoice } // markInvoicePages 保存新建票面的排版选项和页面摘要,供明细增删后安全重排 -// 入参: ctx 取消上下文, source 新增来源, layout 排版选项 +// 入参: ctx 取消上下文, source 来源, index 来源内发票索引, layout 排版选项 // 返回: error 关联或提交错误 -func (e *Editor) markInvoicePages(ctx context.Context, source InvoiceSource, layout InvoiceLayout) error { +func (e *Editor) markInvoicePages(ctx context.Context, source InvoiceSource, index int, layout InvoiceLayout) error { r, err := e.ReaderContext(ctx) if err != nil { return err @@ -911,10 +1056,11 @@ func (e *Editor) markInvoicePages(ctx context.Context, source InvoiceSource, lay if err != nil { return err } - if len(entries) != 1 { - return fmt.Errorf("generated invoice requires one binding record") + position := slices.IndexFunc(entries, func(entry invoiceBindingEntry) bool { return entry.record.Invoice == index }) + if position < 0 { + return fmt.Errorf("generated invoice binding record not found") } - entry := entries[0] + entry := entries[position] var ids []string for _, binding := range entry.bindings { page, err := e.Page(binding.Page) diff --git a/ofdgo_invoice_write.go b/ofdgo_invoice_write.go index 946d53b..1b1aa5e 100644 --- a/ofdgo_invoice_write.go +++ b/ofdgo_invoice_write.go @@ -41,6 +41,20 @@ type InvoiceFormat struct { Items bool `json:"items"` } +// InvoiceFieldDescriptor 发票字段描述 +// Item为从0开始的明细索引,非明细字段为-1 +// Type为text、decimal、date或datetime,Editable表示字段支持编辑,提交时仍需校验 +type InvoiceFieldDescriptor struct { + Field string `json:"field"` + Label string `json:"label"` + Group string `json:"group"` + Type string `json:"type"` + Item int `json:"item"` + Value string `json:"value"` + Editable bool `json:"editable"` + Bindable bool `json:"bindable"` +} + // InvoiceFieldChange 修改已有叶节点,Revision为源XML的SHA256十六进制摘要 // 未提供Revision时必须提供ExpectedValue,ExpectedNil缺省表示原节点非空值 type InvoiceFieldChange struct { @@ -97,6 +111,100 @@ type invoiceXMLPatch struct { data []byte } +// InvoiceFields 获取发票字段描述,原始事实通过来源补丁修改 +// 入参: value 发票数据 +// 返回: []InvoiceFieldDescriptor 字段描述列表 +func InvoiceFields(value Invoice) []InvoiceFieldDescriptor { + format, supported := invoiceWriteFormat(value) + probe := Invoice{Items: []InvoiceItem{{}}} + if value.Standard == "EInvoice" { + probe.Number, probe.Date = "editable", "editable" + for _, group := range invoicePlainGroups() { + for _, name := range strings.Fields(group.value) { + if p, _ := invoicePlainField(&probe, nil, "/EInvoice/EInvoiceData/"+group.name+"/"+name); p != nil { + *p = "editable" + } + } + } + for _, name := range strings.Fields("ItemName SpecMod MeaUnits Quantity UnPrice Amount TaxRate ComTaxAm") { + if p, _ := invoicePlainField(&probe, &probe.Items[0], "/EInvoice/EInvoiceData/IssuItemInformation/"+name); p != nil { + *p = "editable" + } + } + } else { + for _, name := range invoiceWriteHeader(value) { + if p := invoiceXMLField(&probe, value.Kind, name); p != nil { + *p = "editable" + } + } + for _, name := range invoiceWriteItemNames(value.Kind) { + if p := invoiceItemField(&probe.Items[0], value.Kind, name); p != nil { + *p = "editable" + } + } + } + var result []InvoiceFieldDescriptor + for _, field := range invoiceLayoutFields(value) { + 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} + binding := InvoiceBinding{Field: field.field, Item: field.item} + if field.item >= 0 { + binding.Item = 0 + d.Group = "items" + } + if strings.HasPrefix(field.field, "buyer.") { + d.Group = "buyer" + } + if strings.HasPrefix(field.field, "seller.") { + d.Group = "seller" + } + mapped, _ := invoiceBindingValue(probe, binding) + d.Editable = supported && format.Edit && mapped == "editable" + switch field.field { + case "date": + d.Type = "date" + if value.Kind == "einv" { + d.Type = "datetime" + } + case "amount", "taxAmount", "totalAmount", "items.quantity", "items.unitPrice", "items.amount", "items.taxAmount": + d.Type = "decimal" + case "items.taxRate": + if value.Standard == "EInvoice" { + d.Type = "decimal" + } + } + 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} + if item >= 0 { + d.Group = "items" + } + d.Editable = supported && format.Edit && fact.Source.NodePath != "" && fact.Namespace != invoiceXBRL + if invoiceNumeric(value.Kind, fact.Name) { + d.Type = "decimal" + } + if invoiceWriteDateFact(value.Kind, fact.Name) { + d.Type = "date" + } + if value.Kind == "einv" && (fact.Name == "RequestTime" || fact.Name == "IssueTime") { + d.Type = "datetime" + } + result = append(result, d) + } + for i, fact := range value.Facts { + appendFact(fact, fmt.Sprintf("facts.%d", i), -1) + } + for i, item := range value.Items { + for j, fact := range item.Fields { + appendFact(fact, fmt.Sprintf("items.fields.%d", j), i) + } + } + return result +} + // InvoiceFormats 返回独立格式能力副本,历史航空格式仅允许读取和原位修改 // 返回: 已适配格式列表 func InvoiceFormats() []InvoiceFormat {