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 @@ -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")
Expand Down Expand Up @@ -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 ""
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 @@ -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"
],
Expand Down Expand Up @@ -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",
Expand All @@ -77,6 +81,16 @@
"fieldtype": "Autocomplete",
"label": "Sales Invoice Number Field"
},
{
"fieldname": "column_break_vlzj",
"fieldtype": "Column Break"
},
{
"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": "section_break_bt120",
"fieldtype": "Section Break",
Expand All @@ -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",
Expand All @@ -114,4 +128,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
46 changes: 35 additions & 11 deletions eu_einvoice/locale/de.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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}"

Expand Down Expand Up @@ -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 <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>"
msgstr ""
"Muster für den <b>Dateinamen</b> der XML-Datei. <b>.xml</b> wird automatisch angehängt. <b>Leer lassen</b>, um den Dokumentnamen zu verwenden (ID der Ausgangsrechnung). Bei einem Fehler wird der Dokumentname als Fallback verwendet.\n"
"<br><br>Literaltext und dynamische Teile werden mit einem <b>Punkt</b> (<b>.</b>) getrennt. Beispiel:<br><b>BEISPIEL-.MM.-.{po_no}.-.YYYY</b><ul><li><b>Datumsangaben</b>: <b>DD</b>, <b>MM</b>, <b>YYYY</b>, <b>YY</b>, <b>WW</b>, <b>timestamp</b></li><li><b>Felder</b>: Feldnamen der <b>Ausgangsrechnung</b> in geschweiften Klammern, z. B. <b>{po_no}</b></li><li>Alle übrigen Abschnitte sind fester Text.</li></ul>"

#. 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"
Expand Down Expand Up @@ -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."

Loading
Loading