diff --git a/apt_packages.txt b/apt_packages.txt new file mode 100644 index 0000000..3e83da4 --- /dev/null +++ b/apt_packages.txt @@ -0,0 +1,2 @@ +libxml2-dev +libxslt-dev diff --git a/edocument/edocument/doctype/edocument/edocument.js b/edocument/edocument/doctype/edocument/edocument.js index 965509f..2e6391b 100644 --- a/edocument/edocument/doctype/edocument/edocument.js +++ b/edocument/edocument/doctype/edocument/edocument.js @@ -8,8 +8,10 @@ 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) { + const is_transmitted = frm.doc.status === "Transmission Successful"; + + // Generate XML - for outgoing documents (not already transmitted) + if (frm.doc.edocument_source_document && frm.doc.edocument_profile && !is_transmitted) { frm.add_custom_button( __("Generate XML"), () => { @@ -35,7 +37,11 @@ function setup_action_buttons(frm) { 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")); + + if (!is_transmitted) { + 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"), @@ -62,15 +68,16 @@ function show_preview(frm) { 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", - }); + const field = frm.get_field("edocument_preview"); + if (!field) return; + + // Render preview in an iframe to isolate its CSS from ERPNext + const iframe = ``; + field.set_value(iframe); + field.$wrapper?.css({ width: "100%", marginTop: "10px" }); }, }); } @@ -135,6 +142,7 @@ function show_matching_dialog(frm, matching_data, config) { function save_matching(frm, dialog, original_data) { const data = JSON.parse(JSON.stringify(original_data)); const values = dialog.get_values(); + if (!values) return; // Validation failed // Update supplier if (data.supplier) { diff --git a/edocument/edocument/doctype/edocument/edocument.json b/edocument/edocument/doctype/edocument/edocument.json index c1e59e4..1860faa 100644 --- a/edocument/edocument/doctype/edocument/edocument.json +++ b/edocument/edocument/doctype/edocument/edocument.json @@ -65,7 +65,8 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "\nValidation Successful\nValidation Failed\nMatching Successful\nMatching Failed\nTransmission Successful\nTransmission Failed" + "options": "\nValidation Successful\nValidation Failed\nMatching Successful\nMatching Failed\nTransmission Successful\nTransmission Failed", + "read_only": 1 }, { "fieldname": "country", diff --git a/edocument/edocument/doctype/edocument/edocument.py b/edocument/edocument/doctype/edocument/edocument.py index c76fd6b..aabc50c 100644 --- a/edocument/edocument/doctype/edocument/edocument.py +++ b/edocument/edocument/doctype/edocument/edocument.py @@ -120,6 +120,9 @@ def generate_xml(self): Note: This method only generates XML - validation is handled separately. This is the public method called from the button. """ + if self.status == "Transmission Successful": + frappe.throw(_("Cannot regenerate XML for an already transmitted document.")) + try: file_name = self._generate_xml_internal() # Save the document to persist status and error field changes @@ -219,6 +222,9 @@ def validate_xml(self): Updates the status and error fields based on validation results. This is the public method called from the button. """ + if self.status == "Transmission Successful": + frappe.throw(_("Cannot re-validate an already transmitted document.")) + try: self._validate_xml_internal() # Save the document to persist status and error field changes @@ -271,16 +277,16 @@ def before_save(self): ) # Auto-detect EDocument fields (company, etc.) from XML using profile-specific detector - if self.edocument_profile and has_xml and not self.company: + if self.edocument_profile and has_xml: try: xml_bytes = self._get_xml_from_attached_files() if xml_bytes: from edocument.edocument.detector import get_edocument_fields detected_fields = get_edocument_fields(xml_bytes, self.edocument_profile) - # Set detected fields on the document + # Set detected fields — override defaults from Frappe for field, value in detected_fields.items(): - if hasattr(self, field) and not getattr(self, field): + if hasattr(self, field) and value: setattr(self, field, value) except Exception as e: frappe.log_error( @@ -301,7 +307,17 @@ def before_save(self): f"Error during automatic XML generation for EDocument {self.name}: {error_msg}" ) - if self.edocument_profile: + # Don't overwrite status for documents that reached a terminal state + # (already transmitted or matched) — re-validation would reset status + # back to "Validation Successful" and re-enable the Send button. + terminal_statuses = ( + "Transmission Successful", + "Transmission Failed", + "Matching Successful", + "Matching Failed", + ) + + if self.edocument_profile and self.status not in terminal_statuses: # Validate XML automatically try: self._validate_xml_internal() @@ -347,16 +363,16 @@ def on_update(self): ) # Auto-detect EDocument fields (company, etc.) from XML using profile-specific detector - if self.edocument_profile and has_xml and not self.company: + if self.edocument_profile and has_xml: try: xml_bytes = self._get_xml_from_attached_files() if xml_bytes: from edocument.edocument.detector import get_edocument_fields detected_fields = get_edocument_fields(xml_bytes, self.edocument_profile) - # Update detected fields directly in database + # Update detected fields — override defaults from Frappe for field, value in detected_fields.items(): - if hasattr(self, field) and not getattr(self, field): + if hasattr(self, field) and value: self.db_set(field, value, update_modified=False) except Exception as e: frappe.log_error( diff --git a/edocument/edocument/profiles/peppol/generator.py b/edocument/edocument/profiles/peppol/generator.py index 4b8435e..c0fda2c 100644 --- a/edocument/edocument/profiles/peppol/generator.py +++ b/edocument/edocument/profiles/peppol/generator.py @@ -429,21 +429,41 @@ def _add_line_item(self, root: ET.Element, item): line_id = ET.SubElement(line_elem, f"{{{self.namespaces['cbc']}}}ID") line_id.text = str(item.idx) + # Normalize signs for PEPPOL compliance + qty = flt(item.qty, item.precision("qty")) + item_amount = flt(item.amount, item.precision("amount")) + item_rate = flt(item.rate, item.precision("rate")) + + if self.document_type == "CreditNote": + # Credit notes: all values positive (document type implies reversal) + qty = abs(qty) + item_amount = abs(item_amount) + item_rate = abs(item_rate) + elif item_amount < 0: + # Invoice deduction line: move negative from rate/price to quantity + qty = -abs(qty) + item_rate = abs(item_rate) + quantity = ET.SubElement(line_elem, f"{{{self.namespaces['cbc']}}}{quantity_elem_name}") - quantity.text = str(flt(item.qty, item.precision("qty"))) + quantity.text = str(qty) quantity.set("unitCode", self.map_unit_code(item.uom)) # Update references from invoice_line to line_elem invoice_line = line_elem line_amount = ET.SubElement(invoice_line, f"{{{self.namespaces['cbc']}}}LineExtensionAmount") - line_amount.text = str(flt(item.amount, item.precision("amount"))) + line_amount.text = f"{item_amount:.2f}" line_amount.set("currencyID", self.invoice.currency) item_elem = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Item") description = ET.SubElement(item_elem, f"{{{self.namespaces['cbc']}}}Description") - description.text = item.description or item.item_name + item_description = item.description or item.item_name + if frappe.utils.is_html(item_description): + from frappe.core.utils import html2text + + item_description = html2text(item_description).strip() + description.text = item_description name = ET.SubElement(item_elem, f"{{{self.namespaces['cbc']}}}Name") name.text = item.item_name @@ -466,7 +486,8 @@ def _add_line_item(self, root: ET.Element, item): price = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Price") price_amount = ET.SubElement(price, f"{{{self.namespaces['cbc']}}}PriceAmount") - price_amount.text = str(flt(item.rate, item.precision("rate"))) + price_precision = item.precision("rate") or 2 + price_amount.text = f"{item_rate:.{price_precision}f}" price_amount.set("currencyID", self.invoice.currency) def _add_tax_totals(self): @@ -477,8 +498,12 @@ def _add_tax_totals(self): tax_total = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}TaxTotal") # Use invoice.total_taxes_and_charges for total TaxAmount (already correctly calculated after discount) + # Credit notes: use abs() since PEPPOL CreditNote expects positive values + total_tax = flt(self.invoice.total_taxes_and_charges, 2) + if self.document_type == "CreditNote": + total_tax = abs(total_tax) tax_amount = ET.SubElement(tax_total, f"{{{self.namespaces['cbc']}}}TaxAmount") - tax_amount.text = str(flt(self.invoice.total_taxes_and_charges, 2)) + tax_amount.text = f"{total_tax:.2f}" tax_amount.set("currencyID", self.invoice.currency) # Group taxes by (category_code, rate) @@ -500,20 +525,23 @@ def _add_tax_totals(self): if key not in tax_groups: tax_groups[key] = {"taxable_amount": 0, "tax_amount": 0} - item_tax_amount = flt(item.net_amount) * (rate or 0) / 100 + net_amount = flt(item.net_amount) + if self.document_type == "CreditNote": + net_amount = abs(net_amount) + item_tax_amount = net_amount * (rate or 0) / 100 tax_groups[key]["tax_amount"] += item_tax_amount - tax_groups[key]["taxable_amount"] += flt(item.net_amount) + tax_groups[key]["taxable_amount"] += net_amount # 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") - taxable_amount.text = str(flt(data["taxable_amount"], 2)) + taxable_amount.text = f"{flt(data['taxable_amount'], 2):.2f}" taxable_amount.set("currencyID", self.invoice.currency) tax_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxAmount") - tax_amount.text = str(flt(data["tax_amount"], 2)) + tax_amount.text = f"{flt(data['tax_amount'], 2):.2f}" tax_amount.set("currencyID", self.invoice.currency) tax_category = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cac']}}}TaxCategory") @@ -674,6 +702,8 @@ def _add_allowances_charges(self): # Handle document-level discounts (AllowanceCharge with ChargeIndicator=false) discount_amount = flt(self.invoice.total, 2) - flt(self.invoice.net_total, 2) + if self.document_type == "CreditNote": + discount_amount = abs(discount_amount) if discount_amount > 0: # There's a document-level discount allowance_charge = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}AllowanceCharge") @@ -690,8 +720,13 @@ def _add_allowances_charges(self): reason.text = reason_text # MultiplierFactorNumeric: Calculate percentage (Amount / BaseAmount * 100) - if self.invoice.total > 0: - multiplier = (discount_amount / flt(self.invoice.total, 2)) * 100 + inv_total = ( + abs(flt(self.invoice.total, 2)) + if self.document_type == "CreditNote" + else flt(self.invoice.total, 2) + ) + if inv_total > 0: + multiplier = (discount_amount / inv_total) * 100 multiplier_factor = ET.SubElement( allowance_charge, f"{{{self.namespaces['cbc']}}}MultiplierFactorNumeric" ) @@ -699,17 +734,17 @@ def _add_allowances_charges(self): # Amount: The discount amount amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}Amount") - amount.text = str(flt(discount_amount, 2)) + amount.text = f"{flt(discount_amount, 2):.2f}" amount.set("currencyID", self.invoice.currency) # BaseAmount: The amount before discount (invoice.total) base_amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}BaseAmount") - base_amount.text = str(flt(self.invoice.total, 2)) + base_amount.text = f"{inv_total:.2f}" base_amount.set("currencyID", self.invoice.currency) - # Tax Category: Use the tax rate from invoice taxes + # Tax Category (BR-32: always required on AllowanceCharge) # Get the first non-Actual tax rate (usually VAT) - tax_rate = None + tax_rate = 0 sample_tax = None for tax in self.invoice.taxes: if tax.charge_type != "Actual" and tax.rate: @@ -717,25 +752,19 @@ def _add_allowances_charges(self): sample_tax = tax break - if tax_rate and tax_rate > 0: - tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") + 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_code = self.get_vat_category_code(self.invoice, tax=sample_tax) - # Category ID - category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID") - category_id.text = category_code + 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(tax_rate, 2)) + tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") + tax_percent.text = str(flt(tax_rate, 2)) - # Tax scheme - 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" + 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" # Handle document-level charges (AllowanceCharge with ChargeIndicator=true) for tax in self.invoice.taxes: @@ -754,28 +783,29 @@ def _add_allowances_charges(self): reason.text = tax.description # Amount + charge_amt = ( + abs(flt(tax.tax_amount, 2)) + if self.document_type == "CreditNote" + else flt(tax.tax_amount, 2) + ) amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}Amount") - amount.text = str(flt(tax.tax_amount, 2)) + amount.text = f"{charge_amt:.2f}" amount.set("currencyID", self.invoice.currency) - # Tax Category (if applicable) - if tax.rate and tax.rate > 0: - tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory") + # Tax Category (BR-32: always required on AllowanceCharge) + 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_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 = category_code + 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(tax.rate, 2)) + tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent") + tax_percent.text = str(flt(tax.rate or 0, 2)) - # Tax scheme - 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" + 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" def _set_totals(self): # Set monetary totals using ERPNext values directly @@ -785,36 +815,46 @@ def _set_totals(self): # Add LegalMonetaryTotal section legal_total = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}LegalMonetaryTotal") + # For credit notes, ERPNext stores negative totals but PEPPOL requires positive values + is_credit = self.document_type == "CreditNote" + inv_total = abs(flt(self.invoice.total, 2)) if is_credit else flt(self.invoice.total, 2) + inv_net_total = abs(flt(self.invoice.net_total, 2)) if is_credit else flt(self.invoice.net_total, 2) + inv_grand_total = ( + abs(flt(self.invoice.grand_total, 2)) if is_credit else flt(self.invoice.grand_total, 2) + ) + # Line Extension Amount (BT-106) - Sum of Invoice line net amount (BEFORE discount) # Use invoice.total which is the sum of all item.amount values line_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}LineExtensionAmount") - line_total.text = str(flt(self.invoice.total, 2)) + line_total.text = f"{inv_total:.2f}" line_total.set("currencyID", self.invoice.currency) # Tax Exclusive Amount (BT-109) - Invoice total amount without VAT # This equals LineExtensionAmount - AllowanceTotalAmount = net_total (after discount, before tax) tax_exclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxExclusiveAmount") - tax_exclusive_amount.text = str(flt(self.invoice.net_total, 2)) + tax_exclusive_amount.text = f"{inv_net_total:.2f}" tax_exclusive_amount.set("currencyID", self.invoice.currency) # Tax Inclusive Amount (BT-112) - Invoice total amount with VAT tax_inclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxInclusiveAmount") - tax_inclusive_amount.text = str(flt(self.invoice.grand_total, 2)) + tax_inclusive_amount.text = f"{inv_grand_total:.2f}" tax_inclusive_amount.set("currencyID", self.invoice.currency) # Allowance Total Amount (BT-107) - Sum of allowances on document level (discount) # Calculate as difference between total (before discount) and net_total (after discount) - allowance_amount = flt(self.invoice.total, 2) - flt(self.invoice.net_total, 2) + allowance_amount = inv_total - inv_net_total allowance_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}AllowanceTotalAmount") - allowance_total.text = str(flt(allowance_amount, 2)) + allowance_total.text = f"{flt(allowance_amount, 2):.2f}" allowance_total.set("currencyID", self.invoice.currency) # Charge Total Amount (BT-108) - Sum of charges on document level # Must come AFTER AllowanceTotalAmount per XSD schema order actual_charge_total = sum(tax.tax_amount for tax in self.invoice.taxes if tax.charge_type == "Actual") + if is_credit: + actual_charge_total = abs(actual_charge_total) if actual_charge_total: charge_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}ChargeTotalAmount") - charge_total.text = str(flt(actual_charge_total, 2)) + charge_total.text = f"{flt(actual_charge_total, 2):.2f}" charge_total.set("currencyID", self.invoice.currency) else: # Always add ChargeTotalAmount (set to 0.00 if no charges) @@ -825,24 +865,22 @@ def _set_totals(self): # Prepaid Amount (BT-113) - Sum of amounts already paid # This is required when PayableAmount != TaxInclusiveAmount to satisfy BR-CO-16: # PayableAmount = TaxInclusiveAmount - PrepaidAmount + RoundingAmount - if self.document_type != "CreditNote": + if not is_credit: prepaid_value = flt(self.invoice.grand_total, 2) - flt(self.invoice.outstanding_amount, 2) if prepaid_value > 0: prepaid_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PrepaidAmount") - prepaid_amount.text = str(flt(prepaid_value, 2)) + prepaid_amount.text = f"{flt(prepaid_value, 2):.2f}" prepaid_amount.set("currencyID", self.invoice.currency) # Payable Amount (BT-115) - Amount due for payment - # For credit notes, this should be negative (amount to be credited) payable_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PayableAmount") - if self.document_type == "CreditNote": - # For credit notes, PayableAmount should be negative (the amount to be credited) - # Use grand_total (which is already negative for credit notes) - payable_value = flt(self.invoice.grand_total, 2) + if is_credit: + # For credit notes, use positive grand_total (amount to be credited) + payable_value = inv_grand_total else: # For invoices, use outstanding_amount payable_value = flt(self.invoice.outstanding_amount, 2) - payable_amount.text = str(payable_value) + payable_amount.text = f"{payable_value:.2f}" payable_amount.set("currencyID", self.invoice.currency) def initialize_peppol_xml(self) -> ET.Element: @@ -960,12 +998,31 @@ def get_invoice_type_code(self, invoice) -> str: if hasattr(invoice, "is_return") and invoice.is_return: invoice_type_code = "381" elif hasattr(invoice, "amended_from") and invoice.amended_from: - invoice_type_code = "384" + # 384 (Corrected Invoice) only allowed when both parties are German + if self._both_parties_german(): + invoice_type_code = "384" except Exception: pass return invoice_type_code + def _both_parties_german(self) -> bool: + # Check if both seller and buyer are German organizations + try: + seller_code = "" + if self.seller_address and self.seller_address.country: + seller_code = ( + frappe.db.get_value("Country", self.seller_address.country, "code") or "" + ).upper() + buyer_code = "" + if self.buyer_address and self.buyer_address.country: + buyer_code = ( + frappe.db.get_value("Country", self.buyer_address.country, "code") or "" + ).upper() + return seller_code == "DE" and buyer_code == "DE" + except Exception: + return False + def map_unit_code(self, erpnext_unit: str) -> str: # Map ERPNext unit codes to PEPPOL standard unit codes using CommonCodeRetriever if not erpnext_unit: diff --git a/edocument/edocument/profiles/peppol/matcher.py b/edocument/edocument/profiles/peppol/matcher.py index 14ee282..eb90d08 100644 --- a/edocument/edocument/profiles/peppol/matcher.py +++ b/edocument/edocument/profiles/peppol/matcher.py @@ -530,7 +530,6 @@ def _get_dialog_config(matching_data: dict) -> dict: "fieldname": "purchase_order", "label": _("Match to Purchase Order"), "options": "Purchase Order", - "reqd": 1, "default": po_data.get("matched"), } ) diff --git a/edocument/edocument/profiles/peppol/parser.py b/edocument/edocument/profiles/peppol/parser.py index 4a01769..5693db1 100644 --- a/edocument/edocument/profiles/peppol/parser.py +++ b/edocument/edocument/profiles/peppol/parser.py @@ -187,10 +187,11 @@ def parse_peppol_xml(xml_bytes, edocument_profile, edocument=None): pi_data.get("supplier"), document_elements, matched_items=matched_items, + is_return=pi_data.get("is_return"), ) - # Parse taxes - pi_data["taxes"] = parse_peppol_taxes(root, namespaces) + # Remove taxes - will be populated from templates by guess_tax_templates + pi_data.pop("taxes", None) # Post-process: guess missing values guess_missing_values(pi_data) @@ -261,16 +262,28 @@ def parse_peppol_buyer(root, namespaces): buyer_party, ".//cac:PartyLegalEntity/cbc:RegistrationName", namespaces ) or get_xml_text(buyer_party, ".//cac:PartyName/cbc:Name", namespaces) - # Try to find company + # Buyer tax ID + buyer_tax_id = get_xml_text(buyer_party, ".//cac:PartyTaxScheme/cbc:CompanyID", namespaces) + + # Try to find company by tax ID first (more reliable than name) company = None - if buyer_name and frappe.db.exists("Company", buyer_name): + if buyer_tax_id: + company = frappe.db.get_value("Company", {"tax_id": buyer_tax_id}, "name") + + if not company and buyer_name and frappe.db.exists("Company", buyer_name): company = buyer_name - return {"company": company, "name": buyer_name} + return {"company": company, "name": buyer_name, "tax_id": buyer_tax_id} def parse_peppol_line_items( - root, namespaces, purchase_order=None, supplier=None, document_elements=None, matched_items=None + root, + namespaces, + purchase_order=None, + supplier=None, + document_elements=None, + matched_items=None, + is_return=False, ): """ Parse line items from PEPPOL XML. @@ -337,8 +350,14 @@ def parse_peppol_line_items( # Quantity and UOM (use document-specific quantity element name) qty_elem = invoice_line.find(f".//cbc:{quantity_elem_name}", namespaces) + negative_qty = False if qty_elem is not None and qty_elem.text: item["qty"] = flt_or_none(qty_elem.text) + # ERPNext does not allow negative quantities on non-return invoices + # Move the sign from quantity to rate to preserve the line total + if item["qty"] is not None and item["qty"] < 0 and not is_return: + negative_qty = True + item["qty"] = abs(item["qty"]) unit_code = qty_elem.get("unitCode") if unit_code: # Store unit_code for later UOM guessing @@ -354,11 +373,14 @@ def parse_peppol_line_items( # rate = PriceAmount / BaseQuantity net_rate = float(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 + rate = net_rate / base_qty if base_qty else net_rate + item["rate"] = -rate if negative_qty else rate # Line total if line_total_text: item["amount"] = flt_or_none(line_total_text) + if negative_qty and item["amount"] is not None: + item["amount"] = -abs(item["amount"]) # Tax rate (for reference, actual tax is in taxes table) tax_category = invoice_line.find(".//cac:Item/cac:ClassifiedTaxCategory", namespaces) @@ -403,7 +425,7 @@ def parse_peppol_taxes(root, namespaces): # Tax account (try to find from tax category) # This is a simplified approach - you may need to map tax categories to accounts - tax["account_head"] = None # Will need to be set based on your tax setup + tax["account_head"] = None # Resolved later by guess_missing_values # Tax amount tax_amount = get_xml_text(tax_subtotal, ".//cbc:TaxAmount", namespaces) @@ -464,9 +486,9 @@ def parse_peppol_payment_terms(root, namespaces, default_due_date=None): payment_schedule.append(schedule_item) - # If no payment terms found, add default one - if not payment_schedule and default_due_date: - payment_schedule.append({"doctype": "Payment Schedule", "due_date": default_due_date}) + # Don't add a default payment_schedule entry with only due_date. + # ERPNext will compute the schedule from payment_terms_template (set via supplier defaults) + # or the user can add it manually. Adding an entry without payment_amount causes errors. return payment_schedule @@ -578,6 +600,17 @@ def guess_missing_values(pi_data): except Exception: pass + # Last resort: match by item name. Since item_name is not unique, + # this may match the wrong item when duplicates exist. + if not item.get("item_code") and item.get("item_name"): + item_name = item["item_name"] + if frappe.db.exists("Item", item_name): + item["item_code"] = item_name + else: + item_code = frappe.db.get_value("Item", {"item_name": item_name}, "name") + if item_code: + item["item_code"] = item_code + # Remove temporary fields (if they exist) if "seller_product_id" in item: item.pop("seller_product_id") @@ -593,17 +626,76 @@ def guess_missing_values(pi_data): if not item.get("purchase_order"): item["purchase_order"] = pi_data["purchase_order"] - # Guess tax accounts for taxes (simplified - you may need to map based on your tax setup) - for tax in pi_data.get("taxes", []): - if not tax.get("account_head") and tax.get("rate"): - # Try to find a default tax account based on rate - # This is a simplified approach - you may need to customize this - try: - # Get default tax account from company settings or tax template - # For now, leave it empty - user will need to set it manually - pass - except Exception: - pass + # Set tax templates based on item tax rates + guess_tax_templates(pi_data) + + +def guess_tax_templates(pi_data): + """Set header or item tax templates based on item tax rates. + + If all items share the same tax rate, set a header Purchase Taxes and Charges Template. + If items have different tax rates, set Item Tax Template on each item. + """ + company = pi_data.get("company") + if not company: + return + + items = pi_data.get("items", []) + if not items: + return + + # Collect unique tax rates from items + tax_rates = {item["tax_rate"] for item in items if item.get("tax_rate") is not None} + if not tax_rates: + return + + if len(tax_rates) == 1: + # All items have the same rate — use header template + rate = tax_rates.pop() + template = frappe.db.get_value( + "Purchase Taxes and Charges", + { + "rate": rate, + "charge_type": "On Net Total", + "parenttype": "Purchase Taxes and Charges Template", + "parent": [ + "in", + frappe.get_all( + "Purchase Taxes and Charges Template", + filters={"company": company, "disabled": 0}, + pluck="name", + ), + ], + }, + "parent", + ) + if template: + pi_data["taxes_and_charges"] = template + else: + # Mixed rates — set Item Tax Template per item + for item in items: + rate = item.get("tax_rate") + if rate is None: + continue + + item_tax_template = frappe.db.get_value( + "Item Tax Template Detail", + { + "tax_rate": rate, + "parenttype": "Item Tax Template", + "parent": [ + "in", + frappe.get_all( + "Item Tax Template", + filters={"company": company, "disabled": 0}, + pluck="name", + ), + ], + }, + "parent", + ) + if item_tax_template: + item["item_tax_template"] = item_tax_template def guess_po_details(pi_data): diff --git a/edocument/install.py b/edocument/install.py index 726559a..f1a3b3c 100644 --- a/edocument/install.py +++ b/edocument/install.py @@ -125,6 +125,7 @@ def get_custom_fields(): "options": "EDocument", "insert_after": "edocument_profile", "read_only": 1, + "no_copy": 1, }, { "fieldname": "edocument_status", @@ -133,6 +134,7 @@ def get_custom_fields(): "insert_after": "edocument", "fetch_from": "edocument.status", "read_only": 1, + "no_copy": 1, }, ], "Purchase Invoice": [ @@ -149,6 +151,7 @@ def get_custom_fields(): "options": "EDocument", "insert_after": "edocument_tab", "read_only": 1, + "no_copy": 1, }, { "fieldname": "edocument_status", @@ -157,6 +160,7 @@ def get_custom_fields(): "insert_after": "edocument", "fetch_from": "edocument.status", "read_only": 1, + "no_copy": 1, }, ], } diff --git a/pyproject.toml b/pyproject.toml index 120c8bb..8e1a56c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ dependencies = [ requires = ["flit_core >=3.4,<4"] build-backend = "flit_core.buildapi" +[tool.bench.frappe-dependencies] +frappe = ">=15.0.0" +erpnext = ">=15.0.0" + # These dependencies are only installed when developer mode is enabled [tool.bench.dev-dependencies] # package_name = "~=1.1.0" @@ -59,3 +63,9 @@ typing-modules = ["frappe.types.DF"] quote-style = "double" indent-style = "tab" docstring-code-format = true + +[deploy.dependencies.apt] +packages = [ + "libxml2-dev", + "libxslt-dev", +]