feat(电子发票): 补齐发票编辑与票面同步
build.yaml / build (push) Successful in 57s

This commit is contained in:
xiaoqidun committed 2026-10-16 13:00:41 +08:00
1 parent 528162a276
commit 30f4f76260
6 files changed
+1697 -10

No files matched your search

+1 -1
View File
@@ -411,7 +411,7 @@
<h2 id="settingsTitle" class="form-title">文档配置</h2>
<span id="settingsLocation" class="settings-location"></span>
</header>
<label class="form-row"><span>类型</span><select id="settingsKind"><option value="bookmarks">书签</option><option value="actions">动作</option><option value="templates">模板</option><option value="layers">图层</option><option value="extensions">扩展</option><option value="ColorSpace" hidden>色彩</option><option value="DrawParam" hidden>样式</option></select></label>
<label class="form-row"><span>类型</span><select id="settingsKind"><option value="bookmarks">书签</option><option value="actions">动作</option><option value="templates">模板</option><option value="layers">图层</option><option value="extensions">扩展</option><option value="invoices">发票</option><option value="ColorSpace" hidden>色彩</option><option value="DrawParam" hidden>样式</option></select></label>
<div id="settingsBody" class="settings-body">
<div id="settingsContext"></div>
<div id="settingsContent"></div>
+121
View File
@@ -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;
}
+773 -3
View File
@@ -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;
+542
View File
@@ -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 引用或度量错误
+152 -6
View File
@@ -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)
+108
View File
@@ -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 {