From 20320c3704d35697130cd8bf2c5884f7c0d015a8 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Wed, 7 Jan 2026 12:19:51 +0100 Subject: [PATCH 01/13] fix: re-generate einvoice on_submit --- edocument/edocument/custom/sales_invoice.py | 95 ++++++++++++++++++++- edocument/hooks.py | 1 + 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/edocument/edocument/custom/sales_invoice.py b/edocument/edocument/custom/sales_invoice.py index ad9da52..174a5f8 100644 --- a/edocument/edocument/custom/sales_invoice.py +++ b/edocument/edocument/custom/sales_invoice.py @@ -123,8 +123,10 @@ def on_update(doc, method): def before_submit(doc, method): - """Create and validate EDocument before Sales Invoice is submitted. + """Validate EDocument before Sales Invoice is submitted. + This hook only performs validation if an EDocument already exists. + Otherwise EDocument creation happens in on_submit to use the final invoice name. Blocks submission if EDocument validation fails (unless ignore_validation_error is set). """ # Only process if edocument_profile is set @@ -140,5 +142,92 @@ def before_submit(doc, method): if not profile_settings.get("edocument_generation_on_submit"): return - ignore_validation_error = profile_settings.get("ignore_validation_error_for_edocument_generation") - _create_edocument(doc, ignore_validation_error=ignore_validation_error) + # Check if EDocument already exists (created during save) + existing_edocument = frappe.db.exists( + "EDocument", + { + "edocument_source_type": doc.doctype, + "edocument_source_document": doc.name, + }, + ) + + # If EDocument exists and we should block on validation error, validate it now + if existing_edocument: + ignore_validation_error = profile_settings.get("ignore_validation_error_for_edocument_generation") + if not ignore_validation_error: + edocument = frappe.get_doc("EDocument", existing_edocument) + if edocument.status == "Validation Failed": + frappe.throw( + _("EDocument {0} validation failed: {1}").format( + frappe.bold(edocument.name), edocument.error or _("Unknown error") + ) + ) + + +def on_submit(doc, method): + """Create or regenerate EDocument after Sales Invoice is submitted. + + This ensures the eDocument uses the final invoice name, not the draft name. + """ + # Only process if edocument_profile is set + if not doc.edocument_profile: + return + + # Get profile settings + profile_settings = _get_profile_settings(doc.edocument_profile) + if not profile_settings: + return + + # Check if generation on submit is enabled + if not profile_settings.get("edocument_generation_on_submit"): + return + + # Check if EDocument already exists (created during save) + existing_edocument = frappe.db.exists( + "EDocument", + { + "edocument_source_type": doc.doctype, + "edocument_source_document": doc.name, + }, + ) + + if existing_edocument: + # EDocument exists - regenerate XML with final invoice name + edocument = frappe.get_doc("EDocument", existing_edocument) + + # Delete existing XML files + xml_files = frappe.get_all( + "File", + filters={ + "attached_to_doctype": "EDocument", + "attached_to_name": edocument.name, + "file_name": ["like", "%.xml"], + }, + pluck="name", + ) + for file_name in xml_files: + frappe.delete_doc("File", file_name, ignore_permissions=True) + + # Regenerate XML + edocument.generate_xml() + + if edocument.status == "Validation Successful": + frappe.msgprint( + _("EDocument {0} regenerated with final invoice name and validated successfully").format( + frappe.bold(edocument.name) + ), + indicator="green", + alert=True, + ) + elif edocument.status == "Validation Failed": + frappe.msgprint( + _("EDocument {0} regenerated but validation failed: {1}").format( + frappe.bold(edocument.name), edocument.error or _("Unknown error") + ), + indicator="orange", + alert=True, + ) + else: + # EDocument doesn't exist - create it now with final invoice name + ignore_validation_error = profile_settings.get("ignore_validation_error_for_edocument_generation") + _create_edocument(doc, ignore_validation_error=ignore_validation_error) diff --git a/edocument/hooks.py b/edocument/hooks.py index 647e9db..49161fa 100644 --- a/edocument/hooks.py +++ b/edocument/hooks.py @@ -137,6 +137,7 @@ "Sales Invoice": { "on_update": "edocument.edocument.custom.sales_invoice.on_update", "before_submit": "edocument.edocument.custom.sales_invoice.before_submit", + "on_submit": "edocument.edocument.custom.sales_invoice.on_submit", }, "Purchase Invoice": { "on_update": "edocument.edocument.custom.purchase_invoice.on_update", From a5bff5c169886e6b9c950318991dbf76e7bae0d7 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Tue, 13 Jan 2026 10:20:57 +0100 Subject: [PATCH 02/13] feat: generator now uses tax free codes --- .../edocument/profiles/peppol/generator.py | 76 +++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index b065a35..7b9992b 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -381,7 +381,7 @@ def _add_line_item(self, root: ET.Element, item): tax_category = ET.SubElement(item_elem, f"{{{self.namespaces['cac']}}}ClassifiedTaxCategory") category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = "S" + category_id.text = self.get_vat_category_code(self.invoice, item=item) item_tax_rate = self._get_item_tax_rate(item) tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") @@ -441,12 +441,33 @@ def _add_tax_totals(self): tax_category = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cac']}}}TaxCategory") + # Get category code dynamically from first item with this rate + category_code = None + sample_item = None + for item in self.invoice.items: + if self._get_item_tax_rate(item) == rate: + sample_item = item + category_code = self.get_vat_category_code(self.invoice, item=item) + break + + if not category_code: + category_code = "S" # Fallback to standard + category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = "S" + category_id.text = category_code tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") tax_percent.text = str(flt(rate, 2)) + # Add exemption reason text if category requires it (E, AE, G, O, K) + if category_code in ["E", "AE", "G", "O", "K"]: + exemption_text = self._get_exemption_reason_text(category_code) + if exemption_text: + exemption_reason = ET.SubElement( + tax_category, f"{{{self.namespaces['cbc']}}}TaxExemptionReason" + ) + exemption_reason.text = exemption_text + tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme") scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID") scheme_id.text = "VAT" @@ -614,17 +635,24 @@ def _add_allowances_charges(self): # Tax Category: Use the tax rate from invoice taxes # Get the first non-Actual tax rate (usually VAT) tax_rate = None + sample_tax = None for tax in self.invoice.taxes: if tax.charge_type != "Actual" and tax.rate: tax_rate = tax.rate + sample_tax = tax break if tax_rate and tax_rate > 0: tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") + # Get category code dynamically + category_code = ( + self.get_vat_category_code(self.invoice, tax=sample_tax) if sample_tax else "S" + ) + # Category ID category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = "S" + category_id.text = category_code tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") tax_percent.text = str(flt(tax_rate, 2)) @@ -659,9 +687,12 @@ def _add_allowances_charges(self): if tax.rate and tax.rate > 0: tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") + # Get category code dynamically + category_code = self.get_vat_category_code(self.invoice, tax=tax) + # Category ID category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = "S" + category_id.text = category_code tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") tax_percent.text = str(flt(tax.rate, 2)) @@ -874,6 +905,7 @@ def get_vat_category_code(self, invoice, item=None, tax=None) -> str: if item: lookup_records.extend( [ + ("Item", getattr(item, "item_code", None)), ("Item Tax Template", getattr(item, "item_tax_template", None)), ("Account", getattr(item, "income_account", None)), ] @@ -896,10 +928,40 @@ def get_vat_category_code(self, invoice, item=None, tax=None) -> str: lookup_records = [(doctype, name) for doctype, name in lookup_records if name] - # Get the VAT category code using CommonCodeRetriever - category_code = duty_tax_fee_category_codes.get(lookup_records) + # Check for explicit mapping only; fallback logic handles defaults below + category_code = duty_tax_fee_category_codes.get_code(lookup_records) - return category_code + if category_code: + return category_code + + # Auto-detect Z (zero-rated) for 0% rates when no explicit mapping exists + tax_rate = None + if item: + tax_rate = self._get_item_tax_rate(item) + elif tax: + tax_rate = getattr(tax, "rate", None) + + # 0% VAT line exists → Zero-rated (safe assumption, doesn't require exemption text) + if tax_rate is not None and flt(tax_rate) == 0: + return "Z" + + # Default to S (standard) for everything else + return duty_tax_fee_category_codes.default_code or "S" + + def _get_exemption_reason_text(self, category_code: str) -> str: + """Get exemption reason text for PEPPOL validation. + + Categories E, AE, G, O, K require either TaxExemptionReasonCode or TaxExemptionReason + per PEPPOL BIS business rules (BR-E-10, BR-AE-10, BR-G-10, BR-O-10, BR-IC-10). + """ + texts = { + "E": "Exempt from VAT", + "AE": "Reverse charge", + "G": "Export outside the EU", + "O": "Not subject to VAT", + "K": "Intra-community supply", + } + return texts.get(category_code, "") def get_xml_bytes(self) -> bytes: # Return the XML as bytes From 7ab58a2c49f9998becb751b773c2f1f7ab61b927 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Tue, 13 Jan 2026 14:18:30 +0100 Subject: [PATCH 03/13] fix: tax breakdown grouping by category --- .../edocument/profiles/peppol/generator.py | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index 7b9992b..f511d6e 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -408,26 +408,31 @@ def _add_tax_totals(self): tax_amount.text = str(flt(self.invoice.total_taxes_and_charges, 2)) tax_amount.set("currencyID", self.invoice.currency) - # Group taxes by rate from items - # Calculate taxes directly from items to ensure all tax rates are captured correctly - tax_rates = {} + # Group taxes by (category_code, rate) + # O items are not subject to VAT - no rate needed; E items require rate=0 + tax_groups = {} for item in self.invoice.items: - rate = self._get_item_tax_rate(item) + category_code = self.get_vat_category_code(self.invoice, item=item) - if not rate or rate == 0: - continue + # O is not subject to VAT - no rate; E is exempt with rate=0 + if category_code == "O": + rate = None + elif category_code == "E": + rate = 0 + else: + rate = self._get_item_tax_rate(item) or 0 + + key = (category_code, rate) - if rate not in tax_rates: - tax_rates[rate] = {"taxable_amount": 0, "tax_amount": 0} + if key not in tax_groups: + tax_groups[key] = {"taxable_amount": 0, "tax_amount": 0} - # Calculate tax amount for this item - # item.net_amount is already after item-level discounts - item_tax_amount = flt(item.net_amount) * rate / 100 - tax_rates[rate]["tax_amount"] += item_tax_amount - tax_rates[rate]["taxable_amount"] += flt(item.net_amount) + item_tax_amount = flt(item.net_amount) * (rate or 0) / 100 + tax_groups[key]["tax_amount"] += item_tax_amount + tax_groups[key]["taxable_amount"] += flt(item.net_amount) - # Add TaxSubtotal for each rate - for rate, data in tax_rates.items(): + # Add TaxSubtotal for each (category, rate) group + for (category_code, rate), data in tax_groups.items(): tax_subtotal = ET.SubElement(tax_total, f"{{{self.namespaces['cac']}}}TaxSubtotal") taxable_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxableAmount") @@ -456,8 +461,10 @@ def _add_tax_totals(self): category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") category_id.text = category_code - tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") - tax_percent.text = str(flt(rate, 2)) + # BR-48: O (Not subject to VAT) has no rate; all others require Percent + if category_code != "O": + tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") + tax_percent.text = str(flt(rate or 0, 2)) # Add exemption reason text if category requires it (E, AE, G, O, K) if category_code in ["E", "AE", "G", "O", "K"]: From 7cf03625c6b0e2291da61b72a64099596f3423d1 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Tue, 13 Jan 2026 14:20:26 +0100 Subject: [PATCH 04/13] feat: implement br-o-02 (omit vat ids for out of scope) --- edocument/edocument/profiles/peppol/generator.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index f511d6e..0259993 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -76,6 +76,16 @@ def _get_document_type(self) -> str: document_type = DOCUMENT_TYPE_MAPPING.get(invoice_type_code, "Invoice") return document_type + def _has_out_of_scope_items(self) -> bool: + """Check if any invoice line has VAT category 'O' (Out of Scope). + + Per PEPPOL BR-O-02: invoices with O items shall not contain VAT identifiers. + """ + for item in self.invoice.items: + if self.get_vat_category_code(self.invoice, item=item) == "O": + return True + return False + def create_einvoice(self): # Create the PEPPOL XML document try: @@ -226,7 +236,8 @@ def _set_seller(self): else: country_code.text = "DE" - if self.invoice.company_tax_id: + # BR-O-02: Omit VAT identifiers if any item is Out of Scope + if self.invoice.company_tax_id and not self._has_out_of_scope_items(): tax_scheme = ET.SubElement(party, f"{{{self.namespaces['cac']}}}PartyTaxScheme") company_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}CompanyID") company_id.text = self.invoice.company_tax_id @@ -310,7 +321,8 @@ def _set_buyer(self): else: country_code.text = "DE" - if self.invoice.tax_id: + # BR-O-02: Omit VAT identifiers if any item is Out of Scope + if self.invoice.tax_id and not self._has_out_of_scope_items(): tax_scheme = ET.SubElement(party, f"{{{self.namespaces['cac']}}}PartyTaxScheme") company_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}CompanyID") company_id.text = self.invoice.tax_id From 869366c8471e6ff389ad116a35932d71cee3c5f2 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Tue, 13 Jan 2026 14:22:21 +0100 Subject: [PATCH 05/13] fix: implement br-o-05/br-e-05 for line items --- edocument/edocument/profiles/peppol/generator.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index 0259993..db7b879 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -392,12 +392,15 @@ def _add_line_item(self, root: ET.Element, item): tax_category = ET.SubElement(item_elem, f"{{{self.namespaces['cac']}}}ClassifiedTaxCategory") + category_code = self.get_vat_category_code(self.invoice, item=item) category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = self.get_vat_category_code(self.invoice, item=item) + category_id.text = category_code - item_tax_rate = self._get_item_tax_rate(item) - tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") - tax_percent.text = str(flt(item_tax_rate or 0, 2)) + # BR-O-05: O lines shall not contain VAT rate; BR-E-05: E lines require rate = 0 + if category_code != "O": + item_tax_rate = self._get_item_tax_rate(item) if category_code not in ("E",) else 0 + tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") + tax_percent.text = str(flt(item_tax_rate or 0, 2)) tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme") scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID") From 20ecb7cdaad05869f71743ac9c731f9348502666 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Tue, 13 Jan 2026 14:31:20 +0100 Subject: [PATCH 06/13] chore: cleanup commentsand redundant code --- edocument/edocument/profiles/peppol/generator.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index db7b879..df6cee1 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -454,25 +454,12 @@ def _add_tax_totals(self): taxable_amount.text = str(flt(data["taxable_amount"], 2)) taxable_amount.set("currencyID", self.invoice.currency) - # Use tax_amount_after_discount_amount directly (already correctly calculated by ERPNext) tax_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxAmount") tax_amount.text = str(flt(data["tax_amount"], 2)) tax_amount.set("currencyID", self.invoice.currency) tax_category = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cac']}}}TaxCategory") - # Get category code dynamically from first item with this rate - category_code = None - sample_item = None - for item in self.invoice.items: - if self._get_item_tax_rate(item) == rate: - sample_item = item - category_code = self.get_vat_category_code(self.invoice, item=item) - break - - if not category_code: - category_code = "S" # Fallback to standard - category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") category_id.text = category_code @@ -481,7 +468,6 @@ def _add_tax_totals(self): tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") tax_percent.text = str(flt(rate or 0, 2)) - # Add exemption reason text if category requires it (E, AE, G, O, K) if category_code in ["E", "AE", "G", "O", "K"]: exemption_text = self._get_exemption_reason_text(category_code) if exemption_text: From d9d76c067e301c8a7357115f4403175eb4307b64 Mon Sep 17 00:00:00 2001 From: Antoine Maas Date: Tue, 13 Jan 2026 14:56:11 +0100 Subject: [PATCH 07/13] feat: update readme to document feature --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 1e5e338..dcfb5b7 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,16 @@ Then, you can map a **Common Code** from **Code List** "UNTDID.4461", e.g. "Cred Please note that the e-document standard only supports one payment means per invoice, so you should not specify multiple **Modes of Payment** in the same invoice. +### VAT Exempt and Out of Scope Items + +Most invoices use standard VAT rates (S) or zero-rated VAT (Z, automatically detected for 0% rates). For special cases: + +- **VAT Exempt (E)**: Create a **Tax Category** (e.g., "VAT Exempt") and map it to code "E" in **Common Code**. Use this tax category on invoices with exempt items like books, education, or healthcare. + +- **Out of Scope (O)**: Create a **Tax Category** (e.g., "Out of Scope") and map it to code "O" in **Common Code**. Use for non-business transactions or items not subject to VAT. When any invoice line uses "O", VAT identifiers are automatically omitted from the entire invoice per PEPPOL rules. + +For item-specific tax treatment, map codes to **Item**, **Item Tax Template** or **Account** instead of **Tax Category**. + ## How to Guide ### Master Data Configuration From d11b6eb5c77adb5d985d5fb43b16c720d85c45e1 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Thu, 22 Jan 2026 21:28:04 +0100 Subject: [PATCH 08/13] feat: add intra-community invoice support --- README.md | 20 +++++ .../edocument/profiles/peppol/__init__.py | 21 +++++ .../edocument/profiles/peppol/generator.py | 83 +++++++++++++++++-- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dcfb5b7..5bb69ff 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,26 @@ Most invoices use standard VAT rates (S) or zero-rated VAT (Z, automatically det For item-specific tax treatment, map codes to **Item**, **Item Tax Template** or **Account** instead of **Tax Category**. +### Intra-Community (IC) Invoices + +For sales to businesses in other EU countries with 0% VAT (intra-community supply): + +1. **Setup**: Create a **Tax Category** (e.g., "Intra-Community") and map it to code "K" in **Common Code**. + +2. **Automatic Handling**: When any invoice line uses category "K", the app automatically: + - Adds `TaxExemptionReasonCode` with value `VATEX-EU-IC` + - Adds `TaxExemptionReason` with text "Intra-community supply" + - Adds `Delivery` element with: + - `ActualDeliveryDate` (uses delivery date or posting date) + - Delivery country code (from shipping address or customer address) + +3. **PEPPOL Rules Satisfied**: + - **BR-IC-10**: Exemption reason code/text for IC supply + - **BR-IC-11**: Actual delivery date is required + - **BR-IC-12**: Delivery country code is required + +**Example**: A Dutch company sells goods to a German company. Set the Tax Category to "Intra-Community" (mapped to "K"). The e-document will include 0% VAT with IC exemption reason and delivery details proving goods were delivered to Germany. + ## How to Guide ### Master Data Configuration diff --git a/edocument/edocument/profiles/peppol/__init__.py b/edocument/edocument/profiles/peppol/__init__.py index 41cab5b..2331a2e 100644 --- a/edocument/edocument/profiles/peppol/__init__.py +++ b/edocument/edocument/profiles/peppol/__init__.py @@ -63,6 +63,27 @@ }, } +# VATEX Exemption Reason Codes +# Used for TaxExemptionReasonCode element in TaxCategory +# Reference: https://ec.europa.eu/digital-building-blocks/wikis/display/DIGITAL/Code+lists +VATEX_CODES = { + "K": "VATEX-EU-IC", # Intra-community supply + "AE": "VATEX-EU-AE", # Reverse charge + "O": "VATEX-EU-O", # Not subject to VAT + "E": "VATEX-EU-132", # Exempt from VAT (generic exemption) + "G": "VATEX-EU-G", # Export outside the EU +} + +# VATEX Exemption Reason Texts +# Used for TaxExemptionReason element in TaxCategory +VATEX_REASON_TEXTS = { + "K": "Intra-community supply", + "AE": "Reverse charge", + "O": "Not subject to VAT", + "E": "Exempt from VAT", + "G": "Export outside the EU", +} + # Global code retrievers for PEPPOL standardized codes (shared across generator and import) from edocument.edocument.common_codes import CommonCodeRetriever diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index df6cee1..4b8435e 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -19,6 +19,8 @@ PEPPOL_CUSTOMIZATION_ID, PEPPOL_PROFILE_ID, UBL_NAMESPACES, + VATEX_CODES, + VATEX_REASON_TEXTS, duty_tax_fee_category_codes, payment_means_codes, uom_codes, @@ -86,6 +88,19 @@ def _has_out_of_scope_items(self) -> bool: return True return False + def _is_intra_community_invoice(self) -> bool: + """Check if the invoice is an intra-community supply. + + An invoice is intra-community if any item has VAT category K. + + Returns: + bool: True if this is an intra-community invoice + """ + for item in self.invoice.items: + if self.get_vat_category_code(self.invoice, item=item) == "K": + return True + return False + def create_einvoice(self): # Create the PEPPOL XML document try: @@ -97,6 +112,7 @@ def create_einvoice(self): self._set_header() self._set_seller() self._set_buyer() + self._add_delivery() self._add_payment_means() self._add_allowances_charges() self._add_tax_totals() @@ -351,6 +367,48 @@ def _set_buyer(self): contact_email = ET.SubElement(contact, f"{{{self.namespaces['cbc']}}}ElectronicMail") contact_email.text = self.invoice.contact_email + def _add_delivery(self): + """Add Delivery element for intra-community and cross-border invoices. + + Required by: + - BR-IC-11: Intra-community supply must have ActualDeliveryDate or InvoicePeriod + - BR-IC-12: Intra-community supply must have Delivery country code + + For IC invoices, adds: + - ActualDeliveryDate (BT-72): Uses posting_date or delivery_date from invoice + - DeliveryLocation/Address/Country (BT-80): Country where goods were delivered + """ + if not hasattr(self, "root") or self.root is None: + return + + # Check if this is an intra-community invoice or has shipping address + is_ic = self._is_intra_community_invoice() + + # Only add Delivery for IC invoices or when shipping address is present + if not is_ic and not self.shipping_address: + return + + delivery = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}Delivery") + + # ActualDeliveryDate (BT-72) - Required for IC invoices (BR-IC-11) + # Use delivery_date if available, otherwise posting_date + delivery_date = getattr(self.invoice, "delivery_date", None) or self.invoice.posting_date + if delivery_date: + actual_delivery_date = ET.SubElement(delivery, f"{{{self.namespaces['cbc']}}}ActualDeliveryDate") + actual_delivery_date.text = self.format_date(delivery_date) + + # DeliveryLocation with Country (BT-80) - Required for IC invoices (BR-IC-12) + # Use shipping address if available, otherwise buyer address + delivery_address = self.shipping_address or self.buyer_address + if delivery_address and delivery_address.country: + delivery_location = ET.SubElement(delivery, f"{{{self.namespaces['cac']}}}DeliveryLocation") + address = ET.SubElement(delivery_location, f"{{{self.namespaces['cac']}}}Address") + country = ET.SubElement(address, f"{{{self.namespaces['cac']}}}Country") + country_code_elem = ET.SubElement(country, f"{{{self.namespaces['cbc']}}}IdentificationCode") + country_code_elem.text = ( + frappe.db.get_value("Country", delivery_address.country, "code") or "DE" + ).upper() + def _add_line_items(self): # Add invoice line items if not hasattr(self, "root") or self.root is None: @@ -469,6 +527,15 @@ def _add_tax_totals(self): tax_percent.text = str(flt(rate or 0, 2)) if category_code in ["E", "AE", "G", "O", "K"]: + # Add TaxExemptionReasonCode (VATEX code) + exemption_code = self._get_exemption_reason_code(category_code) + if exemption_code: + exemption_reason_code = ET.SubElement( + tax_category, f"{{{self.namespaces['cbc']}}}TaxExemptionReasonCode" + ) + exemption_reason_code.text = exemption_code + + # Add TaxExemptionReason (text description) exemption_text = self._get_exemption_reason_text(category_code) if exemption_text: exemption_reason = ET.SubElement( @@ -956,20 +1023,20 @@ def get_vat_category_code(self, invoice, item=None, tax=None) -> str: # Default to S (standard) for everything else return duty_tax_fee_category_codes.default_code or "S" + def _get_exemption_reason_code(self, category_code: str) -> str: + """Get VATEX exemption reason code for PEPPOL validation. + + Returns the standardized VATEX code for non-standard VAT categories. + """ + return VATEX_CODES.get(category_code, "") + def _get_exemption_reason_text(self, category_code: str) -> str: """Get exemption reason text for PEPPOL validation. Categories E, AE, G, O, K require either TaxExemptionReasonCode or TaxExemptionReason per PEPPOL BIS business rules (BR-E-10, BR-AE-10, BR-G-10, BR-O-10, BR-IC-10). """ - texts = { - "E": "Exempt from VAT", - "AE": "Reverse charge", - "G": "Export outside the EU", - "O": "Not subject to VAT", - "K": "Intra-community supply", - } - return texts.get(category_code, "") + return VATEX_REASON_TEXTS.get(category_code, "") def get_xml_bytes(self) -> bytes: # Return the XML as bytes From 0334dc384fcb0f865192f0ee4b424b41382f5f50 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Sat, 24 Jan 2026 14:16:58 +0100 Subject: [PATCH 09/13] feat: Add matching functionality for EDocument XML processing - Introduced new fields in EDocument JSON schema for matching status and data. - Implemented `get_matching_status` and `save_matching_data` methods in EDocument class to handle matching logic. - Updated EDocument Profile JSON to include matcher path configuration. - Enhanced XML parser to accept EDocument instance for accessing matching data. - Developed PEPPOL-specific matcher to extract and match supplier, items, and purchase order data from XML. - Added dialog configuration for manual matching of unmatched entities. - Created basic matcher as a fallback for profiles without specific matching logic. --- README.md | 33 +- .../edocument/doctype/edocument/edocument.js | 263 +++++---- .../doctype/edocument/edocument.json | 24 +- .../edocument/doctype/edocument/edocument.py | 137 ++++- .../edocument_profile/edocument_profile.json | 19 +- edocument/edocument/matcher.py | 85 +++ edocument/edocument/parser.py | 5 +- .../edocument/profiles/peppol/matcher.py | 505 ++++++++++++++++++ edocument/edocument/profiles/peppol/parser.py | 104 +++- edocument/install.py | 1 + 10 files changed, 1027 insertions(+), 149 deletions(-) create mode 100644 edocument/edocument/matcher.py create mode 100644 edocument/edocument/profiles/peppol/matcher.py diff --git a/README.md b/README.md index 5bb69ff..2147be6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ This app provides a flexible framework for generating and parsing electronic doc - **UBL 2.1 XML Generation**: Generate compliant UBL 2.1 XML for invoices and credit notes - **Multiple Document Types**: Support for Invoice and CreditNote - **XML Validation**: XSD schema validation and Schematron business rule validation -- **HTML Preview**: Visual preview of generated XML using XSLT transformations +- **HTML Preview**: Visual preview of generated XML using XSLT transformations (auto-displayed on load) +- **Entity Matching**: Match incoming XML entities (supplier, items, PO) to ERPNext master data with manual fallback - **Code List Management**: Automatic code list handling for PEPPOL standards - **Profile-Based Architecture**: Extensible profile system for different e-document standards - **Import/Export**: Import incoming PEPPOL invoices and export outgoing invoices @@ -178,6 +179,7 @@ The **EDocument Profile** defines the configuration for a specific e-document st - **Validator Path**: Function that validates XML against schemas and business rules - **Preview Path**: Function that converts XML to HTML preview using XSLT - **Detector Path**: Function that auto-detects fields from incoming XML +- **Matcher Path**: Function that matches XML entities (supplier, items, PO) to ERPNext master data **Sales Invoice Settings**: - **EDocument generation on Save**: Automatically create EDocument when Sales Invoice is saved (draft) @@ -218,8 +220,12 @@ For **incoming** e-documents (imported XML files): 1. Upload the XML file 2. The app automatically detects the document type and profile 3. The XML is validated against XSD and Schematron rules -4. Click **Preview EDocument** to view the formatted HTML preview of the document -5. Click **Create Document** to parse the XML and create a Purchase Invoice +4. The preview is automatically displayed when opening the EDocument +5. Click **Match Document** to match XML entities (supplier, items, PO) to ERPNext master data + - If entities are not auto-matched, a dialog allows manual selection + - Matched data is saved for use when creating the document +6. Click **Create Document** to parse the XML and create a Purchase Invoice using matched entities +7. Click **Review and Create Document** to review the parsed data before saving ### Validation Errors @@ -259,15 +265,16 @@ Before using the app, you need to create an **EDocument Profile** that defines w 1. Go to **EDocument Profile** doctype 2. Create a new profile (e.g., "PEPPOL") 3. Set the profile identifier values: - - **Identifier Namespace**: `urn:oasis:names:specification:ubl:schema:xsd:Invoice-2` + - **Identifier Namespace**: `urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2` - **Identifier Element Name**: `CustomizationID` - **Identifier Value**: `urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0` -4. Set the generator, parser, validator, preview, and detector paths: +4. Set the function paths: - **Generator Path**: `edocument.edocument.profiles.peppol.generator.generate_peppol_xml` - **Parser Path**: `edocument.edocument.profiles.peppol.parser.parse_peppol_xml` - **Validator Path**: `edocument.edocument.profiles.peppol.validator.validate_peppol_xml` - **Preview Path**: `edocument.edocument.profiles.peppol.preview.preview_peppol_xml` - **Detector Path**: `edocument.edocument.profiles.peppol.detector.detect_edocument_fields` + - **Matcher Path**: `edocument.edocument.profiles.peppol.matcher.match_peppol_xml` ### Sales Invoice @@ -346,9 +353,10 @@ The app performs comprehensive validation of generated and imported XML: 3. **Schematron Validation**: Validates business rules using PEPPOL Schematron files Validation results are displayed in the **EDocument** record: -- **Status**: Valid/Invalid +- **Status**: Validation Successful / Validation Failed / Matching Successful / Matching Failed - **Error**: Any validation errors - **Warnings**: Any validation warnings +- **Matching Summary**: Summary of entity matching results ### External Validation @@ -364,6 +372,8 @@ The app follows a modular, profile-based architecture: - **`parser.py`**: Parses UBL 2.1 XML and creates ERPNext documents - **`validator.py`**: XSD and Schematron validation - **`preview.py`**: XSLT-based HTML preview generation +- **`detector.py`**: Auto-detects EDocument fields from incoming XML +- **`matcher.py`**: Matches XML entities to ERPNext master data - **`profiles/`**: Profile-specific implementations (PEPPOL, etc.) ### Profile System @@ -379,6 +389,7 @@ Each profile defines: - Validator function (validates XML) - Preview function (converts XML to HTML using XSLT) - Detector function (auto-detects fields from incoming XML) +- Matcher function (matches XML entities to ERPNext master data) - Profile identifier (for automatic detection) ### Automatic Field Detection @@ -389,6 +400,16 @@ For incoming e-documents, the app automatically detects and populates fields usi - **Country**: Detected from seller's postal address country code - **Target Document Type**: Detected from XML root element (Invoice → Purchase Invoice, CreditNote → Purchase Invoice) +### Entity Matching + +For incoming e-documents, the app matches XML entities to ERPNext master data using the profile's matcher: + +- **Supplier**: Auto-matched by name, tax ID, or electronic address +- **Items**: Auto-matched by buyer item ID or seller product ID via Item Supplier table +- **Purchase Order**: Auto-matched by OrderReference or BuyerReference + +When auto-matching fails, users can manually select the correct entities via a matching dialog. The matched data is stored in the EDocument and used when creating the target document. + ## Contributing This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 4839988..489901c 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -3,114 +3,165 @@ frappe.ui.form.on("EDocument", { refresh(frm) { - // Generate XML button - for outgoing documents with source - if (frm.doc.edocument_source_document && frm.doc.edocument_profile) { - frm.add_custom_button( - __("Generate XML"), - () => { - frm.call({ - method: "generate_xml", - doc: frm.doc, - freeze: true, - freeze_message: __("Generating XML..."), - callback: () => frm.reload_doc(), - }); - }, - __("Actions") - ); - } + setup_action_buttons(frm); + }, +}); - // XML-dependent buttons - check for XML files first - if (frm.doc.edocument_profile) { +function setup_action_buttons(frm) { + // Generate XML - for outgoing documents + if (frm.doc.edocument_source_document && frm.doc.edocument_profile) { + frm.add_custom_button(__("Generate XML"), () => { frm.call({ - method: "_has_xml_file", + method: "generate_xml", doc: frm.doc, - callback: (r) => { - if (r.message || frm.doc.xml_file) { - // Preview EDocument button - only when XML exists - frm.add_custom_button( - __("Preview EDocument"), - () => { - frm.call({ - method: "generate_preview", - doc: frm.doc, - freeze: true, - freeze_message: __("Generating preview..."), - callback: (r) => { - if (r.message) { - frm.get_field("edocument_preview")?.set_value( - r.message - ); - frm.get_field("edocument_preview") - ?.$wrapper?.css({ - width: "100%", - "max-width": "100%", - "overflow-x": "auto", - padding: "15px", - "background-color": "#fff", - border: "1px solid #e0e0e0", - "border-radius": "4px", - "margin-top": "10px", - }) - .find("> div") - .css({ width: "100%", "max-width": "100%" }); - } - }, - error: (r) => - frappe.msgprint( - __("Error generating preview: {0}", [r.message]) - ), - }); - }, - __("Actions") - ); - - // Validate XML button - frm.add_custom_button( - __("Validate XML"), - () => { - frm.call({ - method: "validate_xml", - doc: frm.doc, - freeze: true, - freeze_message: __("Validating XML..."), - callback: () => frm.reload_doc(), - }); - }, - __("Actions") - ); - - // Create Document buttons - frm.add_custom_button( - __("Create & Review Document"), - () => { - frappe.model.open_mapped_doc({ - method: "edocument.edocument.doctype.edocument.edocument.create_document", - frm: frm, - freeze_message: __("Parsing XML and preparing document..."), - }); - }, - __("Actions") - ); - - frm.add_custom_button( - __("Create Document"), - () => { - frm.call({ - method: "create_and_save_document", - doc: frm.doc, - freeze: true, - freeze_message: __("Creating document from XML..."), - callback: (r) => { - if (r.message) frm.reload_doc(); - }, - }); - }, - __("Actions") - ); - } - }, + freeze: true, + freeze_message: __("Generating XML..."), + callback: () => frm.reload_doc(), + }); + }, __("Actions")); + } + + // XML-dependent buttons + if (!frm.doc.edocument_profile) return; + + frm.call({ + method: "_has_xml_file", + doc: frm.doc, + callback: (r) => { + if (!r.message && !frm.doc.xml_file) return; + + frm.add_custom_button(__("Preview EDocument"), () => show_preview(frm), __("Actions")); + frm.add_custom_button(__("Validate XML"), () => validate_xml(frm), __("Actions")); + frm.add_custom_button(__("Match Document"), () => match_document(frm), __("Actions")); + frm.add_custom_button(__("Create Document"), () => create_document(frm), __("Actions")); + frm.add_custom_button(__("Review and Create Document"), () => review_and_create(frm), __("Actions")); + + // Auto-load preview on form load + show_preview(frm); + }, + }); +} + +function show_preview(frm) { + frm.call({ + method: "generate_preview", + doc: frm.doc, + freeze: true, + freeze_message: __("Generating preview..."), + callback: (r) => { + if (!r.message) return; + frm.get_field("edocument_preview")?.set_value(r.message); + frm.get_field("edocument_preview")?.$wrapper?.css({ + width: "100%", + padding: "15px", + background: "#fff", + border: "1px solid #e0e0e0", + borderRadius: "4px", + marginTop: "10px", }); + }, + }); +} + +function validate_xml(frm) { + frm.call({ + method: "validate_xml", + doc: frm.doc, + freeze: true, + freeze_message: __("Validating XML..."), + callback: () => frm.reload_doc(), + }); +} + +function create_document(frm) { + frm.call({ + method: "create_and_save_document", + doc: frm.doc, + freeze: true, + freeze_message: __("Creating document from XML..."), + callback: (r) => { if (r.message) frm.reload_doc(); }, + }); +} + +function match_document(frm) { + frm.call({ + method: "get_matching_status", + doc: frm.doc, + freeze: true, + freeze_message: __("Checking matching status..."), + callback: (r) => { + if (!r.message?.has_matcher) { + frappe.msgprint(__("No matcher configured for this profile.")); + return; + } + show_matching_dialog(frm, r.message.matching_data, r.message.dialog_config); + }, + }); +} + +function review_and_create(frm) { + frappe.model.open_mapped_doc({ + method: "edocument.edocument.doctype.edocument.edocument.create_document", + frm: frm, + freeze_message: __("Parsing XML and preparing document..."), + }); +} + +function show_matching_dialog(frm, matching_data, config) { + const dialog = new frappe.ui.Dialog({ + title: config.title, + size: "large", + fields: config.fields, + primary_action_label: __("Save"), + primary_action: () => save_matching(frm, dialog, matching_data), + }); + dialog.show(); +} + +function save_matching(frm, dialog, original_data) { + const data = JSON.parse(JSON.stringify(original_data)); + const values = dialog.get_values(); + + // Update supplier + if (data.supplier) { + data.supplier.matched = values.supplier || null; + if (values.supplier && values.supplier !== original_data.supplier?.matched) { + data.supplier.match_method = "manual"; } - }, -}); + } + + // Update items from table + const items_table = values.items || []; + for (let i = 0; i < (data.items || []).length; i++) { + const table_row = items_table[i]; + const original_matched = original_data.items?.[i]?.matched; + if (table_row) { + data.items[i].matched = table_row.matched_item || null; + if (table_row.matched_item && table_row.matched_item !== original_matched) { + data.items[i].match_method = "manual"; + } + } + } + + // Update purchase order + if (data.purchase_order) { + data.purchase_order.matched = values.purchase_order || null; + if (values.purchase_order && values.purchase_order !== original_data.purchase_order?.matched) { + data.purchase_order.match_method = "manual"; + } + } + + frm.call({ + method: "save_matching_data", + doc: frm.doc, + args: { matching_data: data }, + freeze: true, + freeze_message: __("Saving..."), + callback: (r) => { + if (r.message?.success) { + dialog.hide(); + frm.reload_doc(); + } + }, + }); +} diff --git a/edocument/edocument/doctype/edocument/edocument.json b/edocument/edocument/doctype/edocument/edocument.json index 1569628..c1e59e4 100644 --- a/edocument/edocument/doctype/edocument/edocument.json +++ b/edocument/edocument/doctype/edocument/edocument.json @@ -21,6 +21,9 @@ "xml_file", "edocument_target_type", "edocument_target_document", + "matching_section", + "matching_summary", + "matching_data", "error_section", "error", "preview_section", @@ -62,7 +65,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "\nValidation Successful\nValidation Failed\nTransmission Successful\nTransmission Failed" + "options": "\nValidation Successful\nValidation Failed\nMatching Successful\nMatching Failed\nTransmission Successful\nTransmission Failed" }, { "fieldname": "country", @@ -142,6 +145,25 @@ "fieldname": "reference", "fieldtype": "Data", "label": "Reference" + }, + { + "collapsible": 1, + "fieldname": "matching_section", + "fieldtype": "Section Break", + "label": "Matching" + }, + { + "fieldname": "matching_summary", + "fieldtype": "Long Text", + "label": "Matching Summary", + "read_only": 1 + }, + { + "fieldname": "matching_data", + "fieldtype": "JSON", + "hidden": 1, + "label": "Matching Data", + "read_only": 1 } ], "grid_page_length": 50, diff --git a/edocument/edocument/doctype/edocument/edocument.py b/edocument/edocument/doctype/edocument/edocument.py index 02d1997..c76fd6b 100644 --- a/edocument/edocument/doctype/edocument/edocument.py +++ b/edocument/edocument/doctype/edocument/edocument.py @@ -400,6 +400,139 @@ def generate_preview(self) -> str: return get_xml_preview(xml_bytes, self.edocument_profile) + @frappe.whitelist() + def get_matching_status(self) -> dict: + """ + Get matching status for this EDocument using profile-specific matcher. + + Returns: + dict: Matching status with structure: + { + "has_matcher": True/False, + "is_matched": True/False, + "matching_data": {...}, + "dialog_config": {...}, + "matching_summary": "..." + } + """ + if not self.edocument_profile: + return {"has_matcher": False, "is_matched": False} + + try: + edocument_profile = frappe.get_doc("EDocument Profile", self.edocument_profile) + except Exception: + return {"has_matcher": False, "is_matched": False} + + # Get XML bytes + try: + xml_bytes = self._get_xml_from_attached_files() + except Exception: + return {"has_matcher": True, "is_matched": False, "error": "No XML file found"} + + try: + # Import and call the matcher + from edocument.edocument.matcher import get_xml_matcher + + result = get_xml_matcher(xml_bytes, edocument_profile, self) + + # Add has_matcher flag (True if profile has matcher_path, False if using fallback) + result["has_matcher"] = bool(edocument_profile.matcher_path) + + return result + except Exception as e: + frappe.log_error( + f"Error in get_matching_status for {self.name}: {e!s}", + "EDocument Matcher Error", + ) + return {"has_matcher": True, "is_matched": False, "error": str(e)} + + @frappe.whitelist() + def save_matching_data(self, matching_data: str | dict) -> dict: + """ + Save matching data for this EDocument. + + This method: + 1. Saves matching_data to the edocument + 2. Re-runs matching to determine is_matched status + 3. Updates status to "Matching Successful" or "Matching Failed" + 4. Generates and saves matching_summary + + Args: + matching_data: Matching data as JSON string or dict + + Returns: + dict: Result with success status + """ + import json + + if not self.edocument_profile: + frappe.throw(_("EDocument Profile is required for matching.")) + + edocument_profile = frappe.get_doc("EDocument Profile", self.edocument_profile) + + if not edocument_profile.matcher_path: + frappe.throw(_("No matcher configured for profile {0}.").format(self.edocument_profile)) + + # Parse matching_data if it's a string + if isinstance(matching_data, str): + matching_data = json.loads(matching_data) + + # Get XML bytes + xml_bytes = self._get_xml_from_attached_files() + + # Save matching_data first so the matcher can use it + frappe.db.set_value( + "EDocument", + self.name, + "matching_data", + json.dumps(matching_data, default=str), + update_modified=False, + ) + + # Reload to get the updated matching_data + self.reload() + + # Re-run matching to determine final status + try: + from edocument.edocument.matcher import get_xml_matcher + + result = get_xml_matcher(xml_bytes, edocument_profile, self) + + if result is None: + frappe.throw(_("Matcher returned no result.")) + + is_matched = result.get("is_matched", False) + matching_summary = result.get("matching_summary", "") + + # Translate is_matched to status (like validator does with is_valid) + status = "Matching Successful" if is_matched else "Matching Failed" + + # Update status and summary using db.set_value to avoid before_save hook + frappe.db.set_value( + "EDocument", + self.name, + { + "status": status, + "matching_summary": matching_summary, + }, + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "edocument_name": self.name, + "is_matched": is_matched, + "status": status, + } + except Exception as e: + frappe.log_error( + f"Error in save_matching_data for {self.name}: {e!s}", + "EDocument Matcher Error", + ) + frappe.throw(_("Error saving matching data: {0}").format(str(e))) + def _get_xml_from_attached_files(self) -> bytes: """ Get XML bytes from the most recently attached XML file. @@ -508,8 +641,8 @@ def create_document(source_name, target_doc=None): # Import and call the profile-specific parser from edocument.edocument.parser import get_xml_parser - # Parse XML using profile-specific parser - document_data = get_xml_parser(xml_bytes, edocument_profile) + # Parse XML using profile-specific parser (pass edocument for matching_data access) + document_data = get_xml_parser(xml_bytes, edocument_profile, edocument=edocument) # Validate that parser returned a dict with doctype if not isinstance(document_data, dict): diff --git a/edocument/edocument/doctype/edocument_profile/edocument_profile.json b/edocument/edocument/doctype/edocument_profile/edocument_profile.json index bfc75b8..6e7b815 100644 --- a/edocument/edocument/doctype/edocument_profile/edocument_profile.json +++ b/edocument/edocument/doctype/edocument_profile/edocument_profile.json @@ -16,9 +16,10 @@ "schema_files_section", "generator_path", "validator_path", - "parser_path", "preview_path", - "detector_path" + "detector_path", + "matcher_path", + "parser_path" ], "fields": [ { @@ -54,31 +55,41 @@ "label": "Schema files" }, { + "description": "Path to function that will generate the xml from source document.", "fieldname": "generator_path", "fieldtype": "Data", "label": "Generator Path" }, { + "description": "Path to function that will validation xml against the authorities standard.", "fieldname": "validator_path", "fieldtype": "Data", "label": "Validator Path" }, { + "description": "Path to function that will translate the xml to ERPNext target document.", "fieldname": "parser_path", "fieldtype": "Data", "label": "Parser Path" }, { + "description": "Path to function that will render the xml as html preview.", "fieldname": "preview_path", "fieldtype": "Data", "label": "Preview Path" }, { - "description": "Path to function that detects EDocument field values from incoming XML (e.g., company, supplier). Function signature: detect_edocument_fields(xml_bytes) -> dict", + "description": "Path to function that detects EDocument field values from incoming XML (e.g., company, supplier, profile etc.)", "fieldname": "detector_path", "fieldtype": "Data", "label": "Detector Path" }, + { + "description": "Path to function to match incoming xml messages to ERPNext document.", + "fieldname": "matcher_path", + "fieldtype": "Data", + "label": "Matcher Path" + }, { "default": "0", "fieldname": "edocument_generation_on_save", @@ -101,7 +112,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-12-24 08:00:17.647010", + "modified": "2026-01-24 13:35:41.069975", "modified_by": "Administrator", "module": "Edocument", "name": "EDocument Profile", diff --git a/edocument/edocument/matcher.py b/edocument/edocument/matcher.py new file mode 100644 index 0000000..0231033 --- /dev/null +++ b/edocument/edocument/matcher.py @@ -0,0 +1,85 @@ +# Copyright (c) 2025, Prilk Consulting BV and contributors +# For license information, please see license.txt + +""" +Matcher module for EDocument XML matching. +This module routes to profile-specific matchers based on the EDocument Profile. + +Profile matchers should implement a function with signature: + match_xxx_xml(xml_bytes, edocument_profile, edocument=None) -> dict + +The function should return: +{ + "is_matched": True/False, + "matching_data": {...}, + "dialog_config": {...}, + "matching_summary": "..." +} + +Example matcher_path: edocument.edocument.profiles.peppol.matcher.match_peppol_xml +""" + +import frappe +from frappe import _ + + +def get_xml_matcher(xml_bytes, edocument_profile, edocument=None): + """ + Get XML matcher result based on the profile. + + Similar to get_xml_validator in validator.py. + + Args: + xml_bytes: Raw XML content as bytes + edocument_profile: EDocument Profile document + edocument: EDocument document (optional, for existing matching_data) + + Returns: + dict: Matching result with is_matched, matching_data, dialog_config, matching_summary + """ + # Try to get matcher from profile's matcher_path if specified + if edocument_profile.matcher_path: + try: + matcher_func = frappe.get_attr(edocument_profile.matcher_path) + return matcher_func(xml_bytes, edocument_profile, edocument) + except Exception as e: + frappe.log_error( + f"Error loading matcher from path {edocument_profile.matcher_path}: {e!s}", + "EDocument Matcher Error", + ) + + # Default: Use basic matcher (no matching needed) + return match_basic_xml(xml_bytes, edocument_profile) + + +def match_basic_xml(xml_bytes, edocument_profile): + """ + Basic XML matcher (placeholder implementation). + + Returns a result indicating no matching is needed - all entities are + considered matched by default. Profiles that need matching should + implement their own matcher. + + Args: + xml_bytes: Raw XML content as bytes + edocument_profile: EDocument Profile document + + Returns: + dict: Matching result with is_matched=True + """ + return { + "is_matched": True, + "matching_data": {}, + "dialog_config": { + "title": _("Match Document"), + "fields": [ + { + "fieldtype": "HTML", + "fieldname": "info", + "options": _("No matching configuration for this profile. All entities are considered matched."), + } + ], + "primary_action_label": _("OK"), + }, + "matching_summary": _("No matching required for this profile."), + } diff --git a/edocument/edocument/parser.py b/edocument/edocument/parser.py index d1b576a..82f07da 100644 --- a/edocument/edocument/parser.py +++ b/edocument/edocument/parser.py @@ -12,7 +12,7 @@ from frappe import _ -def get_xml_parser(xml_bytes, edocument_profile): +def get_xml_parser(xml_bytes, edocument_profile, edocument=None): """ Get XML parser based on the profile. @@ -23,6 +23,7 @@ def get_xml_parser(xml_bytes, edocument_profile): Args: xml_bytes: The XML content as bytes edocument_profile: The EDocument Profile document + edocument: Optional EDocument instance (for accessing matching_data) Returns: dict: Document data dictionary with 'doctype' field ready to be used with frappe.get_doc() @@ -31,7 +32,7 @@ def get_xml_parser(xml_bytes, edocument_profile): if hasattr(edocument_profile, "parser_path") and edocument_profile.parser_path: try: parser_func = frappe.get_attr(edocument_profile.parser_path) - return parser_func(xml_bytes, edocument_profile) + return parser_func(xml_bytes, edocument_profile, edocument=edocument) except Exception as e: frappe.log_error(f"Error loading parser from path {edocument_profile.parser_path}: {e!s}") # Fall through to basic parser if loading fails diff --git a/edocument/edocument/profiles/peppol/matcher.py b/edocument/edocument/profiles/peppol/matcher.py new file mode 100644 index 0000000..862ff37 --- /dev/null +++ b/edocument/edocument/profiles/peppol/matcher.py @@ -0,0 +1,505 @@ +# Copyright (c) 2025, Prilk Consulting BV and contributors +# For license information, please see license.txt + +""" +PEPPOL Matcher Module + +This module provides matching functionality for PEPPOL BIS Billing 3.0 invoices. +It extracts supplier, items, and purchase order data from XML and attempts to +match them against ERPNext master data. + +If mismatches are found, a matching dialog is shown for manual mapping. + +Entry point: match_peppol_xml(xml_bytes, edocument_profile, edocument=None) -> dict +Returns: {"is_matched": bool, "matching_data": dict, "dialog_config": dict, "matching_summary": str} + +matcher_path: edocument.edocument.profiles.peppol.matcher.match_peppol_xml +""" + +import json + +import frappe +from frappe import _ +from lxml import etree as ET + +from edocument.edocument.profiles.peppol import ( + DOCUMENT_TYPE_ELEMENTS, + UBL_NAMESPACES, +) +from edocument.edocument.validator import validate_xml_structure + + +def get_xml_text(element, xpath, namespaces=None) -> str | None: + """Helper function to extract text from XML element.""" + if element is None: + return None + result = element.find(xpath, namespaces or {}) + return result.text if result is not None and result.text else None + + +def _detect_document_type_from_xml(root) -> str: + """Detect UBL document type from XML root element.""" + try: + if root.tag.endswith("}CreditNote"): + return "CreditNote" + elif root.tag.endswith("}DebitNote"): + return "DebitNote" + elif root.tag.endswith("}Invoice"): + return "Invoice" + except Exception: + pass + return "Invoice" + + +def extract_matching_candidates(xml_bytes: bytes) -> dict: + """ + Parse PEPPOL XML and extract supplier, items, and PO data for matching. + + Args: + xml_bytes: The XML content as bytes + + Returns: + dict: Extracted candidates + """ + # Validate and parse XML + xml_bytes = validate_xml_structure(xml_bytes) + root = ET.fromstring(xml_bytes) + + # Detect document type + document_type = _detect_document_type_from_xml(root) + document_elements = DOCUMENT_TYPE_ELEMENTS.get(document_type, DOCUMENT_TYPE_ELEMENTS["Invoice"]) + + namespaces = UBL_NAMESPACES + + candidates = { + "supplier": {}, + "items": [], + "purchase_order": {}, + } + + # Extract supplier information + seller_party = root.find(".//cac:AccountingSupplierParty/cac:Party", namespaces) + if seller_party is not None: + seller_name = get_xml_text( + seller_party, ".//cac:PartyLegalEntity/cbc:RegistrationName", namespaces + ) or get_xml_text(seller_party, ".//cac:PartyName/cbc:Name", namespaces) + + seller_tax_id = get_xml_text(seller_party, ".//cac:PartyTaxScheme/cbc:CompanyID", namespaces) + + endpoint_elem = seller_party.find(".//cbc:EndpointID", namespaces) + electronic_address = endpoint_elem.text if endpoint_elem is not None and endpoint_elem.text else None + electronic_address_scheme = endpoint_elem.get("schemeID") if endpoint_elem is not None else None + + candidates["supplier"] = { + "xml_name": seller_name, + "xml_tax_id": seller_tax_id, + "xml_electronic_address": electronic_address, + "xml_electronic_address_scheme": electronic_address_scheme, + } + + # Extract line items + line_elem_name = document_elements["line"] + quantity_elem_name = document_elements["quantity"] + + for idx, invoice_line in enumerate(root.findall(f".//cac:{line_elem_name}", namespaces)): + item_candidate = {"line_index": idx} + + product_name = get_xml_text(invoice_line, ".//cac:Item/cbc:Name", namespaces) + item_candidate["xml_name"] = product_name + + seller_product_id = get_xml_text( + invoice_line, ".//cac:Item/cac:SellersItemIdentification/cbc:ID", namespaces + ) + buyer_item_id = get_xml_text( + invoice_line, ".//cac:Item/cac:BuyersItemIdentification/cbc:ID", namespaces + ) + + item_candidate["xml_seller_id"] = seller_product_id + item_candidate["xml_buyer_id"] = buyer_item_id + + qty_elem = invoice_line.find(f".//cbc:{quantity_elem_name}", namespaces) + if qty_elem is not None and qty_elem.text: + try: + item_candidate["qty"] = float(qty_elem.text) + except (ValueError, TypeError): + item_candidate["qty"] = None + item_candidate["uom"] = qty_elem.get("unitCode") + else: + item_candidate["qty"] = None + item_candidate["uom"] = None + + candidates["items"].append(item_candidate) + + # Extract purchase order reference + order_reference = get_xml_text(root, ".//cac:OrderReference/cbc:ID", namespaces) + buyer_reference = get_xml_text(root, ".//cbc:BuyerReference", namespaces) + + candidates["purchase_order"] = { + "xml_order_reference": order_reference, + "xml_buyer_reference": buyer_reference, + } + + return candidates + + +def auto_match_entities(candidates: dict, existing_matching_data: dict | None = None) -> dict: + """ + Attempt automatic matching of extracted candidates against ERPNext master data. + + Args: + candidates: Extracted candidates from extract_matching_candidates() + existing_matching_data: Previously saved matching data (if any) + + Returns: + dict: Matching results + """ + existing_matching_data = existing_matching_data or {} + result = { + "supplier": dict(candidates.get("supplier", {})), + "items": [dict(item) for item in candidates.get("items", [])], + "purchase_order": dict(candidates.get("purchase_order", {})), + } + + # --- Match Supplier --- + supplier_data = result["supplier"] + existing_supplier = existing_matching_data.get("supplier", {}) + + if existing_supplier.get("matched") and existing_supplier.get("match_method") == "manual": + if frappe.db.exists("Supplier", existing_supplier["matched"]): + supplier_data["matched"] = existing_supplier["matched"] + supplier_data["match_method"] = "manual" + else: + supplier_data["matched"] = None + supplier_data["match_method"] = None + else: + matched_supplier = None + match_method = None + + if supplier_data.get("xml_name") and frappe.db.exists("Supplier", supplier_data["xml_name"]): + matched_supplier = supplier_data["xml_name"] + match_method = "name" + + if not matched_supplier and supplier_data.get("xml_tax_id"): + matched_supplier = frappe.db.get_value( + "Supplier", {"tax_id": supplier_data["xml_tax_id"]}, "name" + ) + if matched_supplier: + match_method = "tax_id" + + if not matched_supplier and supplier_data.get("xml_electronic_address"): + matched_supplier = frappe.db.get_value( + "Supplier", + {"edocument_electronic_address": supplier_data["xml_electronic_address"]}, + "name", + ) + if matched_supplier: + match_method = "electronic_address" + + supplier_data["matched"] = matched_supplier + supplier_data["match_method"] = match_method + + # --- Match Items --- + existing_items = {item.get("line_index"): item for item in existing_matching_data.get("items", [])} + matched_supplier = supplier_data.get("matched") + + for item in result["items"]: + line_index = item["line_index"] + existing_item = existing_items.get(line_index, {}) + + if existing_item.get("matched") and existing_item.get("match_method") == "manual": + if frappe.db.exists("Item", existing_item["matched"]): + item["matched"] = existing_item["matched"] + item["match_method"] = "manual" + else: + item["matched"] = None + item["match_method"] = None + else: + matched_item = None + match_method = None + + if item.get("xml_buyer_id") and frappe.db.exists("Item", item["xml_buyer_id"]): + matched_item = item["xml_buyer_id"] + match_method = "buyer_id" + + if not matched_item and item.get("xml_seller_id") and matched_supplier: + matched_item = frappe.db.get_value( + "Item Supplier", + {"supplier": matched_supplier, "supplier_part_no": item["xml_seller_id"]}, + "parent", + ) + if matched_item: + match_method = "seller_id" + + item["matched"] = matched_item + item["match_method"] = match_method + + # --- Match Purchase Order --- + po_data = result["purchase_order"] + existing_po = existing_matching_data.get("purchase_order", {}) + + if existing_po.get("matched") and existing_po.get("match_method") == "manual": + if frappe.db.exists("Purchase Order", existing_po["matched"]): + po_data["matched"] = existing_po["matched"] + po_data["match_method"] = "manual" + else: + po_data["matched"] = None + po_data["match_method"] = None + else: + matched_po = None + match_method = None + + if po_data.get("xml_order_reference") and frappe.db.exists( + "Purchase Order", po_data["xml_order_reference"] + ): + matched_po = po_data["xml_order_reference"] + match_method = "order_reference" + + if not matched_po and po_data.get("xml_buyer_reference") and frappe.db.exists( + "Purchase Order", po_data["xml_buyer_reference"] + ): + matched_po = po_data["xml_buyer_reference"] + match_method = "buyer_reference" + + po_data["matched"] = matched_po + po_data["match_method"] = match_method + + return result + + +def match_peppol_xml(xml_bytes, edocument_profile, edocument=None) -> dict: + """ + Match PEPPOL XML entities against ERPNext master data. + + This is the main interface method called by the base matcher. + Follows the same pattern as validate_peppol_xml. + + Args: + xml_bytes: Raw XML content as bytes + edocument_profile: EDocument Profile document + edocument: EDocument document (optional, for existing matching_data) + + Returns: + dict: { + "is_matched": True/False, + "matching_data": {...}, + "dialog_config": {...}, + "matching_summary": "..." + } + """ + # Get existing matching data if any + existing_matching_data = None + if edocument and edocument.matching_data: + try: + existing_matching_data = json.loads(edocument.matching_data) + except (json.JSONDecodeError, TypeError): + pass + + # Extract candidates from XML + candidates = extract_matching_candidates(xml_bytes) + + # Attempt auto-matching + matching_data = auto_match_entities(candidates, existing_matching_data) + + # Check if all entities are matched + supplier_matched = bool(matching_data["supplier"].get("matched")) + items = matching_data.get("items", []) + all_items_matched = all(item.get("matched") for item in items) if items else True + + # PO is required if XML contains a PO reference + po_data = matching_data.get("purchase_order", {}) + po_ref_in_xml = po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") + po_matched = bool(po_data.get("matched")) if po_ref_in_xml else True + + # Determine if fully matched + is_matched = supplier_matched and all_items_matched and po_matched + + # Generate dialog config and summary + dialog_config = _get_dialog_config(matching_data) + matching_summary = _generate_matching_summary(matching_data) + + return { + "is_matched": is_matched, + "matching_data": matching_data, + "dialog_config": dialog_config, + "matching_summary": matching_summary, + } + + +def _generate_matching_summary(matching_data: dict) -> str: + """Generate plain text summary of matching status.""" + supplier_data = matching_data.get("supplier", {}) + items_data = matching_data.get("items", []) + po_data = matching_data.get("purchase_order", {}) + + lines = [] + + # Supplier summary + supplier_xml = supplier_data.get("xml_name") or "-" + supplier_matched = supplier_data.get("matched") + supplier_method = supplier_data.get("match_method") or "" + if supplier_matched: + supplier_status = f"✓ {supplier_matched}" + if supplier_method: + supplier_status += f" ({supplier_method})" + else: + supplier_status = "✗ Not matched" + + lines.append(f"{_('Supplier')}: {supplier_xml} → {supplier_status}") + + # Items summary + if items_data: + matched_count = sum(1 for item in items_data if item.get("matched")) + total_count = len(items_data) + lines.append(f"\n{_('Items')}: {matched_count}/{total_count} {_('matched')}") + + for item in items_data: + xml_name = item.get("xml_name") or "-" + matched = item.get("matched") + method = item.get("match_method") or "" + if matched: + status = f"✓ {matched}" + if method: + status += f" ({method})" + else: + status = "✗ Not matched" + lines.append(f" • {xml_name} → {status}") + + # Purchase Order summary (optional) + po_ref = po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") + if po_ref: + po_matched = po_data.get("matched") + if po_matched: + po_status = f"✓ {po_matched}" + else: + po_status = "- Not linked" + lines.append(f"\n{_('Purchase Order')}: {po_ref} → {po_status}") + + return "\n".join(lines) + + +def _get_dialog_config(matching_data: dict) -> dict: + """ + Get Frappe dialog configuration for the matching dialog. + + Returns complete Frappe-compatible field definitions that can be + passed directly to frappe.ui.Dialog. + + Args: + matching_data: Current matching data + + Returns: + dict: Dialog configuration with title, fields, and validation rules + """ + supplier_data = matching_data.get("supplier", {}) + items_data = matching_data.get("items", []) + po_data = matching_data.get("purchase_order", {}) + + fields = [] + + # --- Supplier Section --- + fields.append({"fieldtype": "Section Break", "label": _("Supplier")}) + + fields.append({ + "fieldtype": "Data", + "fieldname": "xml_supplier_name", + "label": _("XML Supplier Name"), + "default": supplier_data.get("xml_name") or "-", + "read_only": 1, + }) + fields.append({"fieldtype": "Column Break"}) + fields.append({ + "fieldtype": "Data", + "fieldname": "xml_supplier_tax_id", + "label": _("XML Tax ID"), + "default": supplier_data.get("xml_tax_id") or "-", + "read_only": 1, + }) + fields.append({"fieldtype": "Section Break"}) + fields.append({ + "fieldtype": "Link", + "fieldname": "supplier", + "label": _("Match to Supplier"), + "options": "Supplier", + "reqd": 1, + "default": supplier_data.get("matched"), + }) + + # --- Items Section as Table --- + if items_data: + fields.append({"fieldtype": "Section Break", "label": _("Line Items")}) + + # Build table data + table_data = [] + for item in items_data: + idx = item.get("line_index", 0) + xml_name = item.get("xml_name") or "-" + xml_seller_id = item.get("xml_seller_id") or "-" + qty = item.get("qty") or "-" + uom = item.get("uom") or "" + matched = item.get("matched") + + table_data.append({ + "line_no": idx + 1, + "xml_product": xml_name, + "seller_id": xml_seller_id, + "qty": f"{qty} {uom}".strip(), + "matched_item": matched, + }) + + fields.append({ + "fieldtype": "Table", + "fieldname": "items", + "label": _("Items"), + "cannot_add_rows": True, + "cannot_delete_rows": True, + "in_place_edit": True, + "data": table_data, + "fields": [ + {"fieldtype": "Int", "fieldname": "line_no", "label": "#", "in_list_view": 1, "read_only": 1, "columns": 1}, + {"fieldtype": "Data", "fieldname": "xml_product", "label": _("XML Product"), "in_list_view": 1, "read_only": 1, "columns": 3}, + {"fieldtype": "Data", "fieldname": "seller_id", "label": _("Seller ID"), "in_list_view": 1, "read_only": 1, "columns": 2}, + {"fieldtype": "Data", "fieldname": "qty", "label": _("Qty"), "in_list_view": 1, "read_only": 1, "columns": 1}, + {"fieldtype": "Link", "fieldname": "matched_item", "label": _("Match to Item"), "options": "Item", "in_list_view": 1, "reqd": 1, "columns": 3}, + ], + }) + + # --- Purchase Order Section --- + po_ref = po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") + if po_ref: + fields.append({"fieldtype": "Section Break", "label": _("Purchase Order")}) + + fields.append({ + "fieldtype": "Data", + "fieldname": "xml_po_reference", + "label": _("XML Order Reference"), + "default": po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") or "-", + "read_only": 1, + }) + fields.append({"fieldtype": "Section Break"}) + fields.append({ + "fieldtype": "Link", + "fieldname": "purchase_order", + "label": _("Match to Purchase Order"), + "options": "Purchase Order", + "reqd": 1, + "default": po_data.get("matched"), + }) + + # --- Summary Section --- + fields.append({"fieldtype": "Section Break", "label": _("Summary")}) + summary = _generate_matching_summary(matching_data) + fields.append({ + "fieldtype": "Long Text", + "fieldname": "matching_summary", + "label": _("Matching Status"), + "default": summary, + "read_only": 1, + }) + + return { + "title": _("Match Invoice Data"), + "fields": fields, + "primary_action_label": _("Save"), + } + + diff --git a/edocument/edocument/profiles/peppol/parser.py b/edocument/edocument/profiles/peppol/parser.py index 092493b..5f22142 100644 --- a/edocument/edocument/profiles/peppol/parser.py +++ b/edocument/edocument/profiles/peppol/parser.py @@ -73,7 +73,7 @@ def _detect_document_type_from_xml(root) -> str: return "Invoice" -def parse_peppol_xml(xml_bytes, edocument_profile): +def parse_peppol_xml(xml_bytes, edocument_profile, edocument=None): """ Parse PEPPOL UBL 2.1 XML and return Purchase Invoice dict structure. Supports Invoice, CreditNote, and DebitNote documents. @@ -81,10 +81,13 @@ def parse_peppol_xml(xml_bytes, edocument_profile): Args: xml_bytes: The XML content as bytes edocument_profile: The EDocument Profile document + edocument: Optional EDocument instance (for accessing matching_data) Returns: dict: Purchase Invoice data dictionary with 'doctype' field ready to be used with frappe.get_doc() """ + import json + # Validate XML structure first try: xml_bytes = validate_xml_structure(xml_bytes) @@ -92,6 +95,14 @@ def parse_peppol_xml(xml_bytes, edocument_profile): except ValueError as e: frappe.throw(_("The uploaded file does not contain valid XML data: {0}").format(str(e))) + # Get matching data if available + matching_data = None + if edocument and edocument.matching_data: + try: + matching_data = json.loads(edocument.matching_data) + except (json.JSONDecodeError, TypeError): + pass + # Detect document type (Invoice, CreditNote, DebitNote) document_type = _detect_document_type_from_xml(root) document_elements = DOCUMENT_TYPE_ELEMENTS.get(document_type, DOCUMENT_TYPE_ELEMENTS["Invoice"]) @@ -131,8 +142,12 @@ def parse_peppol_xml(xml_bytes, edocument_profile): pi_data["due_date"] = due_date pi_data["currency"] = currency - # Parse seller (supplier) information - seller_data = parse_peppol_seller(root, namespaces) + # Parse seller (supplier) information - use matching_data if available + matched_supplier = None + if matching_data and matching_data.get("supplier", {}).get("matched"): + matched_supplier = matching_data["supplier"]["matched"] + + seller_data = parse_peppol_seller(root, namespaces, matched_supplier=matched_supplier) pi_data["supplier"] = seller_data.get("supplier") pi_data["supplier_name"] = seller_data.get("name") @@ -140,19 +155,38 @@ def parse_peppol_xml(xml_bytes, edocument_profile): buyer_data = parse_peppol_buyer(root, namespaces) pi_data["company"] = buyer_data.get("company") or get_default_company() - # Check for Purchase Order from OrderReference or BuyerReference - # Both can contain the PO number - order_reference = get_xml_text(root, ".//cac:OrderReference/cbc:ID", namespaces) - buyer_reference = get_xml_text(root, ".//cbc:BuyerReference", namespaces) - - # Use OrderReference first, fallback to BuyerReference - po_reference = order_reference or buyer_reference - if po_reference and frappe.db.exists("Purchase Order", po_reference): - pi_data["purchase_order"] = po_reference + # Check for Purchase Order - use matching_data if available + matched_po = None + if matching_data and matching_data.get("purchase_order", {}).get("matched"): + matched_po = matching_data["purchase_order"]["matched"] + + if matched_po: + pi_data["purchase_order"] = matched_po + else: + # Fallback to auto-detect from OrderReference or BuyerReference + order_reference = get_xml_text(root, ".//cac:OrderReference/cbc:ID", namespaces) + buyer_reference = get_xml_text(root, ".//cbc:BuyerReference", namespaces) + + # Use OrderReference first, fallback to BuyerReference + po_reference = order_reference or buyer_reference + if po_reference and frappe.db.exists("Purchase Order", po_reference): + pi_data["purchase_order"] = po_reference + + # Build matched items lookup from matching_data + matched_items = {} + if matching_data and matching_data.get("items"): + for item in matching_data["items"]: + if item.get("matched"): + matched_items[item["line_index"]] = item["matched"] # Parse line items (pass document_elements for generic parsing) pi_data["items"] = parse_peppol_line_items( - root, namespaces, pi_data.get("purchase_order"), pi_data.get("supplier"), document_elements + root, + namespaces, + pi_data.get("purchase_order"), + pi_data.get("supplier"), + document_elements, + matched_items=matched_items, ) # Parse taxes @@ -184,11 +218,18 @@ def parse_peppol_xml(xml_bytes, edocument_profile): raise -def parse_peppol_seller(root, namespaces): - """Parse seller (supplier) information from PEPPOL XML.""" +def parse_peppol_seller(root, namespaces, matched_supplier=None): + """ + Parse seller (supplier) information from PEPPOL XML. + + Args: + root: XML root element + namespaces: Namespace dictionary + matched_supplier: Pre-matched supplier from matching_data (takes precedence) + """ seller_party = root.find(".//cac:AccountingSupplierParty/cac:Party", namespaces) if seller_party is None: - return {"supplier": None, "name": None} + return {"supplier": matched_supplier, "name": None} # Seller name seller_name = get_xml_text( @@ -198,12 +239,13 @@ def parse_peppol_seller(root, namespaces): # Seller tax ID seller_tax_id = get_xml_text(seller_party, ".//cac:PartyTaxScheme/cbc:CompanyID", namespaces) - # Try to find or create supplier - supplier = None - if seller_name and frappe.db.exists("Supplier", seller_name): - supplier = seller_name - elif seller_tax_id: - supplier = frappe.db.get_value("Supplier", {"tax_id": seller_tax_id}, "name") + # Use matched supplier if provided, otherwise try to find + supplier = matched_supplier + if not supplier: + if seller_name and frappe.db.exists("Supplier", seller_name): + supplier = seller_name + elif seller_tax_id: + supplier = frappe.db.get_value("Supplier", {"tax_id": seller_tax_id}, "name") return {"supplier": supplier, "name": seller_name, "tax_id": seller_tax_id} @@ -227,7 +269,9 @@ def parse_peppol_buyer(root, namespaces): return {"company": company, "name": buyer_name} -def parse_peppol_line_items(root, namespaces, purchase_order=None, supplier=None, document_elements=None): +def parse_peppol_line_items( + root, namespaces, purchase_order=None, supplier=None, document_elements=None, matched_items=None +): """ Parse line items from PEPPOL XML. Supports InvoiceLine, CreditNoteLine, and DebitNoteLine. @@ -238,6 +282,7 @@ def parse_peppol_line_items(root, namespaces, purchase_order=None, supplier=None purchase_order: Optional purchase order for item matching supplier: Optional supplier for item matching document_elements: Document type element names (from DOCUMENT_TYPE_ELEMENTS) + matched_items: Dict mapping line_index to matched item_code from matching_data Returns: list: List of item dictionaries @@ -245,13 +290,14 @@ def parse_peppol_line_items(root, namespaces, purchase_order=None, supplier=None if document_elements is None: document_elements = DOCUMENT_TYPE_ELEMENTS["Invoice"] + matched_items = matched_items or {} items = [] # Use document-specific line element name (InvoiceLine, CreditNoteLine, DebitNoteLine) line_elem_name = document_elements["line"] quantity_elem_name = document_elements["quantity"] - for invoice_line in root.findall(f".//cac:{line_elem_name}", namespaces): + for idx, invoice_line in enumerate(root.findall(f".//cac:{line_elem_name}", namespaces)): item = {"doctype": "Purchase Invoice Item"} # Product name/description @@ -280,10 +326,12 @@ def parse_peppol_line_items(root, namespaces, purchase_order=None, supplier=None if seller_product_id: item["seller_product_id"] = seller_product_id - # Try to find item by buyer item ID - item_code = None - if buyer_item_id and frappe.db.exists("Item", buyer_item_id): - item_code = buyer_item_id + # Use matched item from matching_data if available + item_code = matched_items.get(idx) + if not item_code: + # Try to find item by buyer item ID + if buyer_item_id and frappe.db.exists("Item", buyer_item_id): + item_code = buyer_item_id item["item_code"] = item_code diff --git a/edocument/install.py b/edocument/install.py index 1e96319..726559a 100644 --- a/edocument/install.py +++ b/edocument/install.py @@ -185,6 +185,7 @@ def create_peppol_profile(): "validator_path": "edocument.edocument.profiles.peppol.validator.validate_peppol_xml", "preview_path": "edocument.edocument.profiles.peppol.preview.preview_peppol_xml", "detector_path": "edocument.edocument.profiles.peppol.detector.detect_edocument_fields", + "matcher_path": "edocument.edocument.profiles.peppol.matcher.match_peppol_xml", "validate_sales_invoice_on_save": 0, "validate_sales_invoice_on_submit": 0, "action_on_validation_error_during_save": 0, From d46b3a800706dbf90ff3b4de9609377a1c070add Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Mon, 26 Jan 2026 13:52:56 +0100 Subject: [PATCH 10/13] fis: Enhance line item rate calculation by incorporating BaseQuantity in parser --- edocument/edocument/profiles/peppol/parser.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/edocument/edocument/profiles/peppol/parser.py b/edocument/edocument/profiles/peppol/parser.py index 5f22142..4a01769 100644 --- a/edocument/edocument/profiles/peppol/parser.py +++ b/edocument/edocument/profiles/peppol/parser.py @@ -346,15 +346,15 @@ def parse_peppol_line_items( # Price and rate calculation price_text = get_xml_text(invoice_line, ".//cac:Price/cbc:PriceAmount", namespaces) + base_qty_text = get_xml_text(invoice_line, ".//cac:Price/cbc:BaseQuantity", namespaces) line_total_text = get_xml_text(invoice_line, ".//cbc:LineExtensionAmount", namespaces) - if price_text and item.get("qty"): - # Calculate rate from price and quantity + if price_text: + # PriceAmount is the unit price, BaseQuantity is what that price applies to (default 1) + # rate = PriceAmount / BaseQuantity net_rate = float(price_text) - basis_qty = float(item["qty"]) or 1.0 - item["rate"] = net_rate / basis_qty - elif price_text: - item["rate"] = flt_or_none(price_text) + base_qty = float(base_qty_text) if base_qty_text else 1.0 + item["rate"] = net_rate / base_qty if base_qty else net_rate # Line total if line_total_text: From 4a97a219daf0d9120ed2af88a8ec086edaedb5d5 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Mon, 26 Jan 2026 14:01:25 +0100 Subject: [PATCH 11/13] fix: linting error fixes --- .../edocument/doctype/edocument/edocument.js | 43 ++-- edocument/edocument/matcher.py | 4 +- .../edocument/profiles/peppol/matcher.py | 196 +++++++++++------- .../rules/generate_xsl_manual.py | 124 +++++------ 4 files changed, 219 insertions(+), 148 deletions(-) diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 489901c..965509f 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -10,15 +10,19 @@ frappe.ui.form.on("EDocument", { function setup_action_buttons(frm) { // Generate XML - for outgoing documents if (frm.doc.edocument_source_document && frm.doc.edocument_profile) { - frm.add_custom_button(__("Generate XML"), () => { - frm.call({ - method: "generate_xml", - doc: frm.doc, - freeze: true, - freeze_message: __("Generating XML..."), - callback: () => frm.reload_doc(), - }); - }, __("Actions")); + frm.add_custom_button( + __("Generate XML"), + () => { + frm.call({ + method: "generate_xml", + doc: frm.doc, + freeze: true, + freeze_message: __("Generating XML..."), + callback: () => frm.reload_doc(), + }); + }, + __("Actions") + ); } // XML-dependent buttons @@ -33,8 +37,16 @@ function setup_action_buttons(frm) { frm.add_custom_button(__("Preview EDocument"), () => show_preview(frm), __("Actions")); frm.add_custom_button(__("Validate XML"), () => validate_xml(frm), __("Actions")); frm.add_custom_button(__("Match Document"), () => match_document(frm), __("Actions")); - frm.add_custom_button(__("Create Document"), () => create_document(frm), __("Actions")); - frm.add_custom_button(__("Review and Create Document"), () => review_and_create(frm), __("Actions")); + frm.add_custom_button( + __("Create Document"), + () => create_document(frm), + __("Actions") + ); + frm.add_custom_button( + __("Review and Create Document"), + () => review_and_create(frm), + __("Actions") + ); // Auto-load preview on form load show_preview(frm); @@ -79,7 +91,9 @@ function create_document(frm) { doc: frm.doc, freeze: true, freeze_message: __("Creating document from XML..."), - callback: (r) => { if (r.message) frm.reload_doc(); }, + callback: (r) => { + if (r.message) frm.reload_doc(); + }, }); } @@ -146,7 +160,10 @@ function save_matching(frm, dialog, original_data) { // Update purchase order if (data.purchase_order) { data.purchase_order.matched = values.purchase_order || null; - if (values.purchase_order && values.purchase_order !== original_data.purchase_order?.matched) { + if ( + values.purchase_order && + values.purchase_order !== original_data.purchase_order?.matched + ) { data.purchase_order.match_method = "manual"; } } diff --git a/edocument/edocument/matcher.py b/edocument/edocument/matcher.py index 0231033..f3f80fc 100644 --- a/edocument/edocument/matcher.py +++ b/edocument/edocument/matcher.py @@ -76,7 +76,9 @@ def match_basic_xml(xml_bytes, edocument_profile): { "fieldtype": "HTML", "fieldname": "info", - "options": _("No matching configuration for this profile. All entities are considered matched."), + "options": _( + "No matching configuration for this profile. All entities are considered matched." + ), } ], "primary_action_label": _("OK"), diff --git a/edocument/edocument/profiles/peppol/matcher.py b/edocument/edocument/profiles/peppol/matcher.py index 862ff37..1cef604 100644 --- a/edocument/edocument/profiles/peppol/matcher.py +++ b/edocument/edocument/profiles/peppol/matcher.py @@ -254,8 +254,10 @@ def auto_match_entities(candidates: dict, existing_matching_data: dict | None = matched_po = po_data["xml_order_reference"] match_method = "order_reference" - if not matched_po and po_data.get("xml_buyer_reference") and frappe.db.exists( - "Purchase Order", po_data["xml_buyer_reference"] + if ( + not matched_po + and po_data.get("xml_buyer_reference") + and frappe.db.exists("Purchase Order", po_data["xml_buyer_reference"]) ): matched_po = po_data["xml_buyer_reference"] match_method = "buyer_reference" @@ -399,30 +401,36 @@ def _get_dialog_config(matching_data: dict) -> dict: # --- Supplier Section --- fields.append({"fieldtype": "Section Break", "label": _("Supplier")}) - fields.append({ - "fieldtype": "Data", - "fieldname": "xml_supplier_name", - "label": _("XML Supplier Name"), - "default": supplier_data.get("xml_name") or "-", - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Data", + "fieldname": "xml_supplier_name", + "label": _("XML Supplier Name"), + "default": supplier_data.get("xml_name") or "-", + "read_only": 1, + } + ) fields.append({"fieldtype": "Column Break"}) - fields.append({ - "fieldtype": "Data", - "fieldname": "xml_supplier_tax_id", - "label": _("XML Tax ID"), - "default": supplier_data.get("xml_tax_id") or "-", - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Data", + "fieldname": "xml_supplier_tax_id", + "label": _("XML Tax ID"), + "default": supplier_data.get("xml_tax_id") or "-", + "read_only": 1, + } + ) fields.append({"fieldtype": "Section Break"}) - fields.append({ - "fieldtype": "Link", - "fieldname": "supplier", - "label": _("Match to Supplier"), - "options": "Supplier", - "reqd": 1, - "default": supplier_data.get("matched"), - }) + fields.append( + { + "fieldtype": "Link", + "fieldname": "supplier", + "label": _("Match to Supplier"), + "options": "Supplier", + "reqd": 1, + "default": supplier_data.get("matched"), + } + ) # --- Items Section as Table --- if items_data: @@ -438,68 +446,112 @@ def _get_dialog_config(matching_data: dict) -> dict: uom = item.get("uom") or "" matched = item.get("matched") - table_data.append({ - "line_no": idx + 1, - "xml_product": xml_name, - "seller_id": xml_seller_id, - "qty": f"{qty} {uom}".strip(), - "matched_item": matched, - }) - - fields.append({ - "fieldtype": "Table", - "fieldname": "items", - "label": _("Items"), - "cannot_add_rows": True, - "cannot_delete_rows": True, - "in_place_edit": True, - "data": table_data, - "fields": [ - {"fieldtype": "Int", "fieldname": "line_no", "label": "#", "in_list_view": 1, "read_only": 1, "columns": 1}, - {"fieldtype": "Data", "fieldname": "xml_product", "label": _("XML Product"), "in_list_view": 1, "read_only": 1, "columns": 3}, - {"fieldtype": "Data", "fieldname": "seller_id", "label": _("Seller ID"), "in_list_view": 1, "read_only": 1, "columns": 2}, - {"fieldtype": "Data", "fieldname": "qty", "label": _("Qty"), "in_list_view": 1, "read_only": 1, "columns": 1}, - {"fieldtype": "Link", "fieldname": "matched_item", "label": _("Match to Item"), "options": "Item", "in_list_view": 1, "reqd": 1, "columns": 3}, - ], - }) + table_data.append( + { + "line_no": idx + 1, + "xml_product": xml_name, + "seller_id": xml_seller_id, + "qty": f"{qty} {uom}".strip(), + "matched_item": matched, + } + ) + + fields.append( + { + "fieldtype": "Table", + "fieldname": "items", + "label": _("Items"), + "cannot_add_rows": True, + "cannot_delete_rows": True, + "in_place_edit": True, + "data": table_data, + "fields": [ + { + "fieldtype": "Int", + "fieldname": "line_no", + "label": "#", + "in_list_view": 1, + "read_only": 1, + "columns": 1, + }, + { + "fieldtype": "Data", + "fieldname": "xml_product", + "label": _("XML Product"), + "in_list_view": 1, + "read_only": 1, + "columns": 3, + }, + { + "fieldtype": "Data", + "fieldname": "seller_id", + "label": _("Seller ID"), + "in_list_view": 1, + "read_only": 1, + "columns": 2, + }, + { + "fieldtype": "Data", + "fieldname": "qty", + "label": _("Qty"), + "in_list_view": 1, + "read_only": 1, + "columns": 1, + }, + { + "fieldtype": "Link", + "fieldname": "matched_item", + "label": _("Match to Item"), + "options": "Item", + "in_list_view": 1, + "reqd": 1, + "columns": 3, + }, + ], + } + ) # --- Purchase Order Section --- po_ref = po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") if po_ref: fields.append({"fieldtype": "Section Break", "label": _("Purchase Order")}) - fields.append({ - "fieldtype": "Data", - "fieldname": "xml_po_reference", - "label": _("XML Order Reference"), - "default": po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") or "-", - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Data", + "fieldname": "xml_po_reference", + "label": _("XML Order Reference"), + "default": po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") or "-", + "read_only": 1, + } + ) fields.append({"fieldtype": "Section Break"}) - fields.append({ - "fieldtype": "Link", - "fieldname": "purchase_order", - "label": _("Match to Purchase Order"), - "options": "Purchase Order", - "reqd": 1, - "default": po_data.get("matched"), - }) + fields.append( + { + "fieldtype": "Link", + "fieldname": "purchase_order", + "label": _("Match to Purchase Order"), + "options": "Purchase Order", + "reqd": 1, + "default": po_data.get("matched"), + } + ) # --- Summary Section --- fields.append({"fieldtype": "Section Break", "label": _("Summary")}) summary = _generate_matching_summary(matching_data) - fields.append({ - "fieldtype": "Long Text", - "fieldname": "matching_summary", - "label": _("Matching Status"), - "default": summary, - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Long Text", + "fieldname": "matching_summary", + "label": _("Matching Status"), + "default": summary, + "read_only": 1, + } + ) return { "title": _("Match Invoice Data"), "fields": fields, "primary_action_label": _("Save"), } - - diff --git a/edocument/edocument/profiles/peppol/peppol-bis-invoice-3/rules/generate_xsl_manual.py b/edocument/edocument/profiles/peppol/peppol-bis-invoice-3/rules/generate_xsl_manual.py index 6df947a..9514842 100644 --- a/edocument/edocument/profiles/peppol/peppol-bis-invoice-3/rules/generate_xsl_manual.py +++ b/edocument/edocument/profiles/peppol/peppol-bis-invoice-3/rules/generate_xsl_manual.py @@ -12,28 +12,28 @@ # Download schxslt pipeline pipeline_dir = Path("pipeline") if not pipeline_dir.exists(): - pipeline_dir.mkdir() - print("Downloading schxslt pipeline...") + pipeline_dir.mkdir() + print("Downloading schxslt pipeline...") - zip_path = pipeline_dir / "schxslt-pipeline.zip" - url = "https://github.com/schxslt/schxslt/releases/download/v1.10.1/schxslt-1.10.1-xslt-only.zip" + zip_path = pipeline_dir / "schxslt-pipeline.zip" + url = "https://github.com/schxslt/schxslt/releases/download/v1.10.1/schxslt-1.10.1-xslt-only.zip" - with urllib.request.urlopen(url) as response: - with open(zip_path, 'wb') as f: - f.write(response.read()) + with urllib.request.urlopen(url) as response: + with open(zip_path, "wb") as f: + f.write(response.read()) - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - zip_ref.extractall(pipeline_dir) + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(pipeline_dir) pipeline_file = pipeline_dir / "schxslt-1.10.1" / "2.0" / "pipeline-for-svrl.xsl" print(f"Pipeline file: {pipeline_file}") # Schematron files to compile SCHEMATRON_FILES = [ - "sch/CEN-EN16931-UBL.sch", - "sch/PEPPOL-EN16931-UBL.sch", - "sch/CEN-EN16931-CII.sch", - "sch/PEPPOL-EN16931-CII.sch", + "sch/CEN-EN16931-UBL.sch", + "sch/PEPPOL-EN16931-UBL.sch", + "sch/CEN-EN16931-CII.sch", + "sch/PEPPOL-EN16931-CII.sch", ] SCHEMATRON_PIPELINE = str(pipeline_file) @@ -42,69 +42,69 @@ print(f"Pipeline: {SCHEMATRON_PIPELINE}") try: - from saxonche import PySaxonProcessor + from saxonche import PySaxonProcessor - with PySaxonProcessor(license=False) as proc: - xslt30_processor = proc.new_xslt30_processor() - xslt30_processor.set_cwd(".") + with PySaxonProcessor(license=False) as proc: + xslt30_processor = proc.new_xslt30_processor() + xslt30_processor.set_cwd(".") - for sch_file in SCHEMATRON_FILES: - xsl_file = sch_file[:-4] + ".xsl" # Remove .sch and add .xsl - print(f"Converting {sch_file} -> {xsl_file}") + for sch_file in SCHEMATRON_FILES: + xsl_file = sch_file[:-4] + ".xsl" # Remove .sch and add .xsl + print(f"Converting {sch_file} -> {xsl_file}") - xslt30_processor.transform_to_file( - source_file=sch_file, - stylesheet_file=SCHEMATRON_PIPELINE, - output_file=xsl_file - ) + xslt30_processor.transform_to_file( + source_file=sch_file, stylesheet_file=SCHEMATRON_PIPELINE, output_file=xsl_file + ) - if os.path.exists(xsl_file): - size = os.path.getsize(xsl_file) - print(f"✅ Generated {xsl_file} ({size} bytes)") - else: - print(f"❌ Failed to generate {xsl_file}") + if os.path.exists(xsl_file): + size = os.path.getsize(xsl_file) + print(f"✅ Generated {xsl_file} ({size} bytes)") + else: + print(f"❌ Failed to generate {xsl_file}") - # Create combined UBL validation - print("\nCreating combined CEN+PEPPOL UBL schematron...") + # Create combined UBL validation + print("\nCreating combined CEN+PEPPOL UBL schematron...") - with open("sch/CEN-EN16931-UBL.sch") as f: - cen_content = f.read() + with open("sch/CEN-EN16931-UBL.sch") as f: + cen_content = f.read() - with open("sch/PEPPOL-EN16931-UBL.sch") as f: - peppol_content = f.read() + with open("sch/PEPPOL-EN16931-UBL.sch") as f: + peppol_content = f.read() - # Simple combination - peppol_lines = peppol_content.split('\n') - peppol_start = next((i for i, line in enumerate(peppol_lines) if '', '') + # Simple combination + peppol_lines = peppol_content.split("\n") + peppol_start = next((i for i, line in enumerate(peppol_lines) if "", "") - combined_content = cen_content.replace('', f'\n\n{peppol_extensions}\n') + combined_content = cen_content.replace( + "", f"\n\n{peppol_extensions}\n" + ) - with open("PEPPOL-combined-UBL.sch", 'w') as f: - f.write(combined_content) + with open("PEPPOL-combined-UBL.sch", "w") as f: + f.write(combined_content) - print("Converting combined schematron to XSL...") - with PySaxonProcessor(license=False) as proc: - xslt30_processor = proc.new_xslt30_processor() - xslt30_processor.set_cwd(".") + print("Converting combined schematron to XSL...") + with PySaxonProcessor(license=False) as proc: + xslt30_processor = proc.new_xslt30_processor() + xslt30_processor.set_cwd(".") - xslt30_processor.transform_to_file( - source_file="PEPPOL-combined-UBL.sch", - stylesheet_file=SCHEMATRON_PIPELINE, - output_file="PEPPOL-UBL-validation.xsl" - ) + xslt30_processor.transform_to_file( + source_file="PEPPOL-combined-UBL.sch", + stylesheet_file=SCHEMATRON_PIPELINE, + output_file="PEPPOL-UBL-validation.xsl", + ) - if os.path.exists("PEPPOL-UBL-validation.xsl"): - size = os.path.getsize("PEPPOL-UBL-validation.xsl") - print(f"✅ Generated PEPPOL-UBL-validation.xsl ({size} bytes)") - else: - print("❌ Failed to generate PEPPOL-UBL-validation.xsl") + if os.path.exists("PEPPOL-UBL-validation.xsl"): + size = os.path.getsize("PEPPOL-UBL-validation.xsl") + print(f"✅ Generated PEPPOL-UBL-validation.xsl ({size} bytes)") + else: + print("❌ Failed to generate PEPPOL-UBL-validation.xsl") - print("XSL generation completed!") + print("XSL generation completed!") except ImportError as e: - print(f"❌ Import error: {e}") - print("Make sure saxonche and schxslt are installed:") - print("pip install saxonche schxslt") + print(f"❌ Import error: {e}") + print("Make sure saxonche and schxslt are installed:") + print("pip install saxonche schxslt") except Exception as e: - print(f"❌ Error: {e}") + print(f"❌ Error: {e}") From 54bc35c795d641312b7ff838f8172c70ff96d5e9 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Mon, 26 Jan 2026 15:23:15 +0100 Subject: [PATCH 12/13] fix : purchase order is non mandatory --- .../edocument/profiles/peppol/matcher.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/edocument/edocument/profiles/peppol/matcher.py b/edocument/edocument/profiles/peppol/matcher.py index 862ff37..15c5afa 100644 --- a/edocument/edocument/profiles/peppol/matcher.py +++ b/edocument/edocument/profiles/peppol/matcher.py @@ -305,13 +305,11 @@ def match_peppol_xml(xml_bytes, edocument_profile, edocument=None) -> dict: items = matching_data.get("items", []) all_items_matched = all(item.get("matched") for item in items) if items else True - # PO is required if XML contains a PO reference - po_data = matching_data.get("purchase_order", {}) - po_ref_in_xml = po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") - po_matched = bool(po_data.get("matched")) if po_ref_in_xml else True + # PO matching is optional - not required for is_matched + # (PO reference in XML is informational, user can link manually if needed) - # Determine if fully matched - is_matched = supplier_matched and all_items_matched and po_matched + # Determine if fully matched (supplier and items only, PO is optional) + is_matched = supplier_matched and all_items_matched # Generate dialog config and summary dialog_config = _get_dialog_config(matching_data) @@ -476,14 +474,16 @@ def _get_dialog_config(matching_data: dict) -> dict: "read_only": 1, }) fields.append({"fieldtype": "Section Break"}) - fields.append({ - "fieldtype": "Link", - "fieldname": "purchase_order", - "label": _("Match to Purchase Order"), - "options": "Purchase Order", - "reqd": 1, - "default": po_data.get("matched"), - }) + fields.append( + { + "fieldtype": "Link", + "fieldname": "purchase_order", + "label": _("Match to Purchase Order"), + "options": "Purchase Order", + "reqd": 1, + "default": po_data.get("matched"), + } + ) # --- Summary Section --- fields.append({"fieldtype": "Section Break", "label": _("Summary")}) From 381ef85219d2cf22062fc2fe83140d8bdf8b2718 Mon Sep 17 00:00:00 2001 From: Preetam Biswal Date: Mon, 26 Jan 2026 15:28:32 +0100 Subject: [PATCH 13/13] style: apply ruff and prettier formatting --- .../edocument/doctype/edocument/edocument.js | 43 +++-- edocument/edocument/matcher.py | 4 +- .../edocument/profiles/peppol/matcher.py | 178 +++++++++++------- 3 files changed, 147 insertions(+), 78 deletions(-) diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 489901c..965509f 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -10,15 +10,19 @@ frappe.ui.form.on("EDocument", { function setup_action_buttons(frm) { // Generate XML - for outgoing documents if (frm.doc.edocument_source_document && frm.doc.edocument_profile) { - frm.add_custom_button(__("Generate XML"), () => { - frm.call({ - method: "generate_xml", - doc: frm.doc, - freeze: true, - freeze_message: __("Generating XML..."), - callback: () => frm.reload_doc(), - }); - }, __("Actions")); + frm.add_custom_button( + __("Generate XML"), + () => { + frm.call({ + method: "generate_xml", + doc: frm.doc, + freeze: true, + freeze_message: __("Generating XML..."), + callback: () => frm.reload_doc(), + }); + }, + __("Actions") + ); } // XML-dependent buttons @@ -33,8 +37,16 @@ function setup_action_buttons(frm) { frm.add_custom_button(__("Preview EDocument"), () => show_preview(frm), __("Actions")); frm.add_custom_button(__("Validate XML"), () => validate_xml(frm), __("Actions")); frm.add_custom_button(__("Match Document"), () => match_document(frm), __("Actions")); - frm.add_custom_button(__("Create Document"), () => create_document(frm), __("Actions")); - frm.add_custom_button(__("Review and Create Document"), () => review_and_create(frm), __("Actions")); + frm.add_custom_button( + __("Create Document"), + () => create_document(frm), + __("Actions") + ); + frm.add_custom_button( + __("Review and Create Document"), + () => review_and_create(frm), + __("Actions") + ); // Auto-load preview on form load show_preview(frm); @@ -79,7 +91,9 @@ function create_document(frm) { doc: frm.doc, freeze: true, freeze_message: __("Creating document from XML..."), - callback: (r) => { if (r.message) frm.reload_doc(); }, + callback: (r) => { + if (r.message) frm.reload_doc(); + }, }); } @@ -146,7 +160,10 @@ function save_matching(frm, dialog, original_data) { // Update purchase order if (data.purchase_order) { data.purchase_order.matched = values.purchase_order || null; - if (values.purchase_order && values.purchase_order !== original_data.purchase_order?.matched) { + if ( + values.purchase_order && + values.purchase_order !== original_data.purchase_order?.matched + ) { data.purchase_order.match_method = "manual"; } } diff --git a/edocument/edocument/matcher.py b/edocument/edocument/matcher.py index 0231033..f3f80fc 100644 --- a/edocument/edocument/matcher.py +++ b/edocument/edocument/matcher.py @@ -76,7 +76,9 @@ def match_basic_xml(xml_bytes, edocument_profile): { "fieldtype": "HTML", "fieldname": "info", - "options": _("No matching configuration for this profile. All entities are considered matched."), + "options": _( + "No matching configuration for this profile. All entities are considered matched." + ), } ], "primary_action_label": _("OK"), diff --git a/edocument/edocument/profiles/peppol/matcher.py b/edocument/edocument/profiles/peppol/matcher.py index 15c5afa..14ee282 100644 --- a/edocument/edocument/profiles/peppol/matcher.py +++ b/edocument/edocument/profiles/peppol/matcher.py @@ -254,8 +254,10 @@ def auto_match_entities(candidates: dict, existing_matching_data: dict | None = matched_po = po_data["xml_order_reference"] match_method = "order_reference" - if not matched_po and po_data.get("xml_buyer_reference") and frappe.db.exists( - "Purchase Order", po_data["xml_buyer_reference"] + if ( + not matched_po + and po_data.get("xml_buyer_reference") + and frappe.db.exists("Purchase Order", po_data["xml_buyer_reference"]) ): matched_po = po_data["xml_buyer_reference"] match_method = "buyer_reference" @@ -397,30 +399,36 @@ def _get_dialog_config(matching_data: dict) -> dict: # --- Supplier Section --- fields.append({"fieldtype": "Section Break", "label": _("Supplier")}) - fields.append({ - "fieldtype": "Data", - "fieldname": "xml_supplier_name", - "label": _("XML Supplier Name"), - "default": supplier_data.get("xml_name") or "-", - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Data", + "fieldname": "xml_supplier_name", + "label": _("XML Supplier Name"), + "default": supplier_data.get("xml_name") or "-", + "read_only": 1, + } + ) fields.append({"fieldtype": "Column Break"}) - fields.append({ - "fieldtype": "Data", - "fieldname": "xml_supplier_tax_id", - "label": _("XML Tax ID"), - "default": supplier_data.get("xml_tax_id") or "-", - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Data", + "fieldname": "xml_supplier_tax_id", + "label": _("XML Tax ID"), + "default": supplier_data.get("xml_tax_id") or "-", + "read_only": 1, + } + ) fields.append({"fieldtype": "Section Break"}) - fields.append({ - "fieldtype": "Link", - "fieldname": "supplier", - "label": _("Match to Supplier"), - "options": "Supplier", - "reqd": 1, - "default": supplier_data.get("matched"), - }) + fields.append( + { + "fieldtype": "Link", + "fieldname": "supplier", + "label": _("Match to Supplier"), + "options": "Supplier", + "reqd": 1, + "default": supplier_data.get("matched"), + } + ) # --- Items Section as Table --- if items_data: @@ -436,43 +444,85 @@ def _get_dialog_config(matching_data: dict) -> dict: uom = item.get("uom") or "" matched = item.get("matched") - table_data.append({ - "line_no": idx + 1, - "xml_product": xml_name, - "seller_id": xml_seller_id, - "qty": f"{qty} {uom}".strip(), - "matched_item": matched, - }) - - fields.append({ - "fieldtype": "Table", - "fieldname": "items", - "label": _("Items"), - "cannot_add_rows": True, - "cannot_delete_rows": True, - "in_place_edit": True, - "data": table_data, - "fields": [ - {"fieldtype": "Int", "fieldname": "line_no", "label": "#", "in_list_view": 1, "read_only": 1, "columns": 1}, - {"fieldtype": "Data", "fieldname": "xml_product", "label": _("XML Product"), "in_list_view": 1, "read_only": 1, "columns": 3}, - {"fieldtype": "Data", "fieldname": "seller_id", "label": _("Seller ID"), "in_list_view": 1, "read_only": 1, "columns": 2}, - {"fieldtype": "Data", "fieldname": "qty", "label": _("Qty"), "in_list_view": 1, "read_only": 1, "columns": 1}, - {"fieldtype": "Link", "fieldname": "matched_item", "label": _("Match to Item"), "options": "Item", "in_list_view": 1, "reqd": 1, "columns": 3}, - ], - }) + table_data.append( + { + "line_no": idx + 1, + "xml_product": xml_name, + "seller_id": xml_seller_id, + "qty": f"{qty} {uom}".strip(), + "matched_item": matched, + } + ) + + fields.append( + { + "fieldtype": "Table", + "fieldname": "items", + "label": _("Items"), + "cannot_add_rows": True, + "cannot_delete_rows": True, + "in_place_edit": True, + "data": table_data, + "fields": [ + { + "fieldtype": "Int", + "fieldname": "line_no", + "label": "#", + "in_list_view": 1, + "read_only": 1, + "columns": 1, + }, + { + "fieldtype": "Data", + "fieldname": "xml_product", + "label": _("XML Product"), + "in_list_view": 1, + "read_only": 1, + "columns": 3, + }, + { + "fieldtype": "Data", + "fieldname": "seller_id", + "label": _("Seller ID"), + "in_list_view": 1, + "read_only": 1, + "columns": 2, + }, + { + "fieldtype": "Data", + "fieldname": "qty", + "label": _("Qty"), + "in_list_view": 1, + "read_only": 1, + "columns": 1, + }, + { + "fieldtype": "Link", + "fieldname": "matched_item", + "label": _("Match to Item"), + "options": "Item", + "in_list_view": 1, + "reqd": 1, + "columns": 3, + }, + ], + } + ) # --- Purchase Order Section --- po_ref = po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") if po_ref: fields.append({"fieldtype": "Section Break", "label": _("Purchase Order")}) - fields.append({ - "fieldtype": "Data", - "fieldname": "xml_po_reference", - "label": _("XML Order Reference"), - "default": po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") or "-", - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Data", + "fieldname": "xml_po_reference", + "label": _("XML Order Reference"), + "default": po_data.get("xml_order_reference") or po_data.get("xml_buyer_reference") or "-", + "read_only": 1, + } + ) fields.append({"fieldtype": "Section Break"}) fields.append( { @@ -488,18 +538,18 @@ def _get_dialog_config(matching_data: dict) -> dict: # --- Summary Section --- fields.append({"fieldtype": "Section Break", "label": _("Summary")}) summary = _generate_matching_summary(matching_data) - fields.append({ - "fieldtype": "Long Text", - "fieldname": "matching_summary", - "label": _("Matching Status"), - "default": summary, - "read_only": 1, - }) + fields.append( + { + "fieldtype": "Long Text", + "fieldname": "matching_summary", + "label": _("Matching Status"), + "default": summary, + "read_only": 1, + } + ) return { "title": _("Match Invoice Data"), "fields": fields, "primary_action_label": _("Save"), } - -