Skip to content

feat(attachments): multi-annex embed for Sales Invoice (part 1) - #263

Open
dafrose wants to merge 36 commits into
alyf-de:version-16-hotfixfrom
dafrose:feat/embed_attachments_16
Open

feat(attachments): multi-annex embed for Sales Invoice (part 1)#263
dafrose wants to merge 36 commits into
alyf-de:version-16-hotfixfrom
dafrose:feat/embed_attachments_16

Conversation

@dafrose

@dafrose dafrose commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

Part 1 of selectable PDF annexes for Sales Invoice (#261, coordination #76). This PR is supposed to only add support for multiple attachments via the same embedding path as before. File attachments are still embedded into XML (which may be embedded into PDF). It does not provide support for embedding additional files directly into PDF. This is planned for a follow-up PR.

When Multiple Attachment Embedding is enabled on E Invoice Settings, annexes are selected from the new einvoice_attachments child table and embedded in the e-invoice XML as 916 supporting documents (same _embed_attachments loop as today). When the setting is off, behaviour stays on the legacy einvoice_embedded_document attach field.

  • einvoice_attachments child table on Sales Invoice with Desk attach button and first-row-wins dedup on validate
  • multi_attachment_embed_enabled checkbox on E Invoice Settings
  • get_embed_attachments branches between table and legacy field; shared module sales_invoice_attachments.py
  • Migration on enable: per-save move of legacy field → table (orange msgprint); bulk RQ job via Desk button; legacy field hidden + read_only while multi-embed is on
  • Embed refactor: get_legacy_embed_attachment + _embed_attachments URL loop (no change to hybrid PDF mechanics yet — annex bytes still travel inside embedded Factur-X XML)
  • Tests: New tests cover embedding behaviour of the legacy path as well as the table path, migration from legacy field to table and table-specific validations.

Not in this PR (possible follow-ups): PDF/A-3 factur-x pipeline with N PDF file attachments (#262), profile-aware 916 encoding, MIME allowlist, v15/develop backports.

Settings:
image

Migration Button UI:
image

Sales Invoice EInvoice tab:
image

Test plan

  • bench --site <site> run-tests --app eu_einvoice --lightmode — 22 tests green
  • E Invoice Settings: enable Multiple Attachment Embedding → legacy attach field hidden/read-only
  • Sales Invoice with legacy einvoice_embedded_document only → save with setting on → row appears in Embedded Documents, legacy field cleared, orange migration message
  • Sales Invoice with broken legacy URL → save with setting on → field unchanged, Error Log entry (no blocking save)
  • E Invoice SettingsMigrate attachments to table → RQ job clears legacy fields site-wide
  • Sales Invoice with two rows in einvoice_attachments → submit/download PDF → annex payloads present in embedded Factur-X XML (Desk spot-check or XML download)

Notes for reviewers

  • Hybrid PDF path unchanged: attach_xml_to_pdf still embeds invoice XML only; new integration test verifies table annex content survives the PDF round-trip inside that XML.
  • Target branch is version-16-hotfix per rollout plan; develop backport differs (patch migration drops legacy field).

Ticket Ref

LMK-16

@dafrose
dafrose force-pushed the feat/embed_attachments_16 branch from 3e495c6 to 53520f4 Compare June 15, 2026 12:17
@dafrose

dafrose commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe to merge — all core paths (migration, embedding, permission checks, rollback on DB failure) are correctly implemented and the previously flagged issues are resolved.

Every issue raised in previous review rounds is confirmed fixed. The migration path correctly handles draft, submitted, and cancelled invoices through direct DB writes, rolls back on failure, deduplicates the background job, and gates the whitelisted endpoint behind System Manager. The validate_doc hook correctly guards attachment-table validation behind the multi-embed setting. DocType JSON timestamps carry proper microseconds and the auto-generated Python type annotations match the JSON field definitions.

Files Needing Attention: No files require special attention. The one remaining note (misleading parameter name in _file_already_in_attachments) is cosmetic and does not affect correctness.

Sequence Diagram

sequenceDiagram
    participant User
    participant SalesInvoice as Sales Invoice (validate)
    participant Attachments as sales_invoice_attachments.py
    participant DB as Database
    participant RQ as RQ Background Job

    User->>SalesInvoice: Save / Submit
    SalesInvoice->>Attachments: validate_doc(doc)
    Attachments->>DB: get E Invoice Settings.multi_attachment_embed_enabled
    alt "multi_attachment_embed_enabled = ON"
        Attachments->>Attachments: migrate_legacy_embed_to_table(doc)
        alt legacy URL present
            Attachments->>DB: _resolve_embed_file_for_invoice (invoice-scoped)
            alt File resolved
                Attachments->>Attachments: _persist_legacy_embed_migration_on_save
                note right of Attachments: append row in-memory, clear legacy field
            else File not found + table empty
                Attachments->>DB: frappe.log_error
            end
        end
        Attachments->>DB: _validate_embed_file_attached_to_invoice (per row)
        Attachments->>DB: get content_hash per row (dup-content msgprint)
    end

    User->>SalesInvoice: Download XML / PDF
    SalesInvoice->>Attachments: get_embed_attachments(invoice)
    alt "multi_attachment_embed_enabled = ON"
        Attachments->>Attachments: _get_table_embed_attachments
    else
        Attachments->>Attachments: _get_legacy_embed_attachment (find_file_by_url)
    end
    Attachments-->>SalesInvoice: list[EmbedAttachment]
    SalesInvoice->>SalesInvoice: _embed_attachments → CII 916 nodes

    User->>+DB: POST migrate_attachments_to_table (System Manager only)
    DB->>DB: frappe.only_for("System Manager")
    DB->>RQ: enqueue bulk_migrate_legacy_embed_attachments
    loop each invoice with legacy URL
        RQ->>DB: _resolve_embed_file_for_invoice
        alt File resolved
            RQ->>DB: _insert_attachment_row (INSERT)
            RQ->>DB: "set_value einvoice_embedded_document="""
            RQ->>DB: frappe.db.commit()
        else broken link
            RQ->>DB: log_error / clear field (if remove_broken_links)
        end
    end
    RQ-->>User: realtime msgprint summary
Loading

Reviews (21): Last reviewed commit: "fix(attachments): make legacy embed migr..." | Re-trigger Greptile

Comment thread eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py Outdated
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
@dafrose

dafrose commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

@greptileai

@dafrose
dafrose marked this pull request as ready for review June 17, 2026 08:10
@dafrose
dafrose requested a review from barredterra June 17, 2026 08:11
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
@dafrose
dafrose force-pushed the feat/embed_attachments_16 branch from d3710c5 to d0582ca Compare June 17, 2026 09:08
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
@dafrose
dafrose force-pushed the feat/embed_attachments_16 branch from d0582ca to a5c5022 Compare June 17, 2026 13:27
@barredterra
barredterra requested a review from 0xD0M1M0 June 19, 2026 00:53
Comment thread eu_einvoice/european_e_invoice/doctype/e_invoice_settings/e_invoice_settings.py Outdated
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice.js
@dafrose
dafrose force-pushed the feat/embed_attachments_16 branch from 077bfb3 to 49cfcc5 Compare June 19, 2026 09:56
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@dafrose

dafrose commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

I tested a simple print format addition for the embedded files.

The code I used:

		{# Embedded e-invoice annexes (916 supporting documents) #}
		{% set multi_embed = frappe.db.get_single_value("E Invoice Settings", "multi_attachment_embed_enabled") %}
		{% if multi_embed and doc.einvoice_attachments %}
		<div class="info-card">
			<div class="title">{{ _("Embedded Documents") }}</div>
			<ul class="small-text" style="margin: 8px 0 0 0; padding-left: 18px;">
				{% for row in doc.einvoice_attachments %}
				{% set file_url = frappe.db.get_value("File", row.file, "file_url") %}
				<li style="margin-bottom: 4px;">
					{% if file_url %}
					<a href="{{ frappe.utils.get_url(file_url) }}">
						{{ row.display_name or row.file_name or file_url }}
					</a>
					{% else %}
					{{ row.display_name or row.file_name or row.file }}
					{% endif %}
				</li>
				{% endfor %}
			</ul>
		</div>
		{% elif not multi_embed and doc.einvoice_embedded_document %}
		<div class="info-card">
			<div class="title">{{ _("Embedded Document") }}</div>
			<p class="small-text" style="margin-top: 8px;">
				<a href="{{ frappe.utils.get_url(doc.einvoice_embedded_document) }}">
					{{ doc.einvoice_embedded_document }}
				</a>
			</p>
		</div>
		{% endif %}

The result:

image

Comment thread .gitignore Outdated
dafrose added 11 commits June 26, 2026 13:17
Pin legacy embed behaviour with YAML scenarios (empty field, local 916,
https and /api/method/ remote uri_id) and Drafthorse assertions on mocked
find_file_by_url. Ignore .vscode/ in the app repo.
Replace _embed_attachment with get_legacy_embed_attachment and
_embed_attachments so create_einvoice passes a URL list. Add integration
test and before_tests fixtures so embed tests use governed master data
instead of site-specific rows.
…ation

Introduce E Invoice Attachment Row, einvoice_attachments on Sales Invoice,
multi_attachment_embed_enabled setting, first-row-wins dedup on validate,
and Desk attach-button UX.
Branch create_einvoice on get_embed_attachments; move attachment
resolution to sales_invoice_attachments; consolidate tests and helpers.
Move einvoice_embedded_document into einvoice_attachments when
multi-attachment embed is enabled, with per-save migration on Sales
Invoice validate and a deduplicated bulk RQ job from E Invoice
Settings. Lock the legacy attach field when multi-embed is on, fail
embed on missing File rows, and add integration coverage for
migration, lockdown, and broken links.
…units

Add an integration test that embeds a table annex through attach_xml_to_pdf
and verifies the payload survives extraction from the hybrid Factur-X PDF.
Introduce shared helpers for minimal PDF bytes and CII AttachmentBinaryObject
assertions.

Remove unit tests for deduplicate_attachment_rows, get_legacy_embed_attachment,
and mocked get_table_embed_attachments; those paths are already covered by
integration tests.
Regenerate POT/PO from the attachment migration strings and translate
23 new and 1 adapted msgid in de.po.
Add an Include submitted Sales Invoices option on the E Invoice Settings
migrate button. Draft invoices still migrate via save; submitted invoices
use direct child-row inserts and db.set_value so post-submit restrictions
do not block the job. Replace the bulk migration integration test with a
draft/submitted × include_submitted matrix and cancel submitted fixtures
before teardown.
Throw when an einvoice_attachments row points at a File without a
file_url, load File rows via the normal get_doc path during legacy
embed resolution, and restrict site-wide migration to System Manager.
Restructure attachment test coverage: split get_embed_attachments
branching into subTest cases, drop redundant table-principles unit class,
extract URL-order resolver checks, and restore multi-embed settings in
integration setUp/tearDown. Move set_multi_attachment_embed_enabled and
assert_single_orange_message into tests/helpers.py.

Add integration coverage for bulk migration of cancelled submitted
invoices and prefer a positive docstatus check in bulk_migrate_legacy_embed_attachments.

Regenerate fixtures to update attachment row schema and enable file id field visibility,
for the row filter to work on Sales Invoice Attachments table.
…d XML

Share case-insensitive duplicate detection between validate_einvoice_attachment_rows
and _get_table_embed_attachments; remove validate_attachments and the warn-only
path so save-time checks align with the DB constraint. Require file_name on
E Invoice Attachment Row and document display_name as print-only.
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice.py Outdated
dafrose added 3 commits July 29, 2026 11:15
…bility

Show only the legacy attach field before multi-embed is enabled, and only
the Embedded Documents table afterward. Sync Custom Field flags from
settings updates, after_install, and after_migrate, and update German
locale strings.
…ex bytes

Rollback the transaction when site-wide legacy embed migration fails mid-persist
so a later commit cannot flush orphaned child rows. Read embedded annex files with
get_content(encodings=[]) so PNG/PDF bytes are not text-decoded before base64.
Add regression tests and stabilize embed test fixtures when pdf_on_submit is
installed.
@dafrose

dafrose commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

@greptile-apps

@dafrose

dafrose commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Update since 19 Jun 2026

Focus: annex validation hardening, Desk field exclusivity, and two production fixes.

What changed

  • Unique embed filenames (BR-DE-22) — DB unique index on (parent, file_name) on E Invoice Attachment Row; save-time duplicate check when an error action is configured; embed/XML path always enforces uniqueness.
  • Duplicate annex content — identical File content_hash across table rows → orange Desk hint on save (warn-only; filenames can still differ).
  • Desk exclusivity — with multi-embed off: only legacy Embedded Document visible; with multi-embed on: only Embedded Documents table; legacy hidden + read-only. Synced from E Invoice Settings, after_install, and after_migrate.
  • Annex row validationvalidate_einvoice_attachment_rows runs only when multi_attachment_embed_enabled is on (orphan table rows in legacy mode no longer block save).
  • Bulk legacy migrate — failed per-invoice DB persist rolls back the transaction so a later success cannot leave orphan child rows.
  • Binary annex embed — CII embed reads file bytes with get_content(encodings=[]) so small PNG/PDF annexes are not corrupted by Frappe’s default text decoding.
  • German localede.po / main.pot updated for new strings; run compile-po-to-mo if you test Desk in German.

Tests: bench --site <site> run-tests --app eu_einvoice --lightmode (app tests self-seed; no ERPNext test_records preload).

Reviewer setup — unique constraint

There is no migrate patch for unique_parent_file_name. Greenfield installs get the constraint when the child DocType is first synced.

If your review site already had E Invoice Attachment Row before pulling this branch:

  1. Remove any duplicate (parent, file_name) rows on test invoices.
  2. Apply the constraint once:
bench --site <site> execute eu_einvoice.european_e_invoice.doctype.e_invoice_attachment_row.e_invoice_attachment_row.on_doctype_update

bench migrate alone does not re-run on_doctype_update when the child DocType JSON hash is unchanged.

Alternatively: fresh site + install-app eu_einvoice, or save E Invoice Attachment Row in Desk once to trigger on_doctype_update.

Test plan

Area Steps
Constraint Enable multi-embed; on one draft Sales Invoice, add two Embedded Documents rows with the same File Name → save blocked with duplicate-filename message when error action is set.
Content hash Two rows, different filenames, same file content → orange msgprint; save still allowed.
Exclusivity Never-enabled site: reload invoice form → only legacy attach. Enable setting → reload → only table; legacy not editable.
Validate gate (Optional) With setting off and table rows present via API/import → save does not run annex row validation (normal Desk path hides the table).
Bulk migrate E Invoice Settings → migrate with Include submitted; submitted invoice with legacy embed migrates to table; broken URL skip/remove still works.
Embed smoke Draft invoice with one annex (legacy or table path) → create/download e-invoice XML; annex present as ARD 916.

Automated: bench --site <site> run-tests --app eu_einvoice --lightmode.

@barredterra barredterra left a comment

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.

Suggested merge bar

Solid after the earlier review rounds (permissions, POST-only, orphaned-row rollback, binary encodings=[], field exclusivity). CI is green. Please address these three correctness items before merge:

  1. Don’t use validating save() for bulk draft migration (or skip e-invoice validation there).
  2. Always enforce duplicate annex filenames when multi-embed is on.
  3. Set idx on direct child inserts.

Inline comments below.

Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.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?

Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
dafrose and others added 5 commits July 31, 2026 08:17
Route all successful bulk legacy-embed migrations through
_persist_legacy_embed_migration_db so unrelated Sales Invoice
validation cannot block drafts when Action on Validation Error
during Save is enabled.
BR-DE-22 uniqueness is a format rule, not an e-invoice error-action
toggle. Run duplicate-filename validation whenever multi-embed annex
rows are validated, including on Postgres where the unique index is
case-sensitive.
Assign max(idx) + 1 when bulk legacy migration inserts E Invoice
Attachment Row records so Desk grid order is stable instead of
defaulting to idx 0.
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
…mbeds

Queue site-wide bulk migration when Multiple Attachment Embedding is first
enabled on E Invoice Settings. Fail XML/download when the legacy attach field
is still set so annexes cannot silently drop after the setting flip.

Bulk migration always covers all docstatus via direct DB persist; remove the
include_submitted option from the API and migrate dialog. Share enqueue logic
through queue_bulk_migrate_legacy_embed_attachments and publish progress and
completion updates to the requesting user via realtime msgprint.
@dafrose
dafrose force-pushed the feat/embed_attachments_16 branch from 6b26a7a to 0c00060 Compare July 31, 2026 09:47
dafrose added 2 commits July 31, 2026 12:29
Validate table embed rows against File.attached_to_* on save and at XML
generation so API/import paths cannot reference arbitrary File ids. Add unit
and integration coverage; attach test annex files to invoices in helpers.
@dafrose

dafrose commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@greptile-apps

@dafrose
dafrose requested a review from barredterra August 3, 2026 07:11
Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice_attachments.py Outdated
dafrose added 3 commits August 3, 2026 09:39
Drop the site-wide file_url fallback in _resolve_embed_file_for_invoice.
A shared URL on another Sales Invoice could be migrated into the child
table and then fail validate_einvoice_attachment_rows, blocking every
save until manual DB intervention.

Only consider File rows attached to the current invoice; treat unmatched
legacy URLs as broken links. Add an integration test for the cross-invoice
URL collision case.
Only block embed when the legacy field is set and the annex table is
empty. On save, clear unresolvable legacy URLs when table rows already
exist so embed can proceed from the table.
Skip inserting an E Invoice Attachment Row when the resolved File is
already listed in einvoice_attachments; still clear einvoice_embedded_document
on bulk migrate and on-save. Count bulk skips as already_migrated and
suppress the orange “moved” msgprint when no row was appended.

Add integration coverage for bulk and validate paths. Regenerate POT/PO
and translate the broken-legacy-link-cleared message plus workspace labels.
@dafrose

dafrose commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@greptile-apps

Comment thread eu_einvoice/european_e_invoice/custom/sales_invoice.js
Embedded Documents cannot be filled until the invoice is saved (File needs
attached_to_name). Hide Add Row via cannot_add_rows while is_new so users
are not offered empty, unusable rows.
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown

Too many files changed for review (24 files, 15 file limit).

Bypass the limit by tagging @greptile-apps to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants