Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7c271ce
test(eu_einvoice): add unit tests for _embed_attachment
dafrose Jun 12, 2026
a151cba
refactor(eu_einvoice): loop legacy embed attachments with test scaffold
dafrose Jun 12, 2026
880771e
feat(eu_einvoice): add Sales Invoice attachment table and dedup valid…
dafrose Jun 12, 2026
7bfd319
feat(attachments): gate embed source on multi-attachment setting
dafrose Jun 15, 2026
eadd7bc
feat(attachments): migrate legacy embed field to attachment table
dafrose Jun 15, 2026
c429cc2
test(attachments): add hybrid PDF round-trip test and trim redundant …
dafrose Jun 15, 2026
f1b4a25
chore(i18n): update German locale for multi-attachment embedding
dafrose Jun 15, 2026
cd31e4d
fix(ci): add nosemgrep hint to test-specific database changes
dafrose Jun 15, 2026
b70310c
feat(attachments): optional submitted invoices in bulk legacy migration
dafrose Jun 16, 2026
48bfd36
fix(attachments): harden embed file resolution and migration access
dafrose Jun 17, 2026
9a35f8c
test(attachments): refactor embed tests for atomicity and shared helpers
dafrose Jun 17, 2026
066b595
fix(attachments): preserve legacy embed lockdown on custom field sync
dafrose Jun 17, 2026
be7d665
feat(attachments): add bulk broken legacy link skip/remove handling
dafrose Jun 19, 2026
fed065b
docs(sales-invoice): explain empty file link filter on new forms
dafrose Jun 19, 2026
a03fd4b
fix(attachments): ues db.set_value to clear broken links to avoid unr…
dafrose Jun 19, 2026
ba12ba8
fix(attachments): warn on broken legacy embed URL during validate
dafrose Jun 19, 2026
f30b0aa
docs(attachments): add milestone 1 docstrings and German translations
dafrose Jun 19, 2026
73b240e
fix(test): ensure test masters are created without before_tests due t…
dafrose Jun 26, 2026
c7b1332
fix(test): harden lightmode ERPNext setup and legacy embed file resol…
dafrose Jun 29, 2026
693a654
feat(attachments): validate duplicate annex filenames and embed by fi…
dafrose Jul 2, 2026
95f0514
feat(attachments): enforce unique annex filenames per invoice
dafrose Jul 3, 2026
c5d1646
refactor(attachments): unify duplicate filename validation on save an…
dafrose Jul 5, 2026
316dc1e
feat(attachments): enforce exclusive legacy vs table embed field visi…
dafrose Jul 29, 2026
b500ec1
fix(attachments): gate annex row validation on multi-embed setting
dafrose Jul 29, 2026
922f02b
fix(attachments): rollback bulk migrate failures and embed binary ann…
dafrose Jul 29, 2026
6d80a3b
fix(attachments): use DB persist for draft bulk legacy migrate
dafrose Jul 31, 2026
edf3d2e
fix(attachments): always enforce duplicate annex filenames on save
dafrose Jul 31, 2026
e0f4175
fix(attachments): set idx on direct annex child inserts
dafrose Jul 31, 2026
95c17fd
fix(migrate): remove cstr(exc) so err log captures full traceback
dafrose Jul 31, 2026
0c00060
feat(attachments): auto-queue legacy migration and block unmigrated e…
dafrose Jul 31, 2026
c312aec
fix(attachments): require annex File attached to Sales Invoice
dafrose Jul 31, 2026
bc47ed5
chore(patch): bump custom field migration patch
dafrose Aug 3, 2026
5d87a41
fix(attachments): scope legacy embed file resolution to invoice
dafrose Aug 3, 2026
7518784
fix(attachments): prefer table annexes when legacy embed remains
dafrose Aug 3, 2026
651e7a8
fix(attachments): make legacy embed migration idempotent
dafrose Aug 3, 2026
834a1d6
fix(attachments): hide Add Row on unsaved Sales Invoice annex table
dafrose Aug 6, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
tags
node_modules
__pycache__
.vscode/*
.vscode/*
21 changes: 20 additions & 1 deletion eu_einvoice/custom_fields.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom fields should likely be (re-)created through a patch or bumping execute:from eu_einvoice.install import after_install; after_install() # 17 to #18

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it also make sense to include the custom field sync in after_migrate or is the patch bump the preferred solution? @barredterra

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strictly speaking, I don't think we actually need the after_migrate part, since it does during every update what is really only necessary during the first update (via patch).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in bc47ed5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have expected removal of after_migrate logic because it should now be redundant, right?

Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from eu_einvoice.european_e_invoice.custom.sales_invoice_attachments import (
legacy_embed_field_lockdown_properties,
table_embed_field_visibility_properties,
)

from .utils import identity as _


Expand Down Expand Up @@ -158,7 +163,21 @@ def get_custom_fields():
"insert_after": "einvoice_profile",
"fieldtype": "Attach",
"depends_on": "einvoice_profile",
"description": _("Additional supporting document to be embedded in the e-invoice file."),
"description": _(
"Legacy single-attachment field. Enable Multiple Attachment Embedding "
"in E Invoice Settings to use the Embedded Documents table for one or more attachments."
),
**legacy_embed_field_lockdown_properties(),
},
{
"fieldname": "einvoice_attachments",
"label": _("Embedded Documents"),
"insert_after": "einvoice_embedded_document",
"fieldtype": "Table",
"options": "E Invoice Attachment Row",
"depends_on": "einvoice_profile",
"description": _("Additional supporting documents to embed in the e-invoice file."),
**table_embed_field_visibility_properties(),
},
{
"fieldname": "einvoice_is_correct",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Unit-test scenarios for EInvoiceGenerator._embed_attachments().
# Profile gating lives in create_einvoice — not tested here.

- id: legacy_empty_field
field_url: ""
expect:
reference_count: 0

- id: legacy_local_file_916
field_url: /files/legacy-annex.png
mock_file:
name: F-LEGACY-LOCAL
file_url: /files/legacy-annex.png
is_remote: false
content_bytes_b64: iVBORw0KGgoA=
expect:
reference_count: 1
type_code: "916"
issuer_assigned_id: F-LEGACY-LOCAL
has_attached_object: true
filename: legacy-annex.png
mime_type: image/png

- id: legacy_remote_file_uri
field_url: https://example.com/legacy-annex.pdf
mock_file:
name: F-LEGACY-REMOTE
file_url: https://example.com/legacy-annex.pdf
is_remote: true
expect:
reference_count: 1
type_code: "916"
issuer_assigned_id: F-LEGACY-REMOTE
has_attached_object: false
uri_id: https://example.com/legacy-annex.pdf

- id: legacy_remote_api_method_uri
field_url: /api/method/frappe.utils.print_format.download_pdf
mock_file:
name: F-LEGACY-API-REMOTE
file_url: /api/method/frappe.utils.print_format.download_pdf
is_remote: true
expect:
reference_count: 1
type_code: "916"
issuer_assigned_id: F-LEGACY-API-REMOTE
has_attached_object: false
uri_id: /api/method/frappe.utils.print_format.download_pdf
200 changes: 200 additions & 0 deletions eu_einvoice/european_e_invoice/custom/embed_attachment_test_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Copyright (c) 2026, ALYF GmbH and Contributors
# See license.txt

from __future__ import annotations

import base64
import io
from dataclasses import dataclass
from pathlib import Path

import frappe
import yaml
from drafthorse.models.document import Document
from lxml import etree

from eu_einvoice.european_e_invoice.custom.sales_invoice import EInvoiceGenerator, as_base_64
from eu_einvoice.utils import EInvoiceProfile

_SCENARIOS_PATH = Path(__file__).with_name("embed_attachment_scenarios.yaml")


@dataclass(frozen=True)
class MockFileSpec:
name: str
file_url: str
is_remote: bool
content: bytes = b""


@dataclass(frozen=True)
class EmbedAttachmentExpectations:
reference_count: int
type_code: str | None = None
issuer_assigned_id: str | None = None
has_attached_object: bool | None = None
filename: str | None = None
mime_type: str | None = None
uri_id: str | None = None


@dataclass(frozen=True)
class EmbedAttachmentScenario:
id: str
field_url: str
expect: EmbedAttachmentExpectations
mock_file: MockFileSpec | None = None


def load_embed_attachment_scenarios() -> list[EmbedAttachmentScenario]:
"""Load YAML-driven ``_embed_attachments`` unit-test scenarios from disk."""
raw = yaml.safe_load(_SCENARIOS_PATH.read_text(encoding="utf-8"))
scenarios: list[EmbedAttachmentScenario] = []

for row in raw:
mock_row = row.get("mock_file")
mock_file = None
if mock_row:
content = b""
if content_b64 := mock_row.get("content_bytes_b64"):
content = base64.b64decode(content_b64)
mock_file = MockFileSpec(
name=mock_row["name"],
file_url=mock_row["file_url"],
is_remote=bool(mock_row["is_remote"]),
content=content,
)

expect_row = row["expect"]
scenarios.append(
EmbedAttachmentScenario(
id=row["id"],
field_url=row["field_url"],
mock_file=mock_file,
expect=EmbedAttachmentExpectations(
reference_count=expect_row["reference_count"],
type_code=expect_row.get("type_code"),
issuer_assigned_id=expect_row.get("issuer_assigned_id"),
has_attached_object=expect_row.get("has_attached_object"),
filename=expect_row.get("filename"),
mime_type=expect_row.get("mime_type"),
uri_id=expect_row.get("uri_id"),
),
)
)

return scenarios


def make_sales_invoice_doc(**kwargs) -> frappe._dict:
"""Return a minimal in-memory **Sales Invoice** dict for unit tests."""
return frappe._dict(
{
"doctype": "Sales Invoice",
"einvoice_embedded_document": "",
"items": [],
**kwargs,
}
)


def make_embed_generator(invoice) -> EInvoiceGenerator:
"""Return an ``EInvoiceGenerator`` with an empty Drafthorse document for unit tests."""
generator = EInvoiceGenerator(
profile=EInvoiceProfile.EN16931,
invoice=invoice,
company=frappe._dict(name="Test Co"),
customer=frappe._dict(name="Test Customer", supplier_numbers=[]),
)
generator.doc = Document()
return generator


def mock_file_doc(spec: MockFileSpec) -> frappe._dict:
"""Return a mock **File**-shaped dict matching *spec*."""
return frappe._dict(
name=spec.name,
file_url=spec.file_url,
is_remote_file=spec.is_remote,
get_content=lambda *args, content=spec.content, **kwargs: content,
)


def _element_text(value) -> str:
"""Return the text content of a Drafthorse XML element wrapper."""
if hasattr(value, "_text"):
return value._text
return str(value)


def assert_embed_attachment_result(
generator: EInvoiceGenerator,
expect: EmbedAttachmentExpectations,
*,
mock_content: bytes | None = None,
) -> None:
"""Assert ARD 916 nodes on *generator* match *expect*."""
refs = generator.doc.trade.agreement.additional_references.children

if len(refs) != expect.reference_count:
raise AssertionError(f"expected {expect.reference_count} ARD nodes, got {len(refs)}")

if expect.reference_count == 0:
return

ref = refs[0]

if expect.type_code and str(ref.type_code) != expect.type_code:
raise AssertionError(f"expected TypeCode {expect.type_code!r}, got {ref.type_code!r}")

if expect.issuer_assigned_id and _element_text(ref.issuer_assigned_id) != expect.issuer_assigned_id:
raise AssertionError(
f"expected IssuerAssignedID {expect.issuer_assigned_id!r}, got {ref.issuer_assigned_id!r}"
)

attached_object = ref.attached_object
attached_content = getattr(attached_object, "_text", None) if attached_object else None

if expect.has_attached_object is True:
if not attached_content:
raise AssertionError("expected attached_object payload on ARD 916")
if expect.mime_type and attached_object._mime_code != expect.mime_type:
raise AssertionError(f"expected MIME {expect.mime_type!r}, got {attached_object._mime_code!r}")
if expect.filename and attached_object._filename != expect.filename:
raise AssertionError(f"expected filename {expect.filename!r}, got {attached_object._filename!r}")
if mock_content is not None and attached_content != as_base_64(mock_content):
raise AssertionError("expected base64 content from mock file bytes")

if expect.has_attached_object is False and attached_content:
raise AssertionError("expected no attached_object payload on ARD 916")

if expect.uri_id and _element_text(ref.uri_id) != expect.uri_id:
raise AssertionError(f"expected URIID {expect.uri_id!r}, got {ref.uri_id!r}")


_CII_ATTACHMENT_XPATH = "//ram:AttachmentBinaryObject"
_CII_NS = {"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"}


def make_minimal_pdf_bytes() -> bytes:
"""Return a tiny valid PDF for hybrid attach_xml_to_pdf tests."""
from pypdf import PdfWriter

writer = PdfWriter()
writer.add_blank_page(width=72, height=72)
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()


def extract_attachment_binary_objects_from_cii_xml(
xml_bytes: bytes,
) -> list[tuple[str, str, str]]:
"""Return ``(filename, mime_code, base64_payload)`` for each CII annex."""
root = etree.fromstring(xml_bytes)
attachments: list[tuple[str, str, str]] = []

for element in root.xpath(_CII_ATTACHMENT_XPATH, namespaces=_CII_NS):
attachments.append((element.get("filename") or "", element.get("mimeCode") or "", element.text or ""))

return attachments
69 changes: 69 additions & 0 deletions eu_einvoice/european_e_invoice/custom/sales_invoice.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
frappe.ui.form.on("Sales Invoice", {
setup(frm) {
if (frm.fields_dict.einvoice_attachments) {
frm.set_query("file", "einvoice_attachments", function () {
if (frm.is_new()) {
Comment thread
dafrose marked this conversation as resolved.
// No saved invoice name yet — files can only attach after save, so match nothing.
return { filters: { name: ["in", []] } };
Comment thread
dafrose marked this conversation as resolved.
}
return {
filters: {
attached_to_doctype: frm.doctype,
attached_to_name: frm.doc.name,
},
};
});
}
},
refresh: function (frm) {
frm.trigger("add_einvoice_button");
frm.trigger("toggle_einvoice_attachment_add_row");
frm.trigger("setup_einvoice_attachment_grid_attach_button");

if (!frm.is_dirty() && !frm.doc.einvoice_is_correct && frm.doc.einvoice_profile) {
frm.dashboard.set_headline_alert(__("Please note the validation errors of the e-invoice."));
Expand All @@ -20,4 +38,55 @@ frappe.ui.form.on("Sales Invoice", {
);
});
},
toggle_einvoice_attachment_add_row(frm) {
if (!frm.fields_dict.einvoice_attachments) {
return;
}
// Files can only attach after save — hide Add Row on new docs to avoid unusable rows.
frm.set_df_property("einvoice_attachments", "cannot_add_rows", frm.is_new());
},
setup_einvoice_attachment_grid_attach_button(frm) {
const table_field = frm.fields_dict.einvoice_attachments;
if (
!table_field?.grid ||
!frm.doc.einvoice_profile ||
frm.doc.docstatus !== 0 ||
table_field.df.hidden
) {
return;
}

table_field.grid.add_custom_button(__("Attach file"), () => {
open_einvoice_attachment_file_uploader(frm);
});
},
});

function open_einvoice_attachment_file_uploader(frm) {
if (frm.is_new()) {
frappe.msgprint({
title: __("Save required"),
message: __("Please save the Sales Invoice before attaching files."),
indicator: "orange",
});
return;
}

new frappe.ui.FileUploader({
doctype: frm.doctype,
docname: frm.docname,
fieldname: "einvoice_attachments",
allow_multiple: false,
make_attachments_public: frm.meta.make_attachments_public ? 1 : 0,
on_success: (attachment) => {
add_einvoice_attachment_row_from_upload(frm, attachment.file_doc || attachment);
},
});
}

function add_einvoice_attachment_row_from_upload(frm, file_doc) {
const row = frm.add_child("einvoice_attachments");
row.file = file_doc.name;
row.file_name = file_doc.file_name;
frm.refresh_field("einvoice_attachments");
}
Loading
Loading