mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
1 parent
7a0bbbe8df
commit
b8d90174a0
5 files changed
+149
-14
No files matched your search
@@ -1935,6 +1935,11 @@ body[data-hide-meta] .meta-panel {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.invoice-dialog #settingsActions .button {
|
||||
min-width: calc(2em + 18px);
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.invoice-dialog #settingsStatus {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
|
||||
+25
-7
@@ -5555,6 +5555,7 @@ async function loadDocumentSettings() {
|
||||
settings.busy = true;
|
||||
settingsElements.Form.inert = true;
|
||||
settingsElements.Status.textContent = "正在读取信息";
|
||||
settingsElements.Status.title = "";
|
||||
try {
|
||||
if (kind === "invoices") { await loadInvoiceSettings(sequence, openSeq); return; }
|
||||
const data = ["bookmarks", "actions", "extensions"].includes(kind) ? await callWASM("ofdgoDocumentNavigation", kind) : {};
|
||||
@@ -5742,12 +5743,24 @@ function invoiceSetValue(invoice, field, item, value) {
|
||||
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 invoiceIssues(issues, details = false) {
|
||||
const messages = {
|
||||
"unsupported invoice XML root or version": "暂不支持此发票格式或版本",
|
||||
"unsupported eInvoice namespace or version": "暂不支持此发票格式或版本",
|
||||
"unsupported EInvoice version": "暂不支持此发票版本",
|
||||
"EInvoice requires a unique versioned Header": "发票头信息不完整"
|
||||
};
|
||||
return [...new Set((issues || []).map(issue => {
|
||||
const message = typeof issue === "string" ? issue : issue.message || issue.code;
|
||||
if (typeof issue === "string") return details ? message : messages[message] || message;
|
||||
if (details) return [issue.source?.fileLoc || issue.source?.id || issue.source?.kind, issue.source?.nodePath, issue.field, message].filter(Boolean).join(":");
|
||||
return [issue.field, messages[message] || message].filter(Boolean).join(":");
|
||||
}).filter(Boolean))].join("\n");
|
||||
}
|
||||
|
||||
function renderInvoiceSettings() {
|
||||
const context = settingsElements.Context, content = settingsElements.Content;
|
||||
settingsElements.Status.title = "";
|
||||
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 => {
|
||||
@@ -5775,6 +5788,7 @@ function renderInvoiceSettings() {
|
||||
}
|
||||
if (!draft) {
|
||||
settingsElements.Status.textContent = invoiceIssues(invoicePanel.issues) || "暂无发票";
|
||||
settingsElements.Status.title = invoiceIssues(invoicePanel.issues, true);
|
||||
return;
|
||||
}
|
||||
const tabs = document.createElement("div");
|
||||
@@ -5821,6 +5835,7 @@ function renderInvoiceSettings() {
|
||||
}, !draft.detail.capabilities?.delete, "InvoiceDelete");
|
||||
}
|
||||
settingsElements.Status.textContent = draft.status || invoiceIssues(draft.detail.issues) || "";
|
||||
if (!draft.status) settingsElements.Status.title = invoiceIssues(draft.detail.issues, true);
|
||||
}
|
||||
|
||||
async function newInvoiceDraft() {
|
||||
@@ -5854,10 +5869,12 @@ function renderInvoiceBasic(container, draft) {
|
||||
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 => {
|
||||
const value = invoiceValue(draft.invoice, field.field);
|
||||
const display = field.field === "standard" && value === "http://www.edrm.org.cn/schema/e-invoice/2019" ? "电子发票/2019" : value;
|
||||
invoiceInput(grid, labels[field.field.split(".").at(-1)] || field.label, `Field_${field.field.replaceAll(".", "_")}`, display, value => {
|
||||
invoiceSetValue(draft.invoice, field.field, -1, value);
|
||||
invoiceDirty(draft, "model");
|
||||
}, !state.editing || !(field.editable || !draft.entry && field.field === "currency" && draft.invoice.standard !== "EInvoice") || !!draft.entry && !draft.detail.capabilities?.edit).title = field.label;
|
||||
}, !state.editing || !(field.editable || !draft.entry && field.field === "currency" && draft.invoice.standard !== "EInvoice") || !!draft.entry && !draft.detail.capabilities?.edit).title = display !== value ? value : 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 || "同步票面";
|
||||
@@ -5926,7 +5943,7 @@ function renderInvoiceAdvanced(container, draft) {
|
||||
|
||||
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);
|
||||
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?.edit && 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");
|
||||
@@ -6277,10 +6294,11 @@ function resetInvoiceDraft(draft) {
|
||||
}
|
||||
|
||||
function renderInvoiceXML(container, draft) {
|
||||
const writable = state.editing && (!draft.entry || draft.detail.capabilities?.edit);
|
||||
invoiceInput(container, "原文", "XML", draft.xml, value => {
|
||||
draft.xml = value;
|
||||
invoiceDirty(draft, "xml");
|
||||
}, !state.editing, "textarea").rows = 14;
|
||||
}, !writable, "textarea").rows = 14;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "invoice-actions";
|
||||
container.append(actions);
|
||||
@@ -6289,7 +6307,7 @@ function renderInvoiceXML(container, draft) {
|
||||
if (!draft.entry) draft.importing = true;
|
||||
invoiceDirty(draft, "xml");
|
||||
renderDocumentSettings();
|
||||
}), false, "InvoiceLoadXML", actions);
|
||||
}), !writable, "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"));
|
||||
|
||||
@@ -105,7 +105,7 @@ func (r *Reader) InvoiceCapabilities(ctx context.Context, source InvoiceSource,
|
||||
format, known := invoiceWriteFormat(report.Invoices[index])
|
||||
result.Create = known && format.Create
|
||||
result.Edit = known && format.Edit
|
||||
result.Items = known && format.Items
|
||||
result.Items = result.Edit && format.Items
|
||||
result.Bind = known
|
||||
result.Generate = known
|
||||
result.Delete = true
|
||||
|
||||
@@ -205,10 +205,11 @@ func InvoiceFields(value Invoice) []InvoiceFieldDescriptor {
|
||||
return result
|
||||
}
|
||||
|
||||
// InvoiceFormats 返回独立格式能力副本,历史航空格式仅允许读取和原位修改
|
||||
// InvoiceFormats 返回独立格式能力副本,创建及编辑能力按业务版本分别声明
|
||||
// 返回: 已适配格式列表
|
||||
func InvoiceFormats() []InvoiceFormat {
|
||||
result := []InvoiceFormat{{Kind: "einv", Standard: "EInvoice", Version: "0.3", Read: true, Create: true, Edit: true, Items: true}}
|
||||
result = append(result, InvoiceFormat{Kind: "inv", Standard: invoiceEDRM, Version: "1.0", Read: true, Items: true})
|
||||
for _, kind := range []string{"inv", "einv", "atr", "rai"} {
|
||||
result = append(result, InvoiceFormat{Kind: kind, Standard: "http://xbrl.mof.gov.cn/taxonomy/2023-12-31/" + kind, Version: "2023-12-31", Read: true, Create: true, Edit: true, Items: kind == "inv" || kind == "atr"})
|
||||
}
|
||||
@@ -914,6 +915,10 @@ func PatchInvoice(ctx context.Context, data []byte, changes []InvoiceFieldChange
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, inv := range report.Invoices {
|
||||
format, supported := invoiceWriteFormat(inv)
|
||||
if !supported || !format.Edit {
|
||||
continue
|
||||
}
|
||||
for _, f := range inv.Facts {
|
||||
if f.Namespace != invoiceXBRL {
|
||||
allowed[f.Source.NodePath] = true
|
||||
@@ -1176,6 +1181,9 @@ func UpdateInvoiceXML(ctx context.Context, data []byte, invoiceIndex int, value
|
||||
if reflect.DeepEqual(old, value) {
|
||||
return bytes.Clone(data), nil
|
||||
}
|
||||
if format, supported := invoiceWriteFormat(old); !supported || !format.Edit {
|
||||
return nil, fmt.Errorf("invoice format does not support editing")
|
||||
}
|
||||
if value.Kind != old.Kind || value.Standard != old.Standard || value.Version != old.Version {
|
||||
return nil, fmt.Errorf("invoice update cannot change format")
|
||||
}
|
||||
|
||||
+109
-5
@@ -34,6 +34,9 @@ const invoiceXBRL = "http://www.xbrl.org/2003/instance"
|
||||
// invoiceXSI 表示XMLSchema实例命名空间
|
||||
const invoiceXSI = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
||||
// invoiceEDRM 表示2019版电子发票业务命名空间
|
||||
const invoiceEDRM = "http://www.edrm.org.cn/schema/e-invoice/2019"
|
||||
|
||||
// invoiceDecimalPattern 仅接受XML十进制词法
|
||||
var invoiceDecimalPattern = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$`)
|
||||
|
||||
@@ -249,7 +252,10 @@ func parseInvoiceXML(ctx context.Context, data []byte, source InvoiceSource) (In
|
||||
issue := func(code, field, msg string, s InvoiceSource) {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: code, Field: field, Message: msg, Source: s})
|
||||
}
|
||||
if root.name == (xml.Name{Local: "EInvoice"}) {
|
||||
if source.Kind == "tag" && invoiceObjectReferences(root) {
|
||||
return report, nil
|
||||
}
|
||||
if root.name == (xml.Name{Local: "EInvoice"}) || root.name.Local == "eInvoice" && (root.name.Space == "" || root.name.Space == invoiceEDRM) {
|
||||
return invoicePlainXML(ctx, root, source)
|
||||
}
|
||||
if root.name != (xml.Name{Space: invoiceXBRL, Local: "xbrl"}) {
|
||||
@@ -955,11 +961,50 @@ func invoiceAmountCheck(inv *Invoice, report *InvoiceReport, source InvoiceSourc
|
||||
}
|
||||
}
|
||||
|
||||
// invoicePlainXML 解析官方数电发票指南中的无命名空间0.3版本
|
||||
// invoiceObjectReferences 识别由对象引用及对象数据构成的票面标引
|
||||
// 入参: root 标引根节点
|
||||
// 返回: bool 是否为纯对象映射
|
||||
func invoiceObjectReferences(root *invoiceXMLNode) bool {
|
||||
count := 0
|
||||
var walk func(*invoiceXMLNode) bool
|
||||
walk = func(n *invoiceXMLNode) bool {
|
||||
if n.name.Space == ofdNamespace2016 || n.name.Space == ofdNamespaceLegacy || n.name.Space == "ofd" || n.name.Space == invoiceEDRM {
|
||||
if n.name.Local == "ObjectRef" {
|
||||
count++
|
||||
return len(n.children) == 0 && strings.TrimSpace(n.text.String()) != "" && invoiceXMLAttr(n, "", "PageRef") != ""
|
||||
}
|
||||
if n.name.Local == "ObjectData" {
|
||||
return len(n.children) == 0
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(n.text.String()) != "" {
|
||||
return false
|
||||
}
|
||||
for _, child := range n.children {
|
||||
if !walk(child) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return walk(root) && count > 0
|
||||
}
|
||||
|
||||
// invoicePlainXML 解析数电发票0.3及2019版电子发票XML
|
||||
// 入参: ctx 上下文,root 根节点,source 来源
|
||||
// 返回: 发票及诊断,取消错误
|
||||
func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSource) (InvoiceReport, error) {
|
||||
report := InvoiceReport{}
|
||||
inv := Invoice{Kind: "einv", Standard: "EInvoice", Version: "0.3", Sources: []InvoiceSource{source}}
|
||||
namespace, itemPath := "", "/EInvoice/EInvoiceData/IssuItemInformation"
|
||||
if root.name.Local == "eInvoice" {
|
||||
if invoiceXMLAttr(root, "", "Version") != "1.0" || len(invoiceChildren(root, invoiceEDRM, "InvoiceNo")) == 0 {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "unsupported", Message: "unsupported eInvoice namespace or version", Source: source})
|
||||
return report, nil
|
||||
}
|
||||
inv.Kind, inv.Standard, inv.Version = "inv", invoiceEDRM, "1.0"
|
||||
namespace, itemPath = invoiceEDRM, "/eInvoice/GoodsInfos/GoodsInfo"
|
||||
} else {
|
||||
headers := invoiceChildren(root, "", "Header")
|
||||
if len(headers) != 1 {
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "unsupported", Message: "EInvoice requires a unique versioned Header", Source: source})
|
||||
@@ -970,7 +1015,7 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo
|
||||
report.Issues = append(report.Issues, InvoiceIssue{Code: "unsupported", Field: "Version", Message: "unsupported EInvoice version", Source: source})
|
||||
return report, nil
|
||||
}
|
||||
inv := Invoice{Kind: "einv", Standard: "EInvoice", Version: "0.3", Sources: []InvoiceSource{source}}
|
||||
}
|
||||
seen := map[string]InvoiceFact{}
|
||||
blocked := map[string]bool{}
|
||||
var walk func(*invoiceXMLNode, string, *InvoiceItem) error
|
||||
@@ -979,10 +1024,10 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo
|
||||
return err
|
||||
}
|
||||
path += "/" + n.name.Local
|
||||
if n.name.Space != "" {
|
||||
if n != root && n.name.Space != namespace {
|
||||
path = "!" + path
|
||||
}
|
||||
if path == "/EInvoice/EInvoiceData/IssuItemInformation" {
|
||||
if path == itemPath {
|
||||
inv.Items = append(inv.Items, InvoiceItem{})
|
||||
item = &inv.Items[len(inv.Items)-1]
|
||||
}
|
||||
@@ -1007,6 +1052,9 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo
|
||||
return nil
|
||||
}
|
||||
dst, numeric := invoicePlainField(&inv, item, path)
|
||||
if inv.Standard == invoiceEDRM {
|
||||
dst, numeric = invoiceEDRMField(&inv, item, path)
|
||||
}
|
||||
if dst == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1039,11 +1087,67 @@ func invoicePlainXML(ctx context.Context, root *invoiceXMLNode, source InvoiceSo
|
||||
if err := walk(root, "", nil); err != nil {
|
||||
return InvoiceReport{}, err
|
||||
}
|
||||
if inv.Standard == invoiceEDRM && inv.Date != "" {
|
||||
if date, err := time.Parse("2006年01月02日", inv.Date); err == nil {
|
||||
inv.Date = date.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
invoiceAmountCheck(&inv, &report, source)
|
||||
report.Invoices = append(report.Invoices, inv)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// invoiceEDRMField 按2019版电子发票路径映射公共字段,合并信息保留原始事实
|
||||
// 入参: inv 发票,item 当前明细,path 命名空间已校验的路径
|
||||
// 返回: 字段地址及数值标志
|
||||
func invoiceEDRMField(inv *Invoice, item *InvoiceItem, path string) (*string, bool) {
|
||||
switch path {
|
||||
case "/eInvoice/InvoiceCode":
|
||||
return &inv.Code, false
|
||||
case "/eInvoice/InvoiceNo":
|
||||
return &inv.Number, false
|
||||
case "/eInvoice/IssueDate":
|
||||
return &inv.Date, false
|
||||
case "/eInvoice/Buyer/BuyerName":
|
||||
return &inv.Buyer.Name, false
|
||||
case "/eInvoice/Buyer/BuyerTaxID":
|
||||
return &inv.Buyer.TaxID, false
|
||||
case "/eInvoice/Seller/SellerName":
|
||||
return &inv.Seller.Name, false
|
||||
case "/eInvoice/Seller/SellerTaxID":
|
||||
return &inv.Seller.TaxID, false
|
||||
case "/eInvoice/TaxExclusiveTotalAmount":
|
||||
return &inv.Amount, true
|
||||
case "/eInvoice/TaxTotalAmount":
|
||||
return &inv.TaxAmount, true
|
||||
case "/eInvoice/TaxInclusiveTotalAmount":
|
||||
return &inv.TotalAmount, true
|
||||
case "/eInvoice/Note":
|
||||
return &inv.Remarks, false
|
||||
}
|
||||
if item != nil && strings.HasPrefix(path, "/eInvoice/GoodsInfos/GoodsInfo/") {
|
||||
switch strings.TrimPrefix(path, "/eInvoice/GoodsInfos/GoodsInfo/") {
|
||||
case "Item":
|
||||
return &item.Name, false
|
||||
case "Specification":
|
||||
return &item.Specification, false
|
||||
case "MeasurementDimension":
|
||||
return &item.Unit, false
|
||||
case "Quantity":
|
||||
return &item.Quantity, true
|
||||
case "Price":
|
||||
return &item.UnitPrice, true
|
||||
case "Amount":
|
||||
return &item.Amount, true
|
||||
case "TaxScheme":
|
||||
return &item.TaxRate, false
|
||||
case "TaxAmount":
|
||||
return &item.TaxAmount, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// invoicePlainField 按官方样例中的完整路径映射字段
|
||||
// 入参: inv 发票,item 当前明细,path 完整无命名空间路径
|
||||
// 返回: 字段地址及数值标志
|
||||
|
||||
Reference in new issue
Block a user