From 754e9be27063116f4f5a04f2f359b7db7661a76d Mon Sep 17 00:00:00 2001 From: Bastian Guenther Date: Tue, 10 Dec 2024 16:23:53 +0100 Subject: [PATCH 1/2] [IMP] account_invoice_import: * Improved matching of partner * Improved matching of products * Added matching of operating unit * Improved account move line name --- account_invoice_import/__manifest__.py | 6 +- account_invoice_import/models/__init__.py | 1 + .../models/product_product.py | 19 +++ account_invoice_import/models/res_partner.py | 37 +++++ .../wizard/account_invoice_import.py | 130 +++++++++++++++++- oca_dependencies.txt | 1 + 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 account_invoice_import/models/product_product.py diff --git a/account_invoice_import/__manifest__.py b/account_invoice_import/__manifest__.py index 1ff28b5440..9d1a0279f3 100644 --- a/account_invoice_import/__manifest__.py +++ b/account_invoice_import/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Account Invoice Import", - "version": "13.0.1.0.1", + "version": "13.0.1.1.0", "category": "Accounting & Finance", "license": "AGPL-3", "summary": "Import supplier invoices/refunds as PDF or XML files", @@ -16,7 +16,11 @@ "base_iban", "base_business_document_import", "onchange_helper", + "account_operating_unit", + "partner_identification", + "partner_identification_gln", ], + "external_dependencies": {"python": ["factur-x==3.1"]}, "data": [ "security/ir.model.access.csv", "security/rule.xml", diff --git a/account_invoice_import/models/__init__.py b/account_invoice_import/models/__init__.py index e8ed8422d9..2fb300952c 100644 --- a/account_invoice_import/models/__init__.py +++ b/account_invoice_import/models/__init__.py @@ -3,3 +3,4 @@ from . import account_invoice_import_config from . import account_move from . import account_journal +from . import product_product diff --git a/account_invoice_import/models/product_product.py b/account_invoice_import/models/product_product.py new file mode 100644 index 0000000000..694cf2b7d4 --- /dev/null +++ b/account_invoice_import/models/product_product.py @@ -0,0 +1,19 @@ +from odoo import models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def _get_e_invoice_default_code_search_args( + self, default_code, wildcard_default_code + ): + """ + Return default search query for product search by default code in e invoice + """ + return [("default_code", "=", default_code)] + + def _get_e_invoice_barcode_search_args(self, barcode, wildcard_barcode): + """ + Return default search query for product search by barcode in e invoice + """ + return [("barcode", "=", barcode)] diff --git a/account_invoice_import/models/res_partner.py b/account_invoice_import/models/res_partner.py index 8d35ffe540..55cc6fef85 100644 --- a/account_invoice_import/models/res_partner.py +++ b/account_invoice_import/models/res_partner.py @@ -44,3 +44,40 @@ def show_account_invoice_import_config(self): "invoice_import_config_main_view": True, } return action + + def _get_e_invoice_vat_search_args(self, vat, wildcard_vat): + """ + Return default search query for partner search by vat + """ + normalized_vat = vat.replace(" ", "") + return [ + ("vat", "in", [normalized_vat, vat]), + ("tax_number", "in", [normalized_vat, vat]), + ] + + def _get_e_invoice_tax_number_search_args(self, tax_number, wildcard_tax_number): + """ + Return default search query for partner search by tax_number in e invoice + """ + normalized_tax_number = tax_number.replace(" ", "") + return [ + ("vat", "in", [normalized_tax_number, tax_number]), + ("tax_number", "in", [normalized_tax_number, tax_number]), + ] + + def _get_e_invoice_gln_search_args(self, gln, wildcard_gln): + """ + Return default search query for partner search by gln in e invoice + """ + PartnerIDNumber = self.env["res.partner.id_number"] + normalized_gln = gln.replace(" ", "") + partner_ids = PartnerIDNumber.search( + [ + ("active", "=", True), + ("category_id.code", "=", "gln_id_number"), + ("name", "in", [normalized_gln, gln]), + ] + ).mapped("partner_id") + if partner_ids: + return [("id", "in", partner_ids.ids)] + return [] diff --git a/account_invoice_import/wizard/account_invoice_import.py b/account_invoice_import/wizard/account_invoice_import.py index bb9df9113a..48a5e22d8f 100644 --- a/account_invoice_import/wizard/account_invoice_import.py +++ b/account_invoice_import/wizard/account_invoice_import.py @@ -14,12 +14,25 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError +from odoo.osv.expression import AND, OR from odoo.tools import config, float_compare, float_is_zero, float_round from odoo.tools.misc import format_amount logger = logging.getLogger(__name__) +try: + from facturx import get_xml_from_pdf +except ImportError: + logger.debug("Cannot import facturx") + + +def get_wildcard_string(val): + if val and val[-1] != "%": + val += "%" + return val + + class AccountInvoiceImport(models.TransientModel): _name = "account.invoice.import" _inherit = ["business.document.import", "mail.thread"] @@ -83,6 +96,16 @@ def default_get(self, fields_list): def parse_xml_invoice(self, xml_root): return False + @api.model + def get_xml_files_from_pdf(self, file_data): + """ + Overwrite the original function to use the facturx libraray to extract + xml from pdf + """ + data = get_xml_from_pdf(pdf_file=file_data, check_xsd=False) + xml_root = etree.fromstring(data[1]) + return {data[0]: xml_root} + @api.model def parse_pdf_invoice(self, file_data): """This method must be inherited by additional modules with @@ -254,7 +277,9 @@ def _prepare_create_invoice_vals(self, parsed_inv, import_config): assert parsed_inv.get("pre-processed"), "pre-processing not done" amo = self.env["account.move"] company = ( - self.env["res.company"].browse(self.env.context.get("force_company")) + self.env["res.company"].browse( + parsed_inv.get("company_id") or self.env.context.get("force_company") + ) or self.env.company ) vals = { @@ -267,6 +292,8 @@ def _prepare_create_invoice_vals(self, parsed_inv, import_config): "invoice_payment_ref": parsed_inv.get("payment_reference"), "invoice_line_ids": [], } + if parsed_inv.get("operating_unit_id"): + vals["operating_unit_id"] = parsed_inv.get("operating_unit_id") if parsed_inv["type"] in ("out_invoice", "out_refund"): partner_type = "customer" else: @@ -525,7 +552,9 @@ def parse_invoice(self, invoice_file_b64, invoice_filename, email_from=None): parsed_inv["partner"]["name"] = partner_name # pre_process_parsed_inv() will be called again a second time, # but it's OK - pp_parsed_inv = self.pre_process_parsed_inv(parsed_inv) + pp_parsed_inv = self.with_context( + edi_skip_company_check=True + ).pre_process_parsed_inv(parsed_inv) return pp_parsed_inv @api.model @@ -797,14 +826,109 @@ def new_partner(self): # If you have an idea on how to fix this problem, please tell me! return action + @api.model + def _match_product_search(self, product_dict): + """ + Extend the product search function to find products with additional + parameters. + """ + default_code = product_dict.get("code") + barcode = product_dict.get("barcode") + ProductProduct = self.env["product.product"] + args = [] + if default_code: + args += ProductProduct._get_e_invoice_default_code_search_args( + default_code, get_wildcard_string(default_code) + ) + if barcode: + args += ProductProduct._get_e_invoice_barcode_search_args( + barcode, get_wildcard_string(barcode) + ) + if args: + domain = AND( + [ + OR([arg] for arg in args), + [("company_id", "in", [False, self.env.company.id])], + ] + ) + product_variant_id = ProductProduct.search(domain, limit=1) + if product_variant_id: + return product_variant_id + return super()._match_product_search(product_dict=product_dict) + + @api.model + def _hook_match_partner(self, partner_dict, chatter_msg, domain, order): + """ + Custom partner search function to find partners with additional + parameters. + """ + gln = partner_dict.get("gln") + vat = partner_dict.get("vat") + tax_number = partner_dict.get("tax_number") + ResPartner = self.env["res.partner"] + args = [] + if gln: + args += ResPartner._get_e_invoice_gln_search_args( + gln, get_wildcard_string(gln) + ) + if vat: + args += ResPartner._get_e_invoice_vat_search_args( + vat, get_wildcard_string(vat) + ) + if tax_number: + args += ResPartner._get_e_invoice_tax_number_search_args( + tax_number, get_wildcard_string(tax_number) + ) + if args: + domain = AND( + [ + OR([arg] for arg in args), + [("company_id", "in", [False, self.env.company.id])], + ] + ) + partner_id = ResPartner.search(domain, limit=1) + if partner_id: + return partner_id + return super()._hook_match_partner( + partner_dict=partner_dict, + chatter_msg=chatter_msg, + domain=domain, + order=order, + ) + + def _get_operating_unit(self, parsed_inv): + """ + Find the buyer party partner and get the operating unit from it + """ + OperatingUnit = self.env["operating.unit"] + try: + partner_id = self._match_partner( + parsed_inv["company"], parsed_inv["chatter_msg"], raise_exception=False + ) + if partner_id: + return self.env["operating.unit"].search( + [("partner_id", "=", partner_id.id)], limit=1 + ) + except Exception: + ... + return OperatingUnit + def import_invoice(self): """Method called by the button of the wizard (import step AND config step)""" self.ensure_one() amo = self.env["account.move"] aiico = self.env["account.invoice.import.config"] - company_id = self.env.context.get("force_company") or self.env.company.id parsed_inv = self.get_parsed_invoice() + operating_unit_id = self._get_operating_unit(parsed_inv) + if operating_unit_id: + parsed_inv["operating_unit_id"] = operating_unit_id.id + company_id = ( + operating_unit_id.company_id.id + or self.env.context.get("force_company") + or self.env.company.id + ) + parsed_inv["company_id"] = company_id if not self.partner_id: if parsed_inv.get("partner"): try: diff --git a/oca_dependencies.txt b/oca_dependencies.txt index b35b50ed59..9f6eb372f4 100644 --- a/oca_dependencies.txt +++ b/oca_dependencies.txt @@ -10,3 +10,4 @@ stock-logistics-workflow connector storage server-auth +operating-unit From 9bfeecdb0205c948f9a7be2216cc9e1e9de788b4 Mon Sep 17 00:00:00 2001 From: Bastian Guenther Date: Tue, 10 Dec 2024 16:24:01 +0100 Subject: [PATCH 2/2] [IMP] account_invoice_import_facturx: * Improved matching of partner * Improved matching of products * Added matching of operating unit * Improved account move line name --- .../__manifest__.py | 2 +- .../wizard/account_invoice_import.py | 97 ++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/account_invoice_import_facturx/__manifest__.py b/account_invoice_import_facturx/__manifest__.py index 005e71df21..80b43d6a07 100644 --- a/account_invoice_import_facturx/__manifest__.py +++ b/account_invoice_import_facturx/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Account Invoice Import Factur-X", - "version": "13.0.1.0.0", + "version": "13.0.1.1.0", "category": "Invoicing Management", "license": "AGPL-3", "summary": "Import Factur-X/ZUGFeRD supplier invoices/refunds", diff --git a/account_invoice_import_facturx/wizard/account_invoice_import.py b/account_invoice_import_facturx/wizard/account_invoice_import.py index 2fa6dd2bb9..a5c5aaf448 100644 --- a/account_invoice_import_facturx/wizard/account_invoice_import.py +++ b/account_invoice_import_facturx/wizard/account_invoice_import.py @@ -44,6 +44,24 @@ def prepare_facturx_xpath_dict(self): "/ram:SpecifiedTaxRegistration" "/ram:ID[@schemeID='VA']", # ZUGFeRD ], + "tax_number": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:SellerTradeParty" + "/ram:SpecifiedTaxRegistration" + "/ram:ID[@schemeID='FC']", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:SellerTradeParty" + "/ram:SpecifiedTaxRegistration" + "/ram:ID[@schemeID='FC']", # ZUGFeRD + ], + "gln": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:SellerTradeParty" + "/ram:GlobalID[@schemeID='0088']", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:SellerTradeParty" + "/ram:GlobalID[@schemeID='0088']", # ZUGFeRD + ], "name": [ "//ram:ApplicableHeaderTradeAgreement" "/ram:SellerTradeParty" @@ -64,6 +82,18 @@ def prepare_facturx_xpath_dict(self): "/ram:EmailURIUniversalCommunication" "/ram:URIID", # ZUGFeRD ], + "phone": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:SellerTradeParty" + "/ram:DefinedTradeContact" + "/ram:TelephoneUniversalCommunication" + "/ram:CompleteNumber", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:SellerTradeParty" + "/ram:DefinedTradeContact" + "/ram:TelephoneUniversalCommunication" + "/ram:CompleteNumber", # ZUGFeRD + ], "country_code": [ "//ram:ApplicableHeaderTradeAgreement" "/ram:SellerTradeParty" @@ -112,6 +142,56 @@ def prepare_facturx_xpath_dict(self): "/ram:SpecifiedTaxRegistration" "/ram:ID[@schemeID='VA']", # ZUGFeRD ], + "tax_number": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:SpecifiedTaxRegistration" + "/ram:ID[@schemeID='FC']", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:SpecifiedTaxRegistration" + "/ram:ID[@schemeID='FC']", # ZUGFeRD + ], + "gln": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:GlobalID[@schemeID='0088']", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:GlobalID[@schemeID='0088']", # ZUGFeRD + ], + "name": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:Name", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:Name", # ZUGFeRD + ], + "email": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:DefinedTradeContact" + "/ram:EmailURIUniversalCommunication" + "/ram:URIID", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:DefinedTradeContact" + "/ram:EmailURIUniversalCommunication" + "/ram:URIID", # ZUGFeRD + ], + "phone": [ + "//ram:ApplicableHeaderTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:DefinedTradeContact" + "/ram:TelephoneUniversalCommunication" + "/ram:CompleteNumber", # Factur-X + "//ram:ApplicableSupplyChainTradeAgreement" + "/ram:BuyerTradeParty" + "/ram:DefinedTradeContact" + "/ram:TelephoneUniversalCommunication" + "/ram:CompleteNumber", # ZUGFeRD + ], }, "invoice_number": [ "//rsm:ExchangedDocument/ram:ID", # Factur-X @@ -257,6 +337,7 @@ def parse_facturx_invoice_line( "code": ["ram:SpecifiedTradeProduct/ram:SellerAssignedID"], }, "name": ["ram:SpecifiedTradeProduct/ram:Name"], + "description": ["ram:SpecifiedTradeProduct/ram:Description"], "date_start": [ "ram:SpecifiedLineTradeSettlement" "/ram:BillingSpecifiedPeriod" @@ -269,6 +350,18 @@ def parse_facturx_invoice_line( ], } vals = self.xpath_to_dict_helper(iline, xpath_dict, namespaces) + name_parts = [] + if vals.get("name"): + name_parts.append(vals.get("name")) + if vals.get("description"): + name_parts.append(vals.get("description")) + if vals.get("product"): + product = vals.get("product") + if product.get("code"): + name_parts.append(product.get("code")) + if product.get("barcode"): + name_parts.append(product.get("barcode")) + vals["name"] = "\n".join(name_parts) price_unit_xpath = iline.xpath( "ram:SpecifiedSupplyChainTradeAgreement" "/ram:NetPriceProductTradePrice" @@ -650,8 +743,10 @@ def parse_facturx_invoice(self, xml_root): # noqa: C901 ) # Hack for the sample ZUGFeRD invoices that use an invalid VAT number ! if res["partner"].get("vat") == "DE123456789": - res["partner"].pop("vat") + res["partner"]["vat"] = "DE123456788" if not res["partner"].get("email"): res["partner"]["name"] = "Lieferant GmbH" + if res["company"].get("vat") == "DE123456789": + res["company"]["vat"] = "DE123456788" logger.info("Result of Factur-X XML parsing: %s", res) return res