diff --git a/eu_einvoice/european_e_invoice/custom/sales_invoice.py b/eu_einvoice/european_e_invoice/custom/sales_invoice.py index e57ef6e3..cfaf4270 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 v16+ 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 1a69a724..0b5c2418 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,10 @@ "options": "\nWarning Message\nError Message" }, { + "bold": 1, "fieldname": "section_break_yldr", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "XML Settings" }, { "default": "0", @@ -77,6 +81,16 @@ "fieldtype": "Autocomplete", "label": "Sales Invoice Number Field" }, + { + "fieldname": "column_break_vlzj", + "fieldtype": "Column Break" + }, + { + "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", + "fieldname": "auto_name_format_for_xml_file", + "fieldtype": "Data", + "label": "Auto Name Format for XML File" + }, { "fieldname": "section_break_bt120", "fieldtype": "Section Break", @@ -93,7 +107,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-08 13:02:59.672943", + "modified": "2026-05-08 11:55:24.180676", "modified_by": "Administrator", "module": "European e-Invoice", "name": "E Invoice Settings", @@ -114,4 +128,4 @@ "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} 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 91c2a2ee..543758f2 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-05 10:01+0053\n" +"POT-Creation-Date: 2026-06-08 16:49+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.13.1\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,11 @@ 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 a 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 a 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 +170,11 @@ msgstr "Berechneter Betrag" 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." @@ -316,7 +321,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" @@ -349,11 +358,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}" @@ -447,6 +456,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" +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" + #. Label of a Section Break field in DocType 'E Invoice Import' #: eu_einvoice/european_e_invoice/doctype/e_invoice_import/e_invoice_import.json msgid "Payment Means" @@ -722,19 +741,24 @@ msgstr "Validierungswarnungen" msgid "Warning Message" msgstr "Warnung" -#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:782 +#. Label of a 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 c60088c0..680bd82b 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-05 10:01+0053\n" -"PO-Revision-Date: 2026-06-05 10:01+0053\n" +"POT-Creation-Date: 2026-06-08 16:49+0053\n" +"PO-Revision-Date: 2026-06-08 16:49+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.13.1\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,11 @@ msgstr "" msgid "Attach Field for XML File" msgstr "" +#. Label of a 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 a 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 +168,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 "" @@ -314,7 +319,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 "" @@ -347,11 +356,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 "" @@ -445,6 +454,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" +msgstr "" + #. Label of a Section Break field in DocType 'E Invoice Import' #: eu_einvoice/european_e_invoice/doctype/e_invoice_import/e_invoice_import.json msgid "Payment Means" @@ -721,19 +738,24 @@ msgstr "" msgid "Warning Message" msgstr "" -#: eu_einvoice/european_e_invoice/custom/sales_invoice.py:782 +#. Label of a 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 ""