Skip to content

Commit 0e23163

Browse files
dafrosemergify[bot]
authored andcommitted
feat: implement XML attachment file naming functionality (#253)
* feat(eu_einvoice): configurable base name for auto-attached XRechnung XML Add optional pattern on E Invoice Settings (auto_name_format_for_xml_file) for the file base name when auto-attaching XML on Sales Invoice submit. Empty pattern keeps using the document name; otherwise use naming-series-style segments (dot-separated, parse_naming_series, {field} placeholders, date tokens), strip leading # per segment so counter-only parts do not consume Series, sanitize with get_safe_file_name, and fall back to the document name on error. Colocate get_xml_attachment_file_base_name with Sales Invoice custom logic and cover naming edge cases in test_sales_invoice.py. Refresh E Invoice Settings controller and de / template catalogs for the new strings. * refactor(eu_einvoice): enhance download_xrechnung to use configurable base name Update the download_xrechnung function to retrieve the Sales Invoice document and check permissions before generating the XML file. The filename now utilizes a configurable base name from E Invoice Settings, improving flexibility for file naming. This change ensures that the generated XML file adheres to the specified naming format. * chore(eu_einvoice): update E Invoice Settings and localization files Rearrange fields in e_invoice_settings.json for better organization, adding a label for the XML Settings section. Update localization files (de.po and main.pot) to reflect changes in field names and add new translations for XML Settings. Adjust POT creation dates for consistency. * refactor(eu_einvoice): centralize sales invoice xml stem naming Resolve the downloadable XML filename stem in get_xml_attachment_file_base_name: read *Auto name format for XML file* from **E Invoice Settings** when the pattern argument is omitted, accept an optional keyword-only pattern override, and build the stem with parse_naming_series plus a no-op number generator so naming has no series counter DB side effects. Sanitize with get_safe_file_name and fall back to doc.name when the pattern is empty or fails. Call sites use the helper without duplicating settings reads. Tests pass pattern= for empty or whitespace patterns, and assert the settings-backed path by patching frappe.get_single_value only for **E Invoice Settings** / auto_name_format_for_xml_file. --------- Co-authored-by: Daniel Rose <26166128+dafrose@users.noreply.github.com> (cherry picked from commit d45a1f0) # Conflicts: # eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json # eu_einvoice/locale/de.po # eu_einvoice/locale/main.pot
1 parent c4e4e4b commit 0e23163

6 files changed

Lines changed: 443 additions & 6 deletions

File tree

eu_einvoice/european_e_invoice/custom/sales_invoice.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
from drafthorse.models.trade import LogisticsServiceCharge
1616
from drafthorse.models.tradelines import LineItem
1717
from frappe import _
18-
from frappe.core.doctype.file.utils import find_file_by_url
18+
from frappe.core.doctype.file.utils import find_file_by_url, get_safe_file_name
1919
from frappe.core.utils import html2text
20+
from frappe.model.naming import parse_naming_series
21+
from frappe.utils import cstr
2022
from frappe.utils.data import date_diff, flt, getdate, to_markdown
2123

2224
from eu_einvoice.common_codes import CommonCodeRetriever
@@ -45,8 +47,10 @@
4547

4648
@frappe.whitelist()
4749
def download_xrechnung(invoice_id: str):
48-
frappe.local.response.filename = f"{invoice_id}.xml"
49-
frappe.local.response.filecontent = get_einvoice(invoice_id)
50+
invoice = frappe.get_doc("Sales Invoice", invoice_id)
51+
base_name = get_xml_attachment_file_base_name(invoice)
52+
frappe.local.response.filecontent = get_einvoice(invoice)
53+
frappe.local.response.filename = f"{base_name}.xml"
5054
frappe.local.response.type = "download"
5155

5256

@@ -906,7 +910,8 @@ def _attach_xml_file(doc: SalesInvoice, xml_content: bytes, field_name: str | No
906910
)
907911
return
908912

909-
file_name = f"{doc.name}.xml".replace("/", "-")
913+
base_name = get_xml_attachment_file_base_name(doc)
914+
file_name = f"{base_name}.xml"
910915

911916
# Create new File document
912917
file_doc = frappe.new_doc("File")
@@ -1129,3 +1134,35 @@ def attach_xml_to_pdf(invoice_id: str, pdf_data: bytes) -> bytes:
11291134

11301135
xml_bytes = get_einvoice(invoice_id)
11311136
return attach_xml(pdf_data, xml_bytes, level)
1137+
1138+
1139+
def get_xml_attachment_file_base_name(doc, *, pattern: str | None = None) -> str:
1140+
"""Filename stem (no `.xml`) for the downloadable XML.
1141+
1142+
Uses *pattern* when given, otherwise reads *Auto name format for XML file*
1143+
from **E Invoice Settings**. Falls back to `doc.name` when the pattern is
1144+
empty or fails to resolve. Result is sanitized via `get_safe_file_name`
1145+
(same rules as **File** attachments).
1146+
"""
1147+
if pattern is None:
1148+
pattern = frappe.get_single_value("E Invoice Settings", "auto_name_format_for_xml_file")
1149+
pattern = cstr(pattern).strip()
1150+
if pattern:
1151+
try:
1152+
base = parse_naming_series(pattern, doc=doc, number_generator=_no_series_counter).strip()
1153+
except Exception:
1154+
frappe.log_error(
1155+
title=_("E Invoice XML file name pattern failed"),
1156+
message=frappe.get_traceback(),
1157+
reference_doctype=doc.doctype,
1158+
reference_name=doc.name,
1159+
)
1160+
base = ""
1161+
if base:
1162+
return get_safe_file_name(base)
1163+
return get_safe_file_name(doc.name)
1164+
1165+
1166+
def _no_series_counter(_key: str, _digits: int) -> str:
1167+
"""Disable the series counter so XML naming has no DB side effects."""
1168+
return ""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from unittest.mock import patch
2+
3+
import frappe
4+
from frappe.tests.utils import FrappeTestCase
5+
6+
from eu_einvoice.european_e_invoice.custom.sales_invoice import (
7+
get_xml_attachment_file_base_name,
8+
)
9+
10+
11+
class TestXmlAttachmentNaming(FrappeTestCase):
12+
def test_auto_name_format_from_e_invoice_settings(self):
13+
doc = frappe._dict(name="SINV-00001", po_no="PO-42", doctype="Sales Invoice")
14+
field = "auto_name_format_for_xml_file"
15+
pattern = "XMLSTEM.-.{po_no}"
16+
real_gsv = frappe.get_single_value
17+
18+
def get_single_value(doctype, fname, cache=True):
19+
if doctype == "E Invoice Settings" and fname == field:
20+
return pattern
21+
return real_gsv(doctype, fname, cache=cache)
22+
23+
with patch.object(frappe, "get_single_value", side_effect=get_single_value):
24+
self.assertEqual(get_xml_attachment_file_base_name(doc), "XMLSTEM-PO-42")
25+
26+
def test_empty_or_whitespace_uses_doc_name(self):
27+
doc = frappe._dict(name="SINV/00001", doctype="Sales Invoice")
28+
self.assertEqual(get_xml_attachment_file_base_name(doc, pattern=""), "SINV_00001")
29+
self.assertEqual(get_xml_attachment_file_base_name(doc, pattern=" "), "SINV_00001")
30+
31+
def test_dot_separated_pattern_with_field(self):
32+
doc = frappe._dict(name="SINV-00001", po_no="PO-1", doctype="Sales Invoice")
33+
base = get_xml_attachment_file_base_name(doc, pattern="EXAMPLE.-.{po_no}")
34+
self.assertEqual(base, "EXAMPLE-PO-1")
35+
36+
def test_dot_separated_inv_field_end(self):
37+
doc = frappe._dict(name="SINV-00001", po_no="X-9", doctype="Sales Invoice")
38+
base = get_xml_attachment_file_base_name(doc, pattern="INV.-.{po_no}.-.END")
39+
self.assertEqual(base, "INV-X-9-END")
40+
41+
def test_leading_hashes_stripped_per_part_avoids_series_counter(self):
42+
doc = frappe._dict(name="SINV-00001", po_no="PO-1", doctype="Sales Invoice")
43+
base = get_xml_attachment_file_base_name(doc, pattern="EXAMPLE.-.{po_no}.#####")
44+
self.assertEqual(base, "EXAMPLE-PO-1")
45+
46+
def test_hash_in_literal_sanitized_like_file_utils(self):
47+
doc = frappe._dict(name="SINV-00001", doctype="Sales Invoice")
48+
base = get_xml_attachment_file_base_name(doc, pattern="INV#X")
49+
self.assertEqual(base, "INV_X")
50+
51+
def test_only_hash_segments_falls_back_to_doc_name(self):
52+
doc = frappe._dict(name="SINV-00001", doctype="Sales Invoice")
53+
self.assertEqual(get_xml_attachment_file_base_name(doc, pattern="#####"), "SINV-00001")
54+
55+
def test_slash_in_resolved_base_is_sanitized(self):
56+
doc = frappe._dict(name="SINV-00001", po_no="A/B", doctype="Sales Invoice")
57+
base = get_xml_attachment_file_base_name(doc, pattern="{po_no}")
58+
self.assertEqual(base, "A_B")

eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.json

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"error_action_on_submit",
1414
"sales_invoice_number_field",
1515
"section_break_yldr",
16+
"auto_name_format_for_xml_file",
17+
"column_break_vlzj",
1618
"auto_attach_xml",
1719
"attach_field_for_xml_file"
1820
],
@@ -53,8 +55,10 @@
5355
"options": "\nWarning Message\nError Message"
5456
},
5557
{
58+
"bold": 1,
5659
"fieldname": "section_break_yldr",
57-
"fieldtype": "Section Break"
60+
"fieldtype": "Section Break",
61+
"label": "XML Settings"
5862
},
5963
{
6064
"default": "0",
@@ -74,13 +78,41 @@
7478
"fieldname": "sales_invoice_number_field",
7579
"fieldtype": "Autocomplete",
7680
"label": "Sales Invoice Number Field"
81+
<<<<<<< HEAD
82+
=======
83+
},
84+
{
85+
"fieldname": "column_break_vlzj",
86+
"fieldtype": "Column Break"
87+
},
88+
{
89+
"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>",
90+
"fieldname": "auto_name_format_for_xml_file",
91+
"fieldtype": "Data",
92+
"label": "Auto Name Format for XML File"
93+
},
94+
{
95+
"fieldname": "section_break_bt120",
96+
"fieldtype": "Section Break",
97+
"label": "VAT exemption (e-invoice)"
98+
},
99+
{
100+
"description": "If set, written to BT-120 (<code>ram:ExemptionReason</code>) on VAT breakdowns where an exemption reason code is emitted. Left unset in the XML when empty.",
101+
"fieldname": "vat_exemption_reason_text",
102+
"fieldtype": "Small Text",
103+
"label": "Default VAT exemption reason"
104+
>>>>>>> d45a1f0 (feat: implement XML attachment file naming functionality (#253))
77105
}
78106
],
79107
"grid_page_length": 50,
80108
"index_web_pages_for_search": 1,
81109
"issingle": 1,
82110
"links": [],
111+
<<<<<<< HEAD
83112
"modified": "2026-02-06 17:52:09.990716",
113+
=======
114+
"modified": "2026-05-08 11:55:24.180676",
115+
>>>>>>> d45a1f0 (feat: implement XML attachment file naming functionality (#253))
84116
"modified_by": "Administrator",
85117
"module": "European e-Invoice",
86118
"name": "E Invoice Settings",
@@ -101,4 +133,4 @@
101133
"sort_field": "creation",
102134
"sort_order": "DESC",
103135
"states": []
104-
}
136+
}

eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class EInvoiceSettings(Document):
1818

1919
attach_field_for_xml_file: DF.Autocomplete | None
2020
auto_attach_xml: DF.Check
21+
auto_name_format_for_xml_file: DF.Data | None
2122
error_action_on_save: DF.Literal["", "Warning Message", "Error Message"]
2223
error_action_on_submit: DF.Literal["", "Warning Message", "Error Message"]
2324
sales_invoice_number_field: DF.Autocomplete | None

0 commit comments

Comments
 (0)