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 {