diff --git a/eu_einvoice/european_e_invoice/custom/sales_invoice.py b/eu_einvoice/european_e_invoice/custom/sales_invoice.py
index e57ef6e3..3e597fd4 100644
--- a/eu_einvoice/european_e_invoice/custom/sales_invoice.py
+++ b/eu_einvoice/european_e_invoice/custom/sales_invoice.py
@@ -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
@@ -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"
@@ -919,7 +923,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")
@@ -1142,3 +1147,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 ""
diff --git a/eu_einvoice/european_e_invoice/custom/test_sales_invoice.py b/eu_einvoice/european_e_invoice/custom/test_sales_invoice.py
new file mode 100644
index 00000000..6e36ff10
--- /dev/null
+++ b/eu_einvoice/european_e_invoice/custom/test_sales_invoice.py
@@ -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")
diff --git a/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json b/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
index 5a38368f..316b732f 100644
--- a/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+++ b/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
@@ -15,6 +15,8 @@
"section_break_bt120",
"vat_exemption_reason_text",
"section_break_yldr",
+ "auto_name_format_for_xml_file",
+ "column_break_vlzj",
"auto_attach_xml",
"attach_field_for_xml_file"
],
@@ -55,8 +57,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 file name of the XML. .xml is appended automatically. Leave empty to use the document name (Sales Invoice ID). In case of an error, the document name is used as fallback.\n
Segments use a dot (.) between literal text and dynamic parts. Example:
EXAMPLE-.MM.-.{po_no}.-.YYYY
- Dates: DD, MM, YYYY, YY, WW, timestamp
- Fields: Sales Invoice field names in braces, e.g. {po_no}
- Other segments are treated as literal text.
",
+ "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",
diff --git a/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py b/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py
index 48197706..18268b3f 100644
--- a/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py
+++ b/eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py
@@ -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
diff --git a/eu_einvoice/locale/de.po b/eu_einvoice/locale/de.po
index 55f0576f..81276453 100644
--- a/eu_einvoice/locale/de.po
+++ b/eu_einvoice/locale/de.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: European e-Invoice VERSION\n"
"Report-Msgid-Bugs-To: hallo@alyf.de\n"
-"POT-Creation-Date: 2026-06-08 13:47+0053\n"
+"POT-Creation-Date: 2026-06-08 16:32+0053\n"
"PO-Revision-Date: 2026-02-05 11:13+0100\n"
"Last-Translator: hallo@alyf.de\n"
"Language: de\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:803
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:807
msgid "A document level discount is currently not supported in the e-invoice."
msgstr "Ein Rabatt auf der Dokumentebene wird in der E-Rechnung derzeit nicht unterstützt."
@@ -65,6 +65,12 @@ msgstr "Eine E-Rechnung mit der gleichen Rechnungs-ID und Lieferanten existiert
msgid "Attach Field for XML File"
msgstr "Anhangsfeld für XML-Datei"
+#. Label of the auto_name_format_for_xml_file (Data) field in DocType 'E
+#. Invoice Settings'
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+msgid "Auto Name Format for XML File"
+msgstr "Namensmuster für die XML-Datei"
+
#. Label of the auto_attach_xml (Check) field in DocType 'E Invoice Settings'
#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
msgid "Auto-attach XML File"
@@ -165,11 +171,11 @@ msgstr "Käufer-Referenz"
msgid "Calculation Percent"
msgstr "Berechnung Prozentsatz"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:834
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:838
msgid "Cannot create E Invoice."
msgstr "E-Rechnung konnte nicht erstellt werden."
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:848
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:852
msgid "Cannot validate E Invoice schematron."
msgstr "E-Rechnung konnte nicht validiert werden."
@@ -260,7 +266,11 @@ msgstr "E-Rechnungs-Einstellungen"
msgid "E Invoice Trade Tax"
msgstr "E-Rechnungs-Steuer"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:816
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:1168
+msgid "E Invoice XML file name pattern failed"
+msgstr "Namensmuster für E-Rechnungs-XML-Datei fehlgeschlagen"
+
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:820
msgid "E Invoice is not correct"
msgstr "E-Rechnung ist nicht korrekt"
@@ -295,11 +305,11 @@ msgstr "Schema der elektronischen Adresse"
msgid "Embedded Document"
msgstr "Eingebettetes Dokument"
-#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:51
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:52
msgid "Field '{0}' does not exist on Sales Invoice doctype"
msgstr "Feld '{0}' existiert nicht im DocType 'Sales Invoice'"
-#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:59
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:60
msgid "Field '{0}' must be of type 'Attach'. Current type: {1}"
msgstr "Feld '{0}' muss vom Typ 'Anhängen' sein. Aktueller Typ: {1}"
@@ -355,6 +365,16 @@ msgstr "Nur bei E-Rechnungs-Profil \"XRECHNUNG\" beim Buchen"
msgid "Partial Amount"
msgstr "Teilbetrag"
+#. Description of the 'Auto Name Format for XML File' (Data) field in DocType
+#. 'E Invoice Settings'
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+msgid ""
+"Pattern for the file name of the XML. .xml is appended automatically. Leave empty to use the document name (Sales Invoice ID). In case of an error, the document name is used as fallback.\n"
+"
Segments use a dot (.) between literal text and dynamic parts. Example:
EXAMPLE-.MM.-.{po_no}.-.YYYY- Dates: DD, MM, YYYY, YY, WW, timestamp
- Fields: Sales Invoice field names in braces, e.g. {po_no}
- Other segments are treated as literal text.
"
+msgstr ""
+"Muster für den Dateinamen der XML-Datei. .xml wird automatisch angehängt. Leer lassen, um den Dokumentnamen zu verwenden (ID der Ausgangsrechnung). Bei einem Fehler wird der Dokumentname als Fallback verwendet.\n"
+"
Literaltext und dynamische Teile werden mit einem Punkt (.) getrennt. Beispiel:
BEISPIEL-.MM.-.{po_no}.-.YYYY- Datumsangaben: DD, MM, YYYY, YY, WW, timestamp
- Felder: Feldnamen der Ausgangsrechnung in geschweiften Klammern, z. B. {po_no}
- Alle übrigen Abschnitte sind fester Text.
"
+
#. Label of the payment_means_section (Section Break) field in DocType 'E
#. Invoice Import'
#: eu_einvoice/european_e_invoice/doctype/e_invoice_import/e_invoice_import.json
@@ -561,19 +581,25 @@ msgstr "Validierungswarnungen"
msgid "Warning Message"
msgstr "Warnung"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:782
+#. Label of the section_break_yldr (Section Break) field in DocType 'E Invoice
+#. Settings'
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+msgid "XML Settings"
+msgstr "XML-Einstellungen"
+
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:786
msgid "{0} row #{1}: Discount Date should be after Posting Date"
msgstr "{0} Zeile {1}: Rabatt-Datum sollte nach Buchungsdatum liegen"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:771
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:775
msgid "{0} row #{1}: The charge type 'Actual' is only supported in the eInvoice profiles 'EXTENDED' and 'XRECHNUNG'."
msgstr "Die Gebührenart 'Tatsächlich' wird nur in den E-Rechnungs-Profilen 'EXTENDED' und 'XRECHNUNG' unterstützt."
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:759
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:763
msgid "{0} row #{1}: Type '{2}' is not supported in e-invoice"
msgstr "{0} Zeile #{1}: Typ '{2}' wird in der E-Rechnung nicht unterstützt"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:794
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:798
msgid "{0}: Only one mode of payment will be considered in the e-invoice."
msgstr "{0}: Nur eine Zahlungsart wird in der E-Rechnung berücksichtigt."
diff --git a/eu_einvoice/locale/main.pot b/eu_einvoice/locale/main.pot
index 82c42aea..82039ff9 100644
--- a/eu_einvoice/locale/main.pot
+++ b/eu_einvoice/locale/main.pot
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: European e-Invoice VERSION\n"
"Report-Msgid-Bugs-To: hallo@alyf.de\n"
-"POT-Creation-Date: 2026-06-08 13:47+0053\n"
-"PO-Revision-Date: 2026-06-08 13:47+0053\n"
+"POT-Creation-Date: 2026-06-08 16:32+0053\n"
+"PO-Revision-Date: 2026-06-08 16:32+0053\n"
"Last-Translator: hallo@alyf.de\n"
"Language-Team: hallo@alyf.de\n"
"MIME-Version: 1.0\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:803
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:807
msgid "A document level discount is currently not supported in the e-invoice."
msgstr ""
@@ -63,6 +63,12 @@ msgstr ""
msgid "Attach Field for XML File"
msgstr ""
+#. Label of the auto_name_format_for_xml_file (Data) field in DocType 'E
+#. Invoice Settings'
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+msgid "Auto Name Format for XML File"
+msgstr ""
+
#. Label of the auto_attach_xml (Check) field in DocType 'E Invoice Settings'
#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
msgid "Auto-attach XML File"
@@ -163,11 +169,11 @@ msgstr ""
msgid "Calculation Percent"
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:834
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:838
msgid "Cannot create E Invoice."
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:848
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:852
msgid "Cannot validate E Invoice schematron."
msgstr ""
@@ -258,7 +264,11 @@ msgstr ""
msgid "E Invoice Trade Tax"
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:816
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:1168
+msgid "E Invoice XML file name pattern failed"
+msgstr ""
+
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:820
msgid "E Invoice is not correct"
msgstr ""
@@ -293,11 +303,11 @@ msgstr ""
msgid "Embedded Document"
msgstr ""
-#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:51
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:52
msgid "Field '{0}' does not exist on Sales Invoice doctype"
msgstr ""
-#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:59
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py:60
msgid "Field '{0}' must be of type 'Attach'. Current type: {1}"
msgstr ""
@@ -353,6 +363,14 @@ msgstr ""
msgid "Partial Amount"
msgstr ""
+#. Description of the 'Auto Name Format for XML File' (Data) field in DocType
+#. 'E Invoice Settings'
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+msgid ""
+"Pattern for the file name of the XML. .xml is appended automatically. Leave empty to use the document name (Sales Invoice ID). In case of an error, the document name is used as fallback.\n"
+"
Segments use a dot (.) between literal text and dynamic parts. Example:
EXAMPLE-.MM.-.{po_no}.-.YYYY- Dates: DD, MM, YYYY, YY, WW, timestamp
- Fields: Sales Invoice field names in braces, e.g. {po_no}
- Other segments are treated as literal text.
"
+msgstr ""
+
#. Label of the payment_means_section (Section Break) field in DocType 'E
#. Invoice Import'
#: eu_einvoice/european_e_invoice/doctype/e_invoice_import/e_invoice_import.json
@@ -560,19 +578,25 @@ msgstr ""
msgid "Warning Message"
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:782
+#. Label of the section_break_yldr (Section Break) field in DocType 'E Invoice
+#. Settings'
+#: eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json
+msgid "XML Settings"
+msgstr ""
+
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:786
msgid "{0} row #{1}: Discount Date should be after Posting Date"
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:771
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:775
msgid "{0} row #{1}: The charge type 'Actual' is only supported in the eInvoice profiles 'EXTENDED' and 'XRECHNUNG'."
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:759
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:763
msgid "{0} row #{1}: Type '{2}' is not supported in e-invoice"
msgstr ""
-#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:794
+#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:798
msgid "{0}: Only one mode of payment will be considered in the e-invoice."
msgstr ""