Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions eu_einvoice/european_e_invoice/custom/sales_invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.core.utils import html2text
from frappe.model.naming import parse_naming_series
from frappe.utils import cstr
from frappe.utils.data import date_diff, flt, getdate, to_markdown

from eu_einvoice.common_codes import CommonCodeRetriever
Expand Down Expand Up @@ -45,8 +47,10 @@

@frappe.whitelist()
def download_xrechnung(invoice_id: str):
frappe.local.response.filename = f"{invoice_id}.xml"
frappe.local.response.filecontent = get_einvoice(invoice_id)
invoice = frappe.get_doc("Sales Invoice", invoice_id)
base_name = get_xml_attachment_file_base_name(invoice)
frappe.local.response.filecontent = get_einvoice(invoice)
frappe.local.response.filename = f"{base_name}.xml"
frappe.local.response.type = "download"


Expand Down Expand Up @@ -906,7 +910,8 @@ def _attach_xml_file(doc: SalesInvoice, xml_content: bytes, field_name: str | No
)
return

file_name = f"{doc.name}.xml".replace("/", "-")
base_name = get_xml_attachment_file_base_name(doc)
file_name = f"{base_name}.xml"

# Create new File document
file_doc = frappe.new_doc("File")
Expand Down Expand Up @@ -1129,3 +1134,40 @@ def attach_xml_to_pdf(invoice_id: str, pdf_data: bytes) -> bytes:

xml_bytes = get_einvoice(invoice_id)
return attach_xml(pdf_data, xml_bytes, level)


def get_xml_attachment_file_base_name(doc, *, pattern: str | None = None) -> str:
"""Filename stem (no `.xml`) for the downloadable XML.

Uses *pattern* when given, otherwise reads *Auto name format for XML file*
from **E Invoice Settings**. Falls back to `doc.name` when the pattern is
empty or fails to resolve. Result is sanitized via `_get_safe_file_name`
(same rules as **File** attachments).
"""
if pattern is None:
pattern = frappe.get_single_value("E Invoice Settings", "auto_name_format_for_xml_file")
pattern = cstr(pattern).strip()
if pattern:
try:
base = parse_naming_series(pattern, doc=doc, number_generator=_no_series_counter).strip()
except Exception:
frappe.log_error(
title=_("E Invoice XML file name pattern failed"),
message=frappe.get_traceback(),
reference_doctype=doc.doctype,
reference_name=doc.name,
)
base = ""
if base:
return _get_safe_file_name(base)
return _get_safe_file_name(doc.name)


def _get_safe_file_name(file_name: str) -> str:
"""Local-only; mirrors ``get_safe_file_name`` in Frappe v17+ file utils."""
return re.sub(r"[/\\%?#]", "_", file_name)


def _no_series_counter(_key: str, _digits: int) -> str:
"""Disable the series counter so XML naming has no DB side effects."""
return ""
58 changes: 58 additions & 0 deletions eu_einvoice/european_e_invoice/custom/test_sales_invoice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from unittest.mock import patch

import frappe
from frappe.tests.utils import FrappeTestCase

from eu_einvoice.european_e_invoice.custom.sales_invoice import (
get_xml_attachment_file_base_name,
)


class TestXmlAttachmentNaming(FrappeTestCase):
def test_auto_name_format_from_e_invoice_settings(self):
doc = frappe._dict(name="SINV-00001", po_no="PO-42", doctype="Sales Invoice")
field = "auto_name_format_for_xml_file"
pattern = "XMLSTEM.-.{po_no}"
real_gsv = frappe.get_single_value

def get_single_value(doctype, fname, cache=True):
if doctype == "E Invoice Settings" and fname == field:
return pattern
return real_gsv(doctype, fname, cache=cache)

with patch.object(frappe, "get_single_value", side_effect=get_single_value):
self.assertEqual(get_xml_attachment_file_base_name(doc), "XMLSTEM-PO-42")

def test_empty_or_whitespace_uses_doc_name(self):
doc = frappe._dict(name="SINV/00001", doctype="Sales Invoice")
self.assertEqual(get_xml_attachment_file_base_name(doc, pattern=""), "SINV_00001")
self.assertEqual(get_xml_attachment_file_base_name(doc, pattern=" "), "SINV_00001")

def test_dot_separated_pattern_with_field(self):
doc = frappe._dict(name="SINV-00001", po_no="PO-1", doctype="Sales Invoice")
base = get_xml_attachment_file_base_name(doc, pattern="EXAMPLE.-.{po_no}")
self.assertEqual(base, "EXAMPLE-PO-1")

def test_dot_separated_inv_field_end(self):
doc = frappe._dict(name="SINV-00001", po_no="X-9", doctype="Sales Invoice")
base = get_xml_attachment_file_base_name(doc, pattern="INV.-.{po_no}.-.END")
self.assertEqual(base, "INV-X-9-END")

def test_leading_hashes_stripped_per_part_avoids_series_counter(self):
doc = frappe._dict(name="SINV-00001", po_no="PO-1", doctype="Sales Invoice")
base = get_xml_attachment_file_base_name(doc, pattern="EXAMPLE.-.{po_no}.#####")
self.assertEqual(base, "EXAMPLE-PO-1")

def test_hash_in_literal_sanitized_like_file_utils(self):
doc = frappe._dict(name="SINV-00001", doctype="Sales Invoice")
base = get_xml_attachment_file_base_name(doc, pattern="INV#X")
self.assertEqual(base, "INV_X")

def test_only_hash_segments_falls_back_to_doc_name(self):
doc = frappe._dict(name="SINV-00001", doctype="Sales Invoice")
self.assertEqual(get_xml_attachment_file_base_name(doc, pattern="#####"), "SINV-00001")

def test_slash_in_resolved_base_is_sanitized(self):
doc = frappe._dict(name="SINV-00001", po_no="A/B", doctype="Sales Invoice")
base = get_xml_attachment_file_base_name(doc, pattern="{po_no}")
self.assertEqual(base, "A_B")
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"error_action_on_submit",
"sales_invoice_number_field",
"section_break_yldr",
"auto_name_format_for_xml_file",
"column_break_vlzj",
"auto_attach_xml",
"attach_field_for_xml_file"
],
Expand Down Expand Up @@ -53,8 +55,20 @@
"options": "\nWarning Message\nError Message"
},
{
"bold": 1,
"fieldname": "section_break_yldr",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "XML Settings"
},
{
"description": "Pattern for the <b>file name</b> of the XML. <b>.xml</b> is appended automatically. Leave <b>empty</b> to use the document <b>name</b> (Sales Invoice ID). In case of an error, the document <b>name</b> is used as fallback.\n<br><br>Segments use a <b>dot</b> (<b>.</b>) between literal text and dynamic parts. Example:<br><b>EXAMPLE-.MM.-.{po_no}.-.YYYY</b><ul><li><b>Dates</b>: <b>DD</b>, <b>MM</b>, <b>YYYY</b>, <b>YY</b>, <b>WW</b>, <b>timestamp</b></li><li><b>Fields</b>: <b>Sales Invoice</b> field names in braces, e.g. <b>{po_no}</b></li><li>Other segments are treated as literal text.</li></ul>",
"fieldname": "auto_name_format_for_xml_file",
"fieldtype": "Data",
"label": "Auto Name Format for XML File"
},
{
"fieldname": "column_break_vlzj",
"fieldtype": "Column Break"
},
{
"default": "0",
Expand All @@ -80,7 +94,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-06 17:52:09.990716",
"modified": "2026-05-08 11:55:24.180676",
"modified_by": "Administrator",
"module": "European e-Invoice",
"name": "E Invoice Settings",
Expand All @@ -101,4 +115,4 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class EInvoiceSettings(Document):

attach_field_for_xml_file: DF.Autocomplete | None
auto_attach_xml: DF.Check
auto_name_format_for_xml_file: DF.Data | None
error_action_on_save: DF.Literal["", "Warning Message", "Error Message"]
error_action_on_submit: DF.Literal["", "Warning Message", "Error Message"]
sales_invoice_number_field: DF.Autocomplete | None
Expand Down
48 changes: 37 additions & 11 deletions eu_einvoice/locale/de.po

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading