diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1484579fc8..d4444c870e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,8 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS + ^account_invoice_import_facturx/| + ^account_invoice_import_ubl/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| diff --git a/account_invoice_import/README.rst b/account_invoice_import/README.rst index 25a0f6f959..0d1f6b3b73 100644 --- a/account_invoice_import/README.rst +++ b/account_invoice_import/README.rst @@ -46,48 +46,37 @@ post `__ - (Cross-Industry Invoice) developped by - `UN/CEFACT `__ (United Nations Centre for - Trade Facilitation and Electronic Business), -- `UBL `__ (Universal Business Language) which is - an ISO standard (`ISO/IEC - 19845 `__) - developped by `OASIS `__ (Organization - for the Advancement of Structured Information Standards). - -Some e-invoice standards such as -`Factur-X `__ propose to embed the XML -description of the invoice inside the PDF invoice. Other people think -that the futur is pure-XML invoices: a European initiative called -`PEPPOL `__ aims at setting up an open network to -exchange e-invoices as UBL XML. We don't know yet which standard and -which practice will prevail on electronic invoicing in the future, but -we hope that lazy accountants won't have to manually encode their vendor -bills in the near future. This module is here to help achieve this goal! - -This module doesn't do anything useful by itself ; it requires other -modules to work: each modules adds a specific invoice format. +- `CII `__ + (Cross-Industry Invoice) developped by + `UN/CEFACT `__ (United Nations Centre + for Trade Facilitation and Electronic Business), +- `UBL `__ (Universal Business Language) which is + an ISO standard (`ISO/IEC + 19845 `__) + developped by `OASIS `__ (Organization + for the Advancement of Structured Information Standards). + +The `Factur-X `__ invoice standard embeds +a CII XML file inside the PDF invoice: this is the concept of hybrid +invoice. + +This module has native support for UBL XML, CII XML and Factur-X. You +can install additional modules (for example +account_invoice_import_simple_pdf) to support other invoice formats. Here is how the module works: -- the user starts a wizard and uploads the PDF or XML invoice, -- if it is an XML file, Odoo will parse it to create the invoice - (requires additional modules for specific XML formats, such as the - module *account_invoice_import_ubl* for the UBL format), -- if it is a PDF file with an embedded XML file in Factur-X/CII format, - Odoo will extract the embedded XML file and parse it to create the - invoice (requires the module *account_invoice_import_facturx*), -- otherwise, Odoo will use the *invoice2data* Python library to try to - interpret the text of the PDF (requires the module - *account_invoice_import_invoice2data*), -- if there is already some draft supplier invoice for this supplier, - Odoo will propose to select one to update or create a new draft - invoice, -- otherwise, Odoo will directly create a new draft supplier invoice and - attach the PDF to it. - -This module also works with supplier refunds. +- the user starts a wizard and uploads the PDF or XML invoice, +- if it is an XML file, Odoo will parse it to create the invoice +- if it is a PDF file with an embedded XML file in Factur-X/CII format, + Odoo will extract the embedded XML file and parse it to create the + invoice, +- if there is already a draft supplier invoice for this supplier with + the same invoice number, Odoo will display a warning, +- otherwise, Odoo will create a new draft vendor bill and attach the + PDF or XML invoice file to it. + +This module also works with vendor refunds. **Table of contents** @@ -100,25 +89,23 @@ Configuration Go to the form view of the suppliers and configure it with the following parameters: -- Individual/Company: *Company* -- the *VAT Number* (this field is used by default when searching the - supplier in the Odoo partner database) -- in the *Accounting* tab, create one or several *Invoice Import - Configurations*. +- the *VAT Number* (this field is used by default when searching the + supplier in the Odoo partner database) +- in the *Accounting* tab, set the *Invoice Import Configuration*. You can configure a mail gateway to import invoices from an email: -- Go to the menu *Settings > Technical > Email > Incoming Mail Servers* - and setup the access (POP or IMAP) to the mailbox that will be used to - receive the invoices, -- In the section *Actions to perform on incoming mails*, set the field - *Create a new record* to *Wizard to import supplier invoices/refunds* - (model *account.invoice.import*). -- If you are in a multi-company setup, you also have to go to the menu - *Invoicing > Configuration > Settings*: in the section *Invoice - Import*, enter the email of the mailbox used to import invoices in the - field *Mail Gateway: Destination E-mail* (it will be used to import - the invoice in the proper company). +- Go to the menu *Settings > Technical > Email > Incoming Mail Servers* + and setup the access (POP or IMAP) to the mailbox that will be used + to receive the invoices, +- In the section *Actions to perform on incoming mails*, set the field + *Create a new record* to *Wizard to import supplier invoices/refunds* + (model *account.invoice.import*). +- If you are in a multi-company setup, you also have to go to the menu + *Invoicing > Configuration > Settings*: in the section *Invoice + Import*, enter the email of the mailbox used to import invoices in + the field *Mail Gateway: Destination E-mail* (it will be used to + import the invoice in the proper company). Usage ===== @@ -128,32 +115,13 @@ instructions of the wizard. You can also start the wizard from the *Accounting Dashboard*: on the purchase journal, click on the *Upload* button. -This module also supports the scenario where you have a draft vendor -bill (generated from a purchase order for instance) and you have to -update it to comply with the real invoice sent by the vendor: on the -form view of the draft vendor bill, click on the button *Import Invoice -File* and follow the instructions of the wizard. - -If you have a large volume of invoices to import, you may be interested -by the script **mass_invoice_import.py** which is available in the -*scripts* subdirectory of this module. If you run: - -:: - - ./mass_invoice_import.py --help - -you will have detailed instructions on how to use the script. - -A particular use case of this script is to have a directory where all -the invoices saved are automatically uploaded in Odoo. For that, have a -look at the sample script **inotify-sample.sh** available in the same -subdirectory. Edit this sample script to adapt it to your needs. +You can upload several invoice file at the same time. Known issues / Roadmap ====================== -- Remove dependency on *base_iban* and develop a separate glue module - between this module and *base_iban* +- Remove dependency on *base_iban* and develop a separate glue module + between this module and *base_iban* Bug Tracker =========== @@ -176,12 +144,12 @@ Authors Contributors ------------ -- Alexis de Lattre -- Andrea Stirpe -- Nicolas JEUDY -- Yannick Vaucher -- Ronald Portier -- Simone Orsi +- Alexis de Lattre +- Andrea Stirpe +- Nicolas JEUDY +- Yannick Vaucher +- Ronald Portier +- Simone Orsi Maintainers ----------- diff --git a/account_invoice_import/__manifest__.py b/account_invoice_import/__manifest__.py index b075188ceb..3050dfede8 100644 --- a/account_invoice_import/__manifest__.py +++ b/account_invoice_import/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Account Invoice Import", - "version": "18.0.1.4.0", + "version": "18.0.2.0.0", "category": "Accounting & Finance", "license": "AGPL-3", "summary": "Import supplier invoices/refunds as PDF or XML files", @@ -14,8 +14,16 @@ "depends": [ "account", "base_iban", - "base_business_document_import", + "account_tax_unece", + "account_payment_unece", + "uom_unece", ], + # account_invoice_import_ubl and account_invoice_import_facturx + # should be uninstalled now. But, in order to ease the + # transition, we don't declare them in "excludes", to avoid + # an error on the first upgrade + # "excludes": ["account_invoice_import_ubl", "account_invoice_import_facturx"], + "external_dependencies": {"python": ["factur-x>=7.5"]}, "data": [ "security/ir.model.access.csv", "views/res_config_settings.xml", diff --git a/account_invoice_import/migrations/18.0.2.0.0/post-migration.py b/account_invoice_import/migrations/18.0.2.0.0/post-migration.py new file mode 100644 index 0000000000..142849589f --- /dev/null +++ b/account_invoice_import/migrations/18.0.2.0.0/post-migration.py @@ -0,0 +1,32 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +# In post migration script: +# - we can access the registry +# - the new fields are created +# - the old fields are still readable + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.logged_query( + env.cr, + f""" + UPDATE res_partner + SET invoice_import_shrink_lines = ( + SELECT jsonb_object_agg( + key, + CASE + WHEN value IN ('true'::jsonb, '1'::jsonb) + THEN '"per_vat_rate"'::jsonb + ELSE value + END + ) + FROM jsonb_each({openupgrade.get_legacy_name("invoice_import_single_line")}) + ) + WHERE {openupgrade.get_legacy_name("invoice_import_single_line")} IS NOT NULL + """, + ) diff --git a/account_invoice_import/migrations/18.0.2.0.0/pre-migration.py b/account_invoice_import/migrations/18.0.2.0.0/pre-migration.py new file mode 100644 index 0000000000..120a0befc4 --- /dev/null +++ b/account_invoice_import/migrations/18.0.2.0.0/pre-migration.py @@ -0,0 +1,23 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + +_column_renames = { + "res_partner": [("invoice_import_single_line", None)], +} + +# XML parsing moved to the factur-x lib: these modules are not installable +# anymore. Merge them so that databases where they were installed don't keep +# them in "to upgrade" state. +_merged_modules = [ + ("account_invoice_import_ubl", "account_invoice_import"), + ("account_invoice_import_facturx", "account_invoice_import"), +] + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.rename_columns(env.cr, _column_renames) + openupgrade.update_module_names(env.cr, _merged_modules, merge_modules=True) diff --git a/account_invoice_import/models/__init__.py b/account_invoice_import/models/__init__.py index 7d0a6d323c..373b79d830 100644 --- a/account_invoice_import/models/__init__.py +++ b/account_invoice_import/models/__init__.py @@ -1,4 +1,5 @@ from . import res_partner from . import res_company from . import account_move +from . import account_move_line from . import account_journal diff --git a/account_invoice_import/models/account_journal.py b/account_invoice_import/models/account_journal.py index 3d7a77e9ab..2a4208ce14 100644 --- a/account_invoice_import/models/account_journal.py +++ b/account_invoice_import/models/account_journal.py @@ -21,5 +21,5 @@ def create_document_from_attachment(self, attachment_ids): "invoice_attachment_ids": [Command.set(attachment_ids)], } ) - action = wiz.import_invoices() + action = wiz.import_invoices_button() return action diff --git a/account_invoice_import/models/account_move.py b/account_invoice_import/models/account_move.py index 52259ffa89..d619c0f436 100644 --- a/account_invoice_import/models/account_move.py +++ b/account_invoice_import/models/account_move.py @@ -3,6 +3,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import Command, api, fields, models +from odoo.exceptions import UserError from odoo.tools import is_html_empty @@ -23,6 +24,24 @@ def _compute_show_import_warnings(self): def _invoice_import_set_partner_and_update_lines(self, partner): self.ensure_one() + if self.is_purchase_document() and self.ref: + existing_inv = self.search( + [ + ("company_id", "=", self.company_id.id), + ("commercial_partner_id", "=", partner.commercial_partner_id.id), + ("ref", "=ilike", self.ref), + ("move_type", "=", self.move_type), + ], + limit=1, + ) + if existing_inv: + raise UserError( + self.env._( + "The invoice on which you are trying to update the partner " + "already exists: %s.", + existing_inv.display_name, + ) + ) initial_fp = self.fiscal_position_id fp = partner.property_account_position_id self.write({"partner_id": partner.id}) diff --git a/account_invoice_import/models/account_move_line.py b/account_invoice_import/models/account_move_line.py new file mode 100644 index 0000000000..17602f5e24 --- /dev/null +++ b/account_invoice_import/models/account_move_line.py @@ -0,0 +1,18 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + # I initially wanted to name this field import_identifier + # but the risk of naming conflict was too high, so I decided to + # set a longer name + import_invoice_line_identifier = fields.Char( + string="Import Identifier", + readonly=True, + help="When importing a vendor bill, this field stores BT-126", + ) diff --git a/account_invoice_import/models/res_partner.py b/account_invoice_import/models/res_partner.py index 7550c939f3..bdf6530f73 100644 --- a/account_invoice_import/models/res_partner.py +++ b/account_invoice_import/models/res_partner.py @@ -4,7 +4,7 @@ from markupsafe import Markup -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import UserError @@ -35,8 +35,13 @@ class ResPartner(models.Model): "fiscal position.", ) # FORCE VALUE fields - invoice_import_single_line = fields.Boolean( - string="Force Single Invoice Line", company_dependent=True + invoice_import_shrink_lines = fields.Selection( + [ + ("single", "Single Line"), + ("per_vat_rate", "One Line per VAT rate"), + ], + string="Shrink Invoice Lines", + company_dependent=True, ) invoice_import_label = fields.Char( string="Force Invoice Line Description", @@ -61,19 +66,18 @@ class ResPartner(models.Model): related="invoice_import_move_id.partner_id" ) - def _convert_to_import_config(self, company): + def _commercial_partner_update_import_config(self, import_config): self.ensure_one() - if not company: - company = self.env.company - self = self.with_company(company.id) - vals = { - "company": company, - "single_line": self.invoice_import_single_line, - "label": self.invoice_import_label or False, - "journal": self.invoice_import_journal_id or False, - } + assert self == self.commercial_partner_id + # company context has already been injected in self + company = import_config["company"] + import_config["shrink_lines"] = self.invoice_import_shrink_lines + if self.invoice_import_label: + import_config["label"] = self.invoice_import_label + if self.invoice_import_journal_id: + import_config["purchase_journal"] = self.invoice_import_journal_id if self.invoice_import_product_id: - vals["product"] = self.invoice_import_product_id + import_config["product"] = self.invoice_import_product_id else: taxes = ( self.invoice_import_tax_ids @@ -83,13 +87,48 @@ def _convert_to_import_config(self, company): or False ) if taxes: - vals["taxes"] = taxes + import_config["taxes"] = taxes if ( self.invoice_import_account_id and company in self.invoice_import_account_id.company_ids ): - vals["account"] = self.invoice_import_account_id - return vals + import_config["account"] = self.invoice_import_account_id + if not import_config.get("product") and not import_config.get("account"): + previous_invoice = self._get_previous_invoice(import_config) + if previous_invoice: + import_config["previous_invoice"] = previous_invoice + self._update_import_config_from_previous_invoice(import_config) + + @api.model + def _update_import_config_from_previous_invoice(self, import_config): + if import_config.get("previous_invoice"): + inv = import_config["previous_invoice"] + ilines = inv.invoice_line_ids.filtered( + lambda x: x.display_type == "product" + ) + if ilines: + iline = ilines[0] + if not import_config.get("product") and iline.product_id: + import_config["product"] = iline.product_id + else: + if not import_config.get("account"): + import_config["account"] = iline.account_id + if not import_config.get("taxes") and iline.tax_ids: + import_config["taxes"] = iline.tax_ids + + def _get_previous_invoice(self, import_config): + self.ensure_one() + domain = [ + ("company_id", "=", import_config["company"].id), + ("commercial_partner_id", "=", self.id), + ("state", "=", "posted"), + ] + if import_config["invoice_type"] == "out": + domain.append(("move_type", "in", ("out_invoice", "out_refund"))) + else: + domain.append(("move_type", "in", ("in_invoice", "in_refund"))) + inv = self.env["account.move"].search(domain, limit=1, order="date desc") + return inv def update_imported_invoice(self): """Method called by button in partner banner""" @@ -101,7 +140,7 @@ def update_imported_invoice(self): # it could cause multi-company issues self.message_post( body=Markup( - _( + self.env._( "Partner has been created from the wizard " "Create or Update Partner of vendor bill import." ) @@ -109,7 +148,7 @@ def update_imported_invoice(self): ) if invoice_import_move.partner_id: raise UserError( - _( + self.env._( "The vendor bill %(move)s already has a partner %(partner)s.", move=invoice_import_move.display_name, partner=invoice_import_move.partner_id.display_name, @@ -118,7 +157,7 @@ def update_imported_invoice(self): invoice_import_move._invoice_import_set_partner_and_update_lines(self) invoice_import_move.message_post( body=Markup( - _( + self.env._( "Partner %(partner_name)s has been " "created via the wizard Create or update partner.", @@ -166,7 +205,7 @@ def _invoice_import_update_partner(self, import_partner_data): # it could cause multi-company access-right issues self.message_post( body=Markup( - _( + self.env._( "Partner updated via the wizard " "Create or Update Partner of Vendor Bill import." ) diff --git a/account_invoice_import/readme/CONFIGURE.md b/account_invoice_import/readme/CONFIGURE.md index ceedaeecd2..ff29eb9e2c 100644 --- a/account_invoice_import/readme/CONFIGURE.md +++ b/account_invoice_import/readme/CONFIGURE.md @@ -1,11 +1,9 @@ Go to the form view of the suppliers and configure it with the following parameters: -- Individual/Company: *Company* - the *VAT Number* (this field is used by default when searching the supplier in the Odoo partner database) -- in the *Accounting* tab, create one or several *Invoice Import - Configurations*. +- in the *Accounting* tab, set the *Invoice Import Configuration*. You can configure a mail gateway to import invoices from an email: diff --git a/account_invoice_import/readme/DESCRIPTION.md b/account_invoice_import/readme/DESCRIPTION.md index 08825ca6f6..daf813225e 100644 --- a/account_invoice_import/readme/DESCRIPTION.md +++ b/account_invoice_import/readme/DESCRIPTION.md @@ -22,35 +22,21 @@ two international standards exists to describe an Invoice in XML: developped by [OASIS](https://www.oasis-open.org/) (Organization for the Advancement of Structured Information Standards). -Some e-invoice standards such as -[Factur-X](http://fnfe-mpe.org/factur-x/) propose to embed the XML -description of the invoice inside the PDF invoice. Other people think -that the futur is pure-XML invoices: a European initiative called -[PEPPOL](https://peppol.eu/) aims at setting up an open network to -exchange e-invoices as UBL XML. We don't know yet which standard and -which practice will prevail on electronic invoicing in the future, but -we hope that lazy accountants won't have to manually encode their vendor -bills in the near future. This module is here to help achieve this goal! +The [Factur-X](http://fnfe-mpe.org/factur-x/) invoice standard embeds a CII XML +file inside the PDF invoice: this is the concept of hybrid invoice. -This module doesn't do anything useful by itself ; it requires other -modules to work: each modules adds a specific invoice format. +This module has native support for UBL XML, CII XML and Factur-X. You can install additional modules (for example account_invoice_import_simple_pdf) to support other invoice formats. Here is how the module works: - the user starts a wizard and uploads the PDF or XML invoice, - if it is an XML file, Odoo will parse it to create the invoice - (requires additional modules for specific XML formats, such as the - module *account_invoice_import_ubl* for the UBL format), - if it is a PDF file with an embedded XML file in Factur-X/CII format, Odoo will extract the embedded XML file and parse it to create the - invoice (requires the module *account_invoice_import_facturx*), -- otherwise, Odoo will use the *invoice2data* Python library to try to - interpret the text of the PDF (requires the module - *account_invoice_import_invoice2data*), -- if there is already some draft supplier invoice for this supplier, - Odoo will propose to select one to update or create a new draft invoice, -- otherwise, Odoo will directly create a new draft supplier invoice and - attach the PDF to it. +- if there is already a draft supplier invoice for this supplier with + the same invoice number, Odoo will display a warning, +- otherwise, Odoo will create a new draft vendor bill and + attach the PDF or XML invoice file to it. -This module also works with supplier refunds. +This module also works with vendor refunds. diff --git a/account_invoice_import/readme/USAGE.md b/account_invoice_import/readme/USAGE.md index fced72c7ba..3051f437d1 100644 --- a/account_invoice_import/readme/USAGE.md +++ b/account_invoice_import/readme/USAGE.md @@ -3,23 +3,4 @@ the instructions of the wizard. You can also start the wizard from the *Accounting Dashboard*: on the purchase journal, click on the *Upload* button. -This module also supports the scenario where you have a draft vendor -bill (generated from a purchase order for instance) and you have to -update it to comply with the real invoice sent by the vendor: on the -form view of the draft vendor bill, click on the button *Import Invoice -File* and follow the instructions of the wizard. - -If you have a large volume of invoices to import, you may be interested -by the script **mass_invoice_import.py** which is available in the -*scripts* subdirectory of this module. If you run: - -``` -./mass_invoice_import.py --help -``` - -you will have detailed instructions on how to use the script. - -A particular use case of this script is to have a directory where all -the invoices saved are automatically uploaded in Odoo. For that, have a -look at the sample script **inotify-sample.sh** available in the same -subdirectory. Edit this sample script to adapt it to your needs. +You can upload several invoice file at the same time. diff --git a/account_invoice_import/static/description/index.html b/account_invoice_import/static/description/index.html index 825ada584d..f86ba83005 100644 --- a/account_invoice_import/static/description/index.html +++ b/account_invoice_import/static/description/index.html @@ -390,44 +390,33 @@

Account Invoice Import

  • CII (Cross-Industry Invoice) developped by -UN/CEFACT (United Nations Centre for -Trade Facilitation and Electronic Business),
  • +UN/CEFACT (United Nations Centre +for Trade Facilitation and Electronic Business),
  • UBL (Universal Business Language) which is an ISO standard (ISO/IEC 19845) developped by OASIS (Organization for the Advancement of Structured Information Standards).
-

Some e-invoice standards such as -Factur-X propose to embed the XML -description of the invoice inside the PDF invoice. Other people think -that the futur is pure-XML invoices: a European initiative called -PEPPOL aims at setting up an open network to -exchange e-invoices as UBL XML. We don’t know yet which standard and -which practice will prevail on electronic invoicing in the future, but -we hope that lazy accountants won’t have to manually encode their vendor -bills in the near future. This module is here to help achieve this goal!

-

This module doesn’t do anything useful by itself ; it requires other -modules to work: each modules adds a specific invoice format.

+

The Factur-X invoice standard embeds +a CII XML file inside the PDF invoice: this is the concept of hybrid +invoice.

+

This module has native support for UBL XML, CII XML and Factur-X. You +can install additional modules (for example +account_invoice_import_simple_pdf) to support other invoice formats.

Here is how the module works:

  • the user starts a wizard and uploads the PDF or XML invoice,
  • -
  • if it is an XML file, Odoo will parse it to create the invoice -(requires additional modules for specific XML formats, such as the -module account_invoice_import_ubl for the UBL format),
  • +
  • if it is an XML file, Odoo will parse it to create the invoice
  • if it is a PDF file with an embedded XML file in Factur-X/CII format, Odoo will extract the embedded XML file and parse it to create the -invoice (requires the module account_invoice_import_facturx),
  • -
  • otherwise, Odoo will use the invoice2data Python library to try to -interpret the text of the PDF (requires the module -account_invoice_import_invoice2data),
  • -
  • if there is already some draft supplier invoice for this supplier, -Odoo will propose to select one to update or create a new draft invoice,
  • -
  • otherwise, Odoo will directly create a new draft supplier invoice and -attach the PDF to it.
  • +
  • if there is already a draft supplier invoice for this supplier with +the same invoice number, Odoo will display a warning,
  • +
  • otherwise, Odoo will create a new draft vendor bill and attach the +PDF or XML invoice file to it.
-

This module also works with supplier refunds.

+

This module also works with vendor refunds.

Table of contents

    @@ -448,25 +437,23 @@

    Configuration

    Go to the form view of the suppliers and configure it with the following parameters:

      -
    • Individual/Company: Company
    • the VAT Number (this field is used by default when searching the supplier in the Odoo partner database)
    • -
    • in the Accounting tab, create one or several Invoice Import -Configurations.
    • +
    • in the Accounting tab, set the Invoice Import Configuration.

    You can configure a mail gateway to import invoices from an email:

    • Go to the menu Settings > Technical > Email > Incoming Mail Servers -and setup the access (POP or IMAP) to the mailbox that will be used to -receive the invoices,
    • +and setup the access (POP or IMAP) to the mailbox that will be used +to receive the invoices,
    • In the section Actions to perform on incoming mails, set the field Create a new record to Wizard to import supplier invoices/refunds (model account.invoice.import).
    • If you are in a multi-company setup, you also have to go to the menu Invoicing > Configuration > Settings: in the section Invoice -Import, enter the email of the mailbox used to import invoices in the -field Mail Gateway: Destination E-mail (it will be used to import -the invoice in the proper company).
    • +Import, enter the email of the mailbox used to import invoices in +the field Mail Gateway: Destination E-mail (it will be used to +import the invoice in the proper company).
@@ -475,22 +462,7 @@

Usage

instructions of the wizard. You can also start the wizard from the Accounting Dashboard: on the purchase journal, click on the Upload button.

-

This module also supports the scenario where you have a draft vendor -bill (generated from a purchase order for instance) and you have to -update it to comply with the real invoice sent by the vendor: on the -form view of the draft vendor bill, click on the button Import Invoice -File and follow the instructions of the wizard.

-

If you have a large volume of invoices to import, you may be interested -by the script mass_invoice_import.py which is available in the -scripts subdirectory of this module. If you run:

-
-./mass_invoice_import.py --help
-
-

you will have detailed instructions on how to use the script.

-

A particular use case of this script is to have a directory where all -the invoices saved are automatically uploaded in Odoo. For that, have a -look at the sample script inotify-sample.sh available in the same -subdirectory. Edit this sample script to adapt it to your needs.

+

You can upload several invoice file at the same time.

Known issues / Roadmap

diff --git a/account_invoice_import/tests/__init__.py b/account_invoice_import/tests/__init__.py index d0a138175d..49b90faa42 100644 --- a/account_invoice_import/tests/__init__.py +++ b/account_invoice_import/tests/__init__.py @@ -1 +1,2 @@ -from . import test_invoice_import +# from . import test_invoice_import +from . import test_facturx diff --git a/account_invoice_import/tests/pdf/Facture_FR_BASICWL.pdf b/account_invoice_import/tests/pdf/Facture_FR_BASICWL.pdf new file mode 100644 index 0000000000..72dcaf7c7c Binary files /dev/null and b/account_invoice_import/tests/pdf/Facture_FR_BASICWL.pdf differ diff --git a/account_invoice_import/tests/pdf/Facture_FR_EN16931.pdf b/account_invoice_import/tests/pdf/Facture_FR_EN16931.pdf new file mode 100644 index 0000000000..c23f3fa30b Binary files /dev/null and b/account_invoice_import/tests/pdf/Facture_FR_EN16931.pdf differ diff --git a/account_invoice_import/tests/pdf/Facture_FR_EXTENDED.pdf b/account_invoice_import/tests/pdf/Facture_FR_EXTENDED.pdf new file mode 100644 index 0000000000..e163f28791 Binary files /dev/null and b/account_invoice_import/tests/pdf/Facture_FR_EXTENDED.pdf differ diff --git a/account_invoice_import/tests/test_facturx.py b/account_invoice_import/tests/test_facturx.py new file mode 100644 index 0000000000..1e9e29e52e --- /dev/null +++ b/account_invoice_import/tests/test_facturx.py @@ -0,0 +1,155 @@ +# Copyright 2015-2021 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import Command, fields +from odoo.tests.common import TransactionCase +from odoo.tools import file_open, mute_logger + + +class TestFacturx(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.company = cls.env.ref("base.main_company") + adjustment_credit_account_id = ( + cls.env["account.account"] + .create( + { + "company_ids": [Command.set([cls.company.id])], + "code": "758FXADJUST", + "name": "Adjustment income account", + "account_type": "income", + } + ) + .id + ) + adjustment_debit_account_id = ( + cls.env["account.account"] + .create( + { + "company_ids": [Command.set([cls.company.id])], + "code": "658FXADJUST", + "name": "Adjustment expense account", + "account_type": "expense", + } + ) + .id + ) + cls.company.write( + { + "adjustment_credit_account_id": adjustment_credit_account_id, + "adjustment_debit_account_id": adjustment_debit_account_id, + } + ) + cls.expense_account = cls.env["account.account"].search( + [ + ("company_ids", "=", cls.company.id), + ("account_type", "=", "expense"), + ], + limit=1, + ) + cls.purchase_tax = cls.env["account.tax"].search( + [ + ("company_id", "=", cls.company.id), + ("type_tax_use", "=", "purchase"), + ("amount_type", "=", "percent"), + ("amount", ">", 5), + ], + limit=1, + ) + cls.jolie_boutique_partner = cls.env["res.partner"].create( + { + "name": "Ma jolie boutique", + "is_company": True, + "country_id": cls.env.ref("base.fr").id, + "vat": "FR11999999998", + "invoice_import_shrink_lines": "single", + "invoice_import_account_id": cls.expense_account.id, + "invoice_import_tax_ids": [Command.set(cls.purchase_tax.ids)], + } + ) + + # avoid warning logs pypdf._reader: incorrect startxref pointer(1) + @mute_logger("pypdf._reader") + def test_import_facturx_invoice(self): + sample_files = { + "Facture_FR_BASICWL.pdf": { + "invoice_number": "FA-2017-0010", + "amount_untaxed": 624.90, + "amount_total": 671.15, + "invoice_date": "2017-11-13", + "partner_id": self.jolie_boutique_partner.id, + }, + "Facture_FR_EN16931.pdf": { + "invoice_number": "FA-2017-0010", + "amount_untaxed": 624.90, + "amount_total": 671.15, + "invoice_date": "2017-11-13", + "partner_id": self.jolie_boutique_partner.id, + }, + "Facture_FR_EXTENDED.pdf": { + "invoice_number": "FA-2017-0010", + "amount_untaxed": 624.90, + "amount_total": 671.15, + "invoice_date": "2017-11-13", + "partner_id": self.jolie_boutique_partner.id, + }, + } + amo = self.env["account.move"] + cur = self.env.ref("base.EUR") + for inv_file, res_dict in sample_files.items(): + f = file_open( + "account_invoice_import_facturx/tests/files/" + inv_file, "rb" + ) + pdf_file = f.read() + f.close() + wiz = self.env["account.invoice.import"].create( + { + "invoice_attachment_ids": [ + Command.create({"raw": pdf_file, "name": inv_file}) + ], + "company_id": self.company.id, + "debug_disable_company_check": True, + } + ) + wiz.import_invoices_button() + invoices = amo.search( + [ + ("state", "=", "draft"), + ("move_type", "in", ("in_invoice", "in_refund")), + ("ref", "=", res_dict["invoice_number"]), + ("company_id", "=", self.company.id), + ] + ) + self.assertEqual(len(invoices), 1) + inv = invoices[0] + self.assertEqual(inv.move_type, res_dict.get("type", "in_invoice")) + self.assertEqual( + fields.Date.to_string(inv.invoice_date), res_dict["invoice_date"] + ) + if res_dict.get("invoice_date_due"): + self.assertEqual( + fields.Date.to_string(inv.invoice_date_due), + res_dict["invoice_date_due"], + ) + self.assertEqual( + inv.partner_id.id, + res_dict["partner_id"], + ) + self.assertFalse( + cur.compare_amounts( + inv.amount_untaxed, + res_dict["amount_untaxed"], + ) + ) + self.assertFalse( + cur.compare_amounts( + inv.amount_total, + res_dict["amount_total"], + ) + ) + # Delete because several sample invoices have the same number + invoices.unlink() diff --git a/account_invoice_import/tests/test_invoice_import.py b/account_invoice_import/tests/test_invoice_import.py index 245fbeb3f3..ec74a9dc89 100644 --- a/account_invoice_import/tests/test_invoice_import.py +++ b/account_invoice_import/tests/test_invoice_import.py @@ -73,7 +73,7 @@ def setUpClass(cls): { "name": "Expense product", "default_code": "AII-TEST-PRODUCT", - "taxes_id": [(6, 0, [cls.sale_tax.id])], + "taxes_id": [Command.set([cls.sale_tax.id])], "supplier_taxes_id": [Command.set([cls.purchase_tax.id])], "property_account_income_id": cls.income_account.id, "property_account_expense_id": cls.expense_account.id, @@ -82,12 +82,12 @@ def setUpClass(cls): ) cls.all_import_config = [ { - "single_line": True, + "shrink_lines": "single", "account": cls.expense_account, "taxes": cls.purchase_tax, "company": cls.company, }, - {"single_line": False, "product": cls.product, "company": cls.company}, + {"shrink_lines": False, "product": cls.product, "company": cls.company}, { "account": cls.expense_account, "company": cls.company, diff --git a/account_invoice_import/views/account_move.xml b/account_invoice_import/views/account_move.xml index d988c16c27..5c957369db 100644 --- a/account_invoice_import/views/account_move.xml +++ b/account_invoice_import/views/account_move.xml @@ -32,6 +32,27 @@ string="Create or Update Partner" /> + + + + + + diff --git a/account_invoice_import/views/res_partner.xml b/account_invoice_import/views/res_partner.xml index da8b9b5b60..85341da487 100644 --- a/account_invoice_import/views/res_partner.xml +++ b/account_invoice_import/views/res_partner.xml @@ -26,7 +26,7 @@ invisible="invoice_import_product_id" /> - + @@ -44,7 +44,10 @@ type="object" name="update_imported_invoice" class="oe_link" - /> on imported vendor bill + /> on imported vendor bill
diff --git a/account_invoice_import/wizard/account_invoice_import.py b/account_invoice_import/wizard/account_invoice_import.py index 976e21d91c..2c819a5821 100644 --- a/account_invoice_import/wizard/account_invoice_import.py +++ b/account_invoice_import/wizard/account_invoice_import.py @@ -3,21 +3,25 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -import base64 import html import logging import mimetypes -from datetime import datetime +from collections import defaultdict from email.utils import parseaddr from io import BytesIO +from textwrap import shorten +from babel import Locale from lxml import etree from markupsafe import Markup +from stdnum.iban import is_valid as iban_is_valid -from odoo import Command, _, api, fields, models +from odoo import Command, api, fields, models from odoo.exceptions import UserError from odoo.tools import config, float_is_zero, float_round -from odoo.tools.misc import format_amount +from odoo.tools.misc import format_amount, formatLang + +from odoo.addons.base.models.res_bank import sanitize_account_number logger = logging.getLogger(__name__) @@ -26,6 +30,17 @@ except (OSError, ImportError) as err: logger.debug("Cannot import pypdf. Error details below.") logger.debug(err) +try: + from facturx import ( + get_data_dict_copy_for_logs, + get_flavor, + is_credit_note, + parse_ubl_cii_xml, + untdid_get_label, + ) +except (OSError, ImportError) as err: + logger.debug("Cannot import factur-x. Error details below.") + logger.debug(err) class AccountInvoiceImport(models.TransientModel): @@ -40,16 +55,49 @@ class AccountInvoiceImport(models.TransientModel): invoice_attachment_ids = fields.Many2many( "ir.attachment", string="PDF or XML Invoices to Import", required=True ) + debug_disable_company_check = fields.Boolean( + string="Disable Company Check", + help="If enabled, it will disable the check that the buyer " + "company correspond to the company of the wizard. " + "It is useful for debug purposes to test the import of a vendor bill " + "in a test database.", + ) + show_debug_disable_company_check = fields.Boolean(readonly=True) @api.model - def parse_xml_invoice(self, xml_root, company): - return False + def default_get(self, fields_list): + res = super().default_get(fields_list) + running_env = config.get("running_env") + if running_env in ("test", "dev"): + res["show_debug_disable_company_check"] = True + return res @api.model - def parse_pdf_invoice(self, file_data, company): + def parse_xml_invoice(self, xml_root, import_config): + self._update_import_config(import_config) + parsed_inv = False + try: + flavor = get_flavor(xml_root) + import_config["xml_flavor"] = flavor + parsed_inv = parse_ubl_cii_xml( + xml_root, + flavor=flavor, + float_as="float", + date_as="date", + bytes_as="bytes", + ) + except Exception as err: + logger.info( + f"The factur-x lib wasn't able to parse this XML file. Error: {err}" + ) + return parsed_inv + + @api.model + def parse_pdf_invoice(self, file_data, import_config): """This method must be inherited by additional modules with the same kind of logic as the account_statement_import_* modules""" + self._update_import_config(import_config) pdf_reader = PdfReader(BytesIO(file_data)) for attach_obj in pdf_reader.attachment_list: filename = attach_obj.name @@ -70,547 +118,755 @@ def parse_pdf_invoice(self, file_data, company): ) continue logger.info("Start to parse XML file %s", filename) - parsed_inv = self.parse_xml_invoice(xml_root, company) + parsed_inv = self.parse_xml_invoice(xml_root, import_config) if parsed_inv: return parsed_inv - parsed_inv = self.fallback_parse_pdf_invoice(file_data, company) + parsed_inv = self.fallback_parse_pdf_invoice(file_data, import_config) if not parsed_inv: parsed_inv = {} + self._fallback_update_parsed_inv(parsed_inv, import_config) + # In the fallback scenario, we may have amount_tax and amount_untaxed + # but not amount total return parsed_inv - def fallback_parse_pdf_invoice(self, file_data, company): + def fallback_parse_pdf_invoice(self, file_data, import_config): """Designed to be inherited by the module account_invoice_import_invoice2data, to be sure the invoice2data technique is used after the electronic invoice modules such as account_invoice_import_facturx and account_invoice_import_ubl """ + self._update_import_config(import_config) return False - # INVOICE PIVOT format ('parsed_inv' without pre-processing) - # For refunds, we support 2 possibilities: - # a) type = 'in_invoice' with negative amounts and qty - # b) type = 'in_refund' with positive amounts and qty ("Odoo way") - # That way, it simplifies the code in the format-specific import - # modules, which is what we want! - # { - # 'type': 'in_invoice' or 'in_refund' # 'in_invoice' by default - # 'journal': {'code': 'PUR'}, # use only if you want to force - # # a specific journal - # 'currency': { - # 'iso': 'EUR', - # 'country_code': 'FR', - # 'currency_symbol': '€', # The one or the other - # }, - # 'date': '2015-10-08', # Must be a string - # 'date_due': '2015-11-07', - # 'date_start': '2015-10-01', # for services over a period of time - # 'date_end': '2015-10-31', - # 'amount_untaxed': 10.0, - # 'amount_tax': 2.0, # provide amount_untaxed OR amount_tax - # 'amount_total': 12.0, # Total with taxes, must always be provided - # 'partner': { - # 'vat': 'FR25499247138', - # 'email': 'support@browserstack.com', - # 'name': 'Capitaine Train', - # 'street': '27 rue Henri Rolland', - # 'street2': 'ZAC des cactus', - # 'city': 'Villeurbanne', - # 'zip': '69100', - # 'country_code': 'FR', - # 'state_code': False, - # 'phone': '+33 4 72 42 24 42', - # 'mobile': '+33 6 42 12 42 12', - # }, - # 'company': {'vat': 'FR12123456789'}, # Rarely set in invoices - # # Only used to check we are not - # # importing the invoice in the - # # wrong company by mistake - # 'invoice_number': 'I1501243', - # 'description': 'TGV Paris-Lyon', - # 'attachments': {'file1.pdf': base64data1, 'file2.pdf': base64data2}, - # 'chatter_msg': ['Notes added in chatter of the invoice'], - # 'note': 'Note embedded in the document', - # 'origin': 'Origin note', - # 'lines': [{ - # 'product': { - # 'barcode': '4123456000021', - # 'code': 'GZ250', - # }, - # 'name': 'Gelierzucker Extra 250g', - # 'price_unit': 1.45, # price_unit without taxes - # 'discount': 10.0, # for 10% discount - # 'qty': 2.0, - # 'price_subtotal': 2.61, # not required, but needed - # to be able to generate adjustment lines when decimal - # precision is not high enough in Odoo - # 'uom': {'unece_code': 'C62'}, - # 'taxes': [{ - # 'amount_type': 'percent', - # 'amount': 20.0, - # 'unece_type_code': 'VAT', - # 'unece_categ_code': 'S', - # 'unece_due_date_code': '432', - # }], - # 'date_start': '2015-10-01', - # 'date_end': '2015-10-31', - # # date_start and date_end on lines override the global value - # }], - # } - - # IMPORT CONFIG - # { - # 'company': company recordset, # required field - # 'single_line': False, # boolean - # 'analytic_distribution': Analytic distribution, - # 'account': Account recordset, - # 'taxes': taxes multi-recordset, - # 'label': 'Force invoice line description', - # 'product': product recordset, - # 'previous_invoice': invoice recordset, # used - # } - # - # Note: we also support importing customer invoices via - # create_invoice() but only with single_line = False + def _fallback_update_parsed_inv(self, parsed_inv, import_config): + if "BT-112" not in parsed_inv: + # Designed to allow the import of an empty invoice with + # 1 invoice line at 0 that has the right account/product/analytic + parsed_inv["BT-112"] = 0 + if "BT-110" in parsed_inv and "BT-109" not in parsed_inv: + parsed_inv["BT-109"] = parsed_inv["BT-112"] - parsed_inv["BT-110"] + elif "BT-109" not in parsed_inv and "BT-110" not in parsed_inv: + # For invoices that never have taxes + parsed_inv["BT-109"] = parsed_inv["BT-112"] @api.model def _prepare_create_invoice_no_partner(self, parsed_inv, import_config, vals): - if parsed_inv.get("partner"): - if parsed_inv["partner"].get("email"): - source_email = parsed_inv["partner"]["email"] - if parsed_inv["partner"].get("name"): - source_email = f"{parsed_inv['partner']['name']} <{source_email}>" - vals["invoice_source_email"] = source_email + if import_config.get("email_from"): + vals["invoice_source_email"] = import_config["email_from"] + partner_dict = self._get_partner_dict(parsed_inv, import_config) + if partner_dict: + if not vals.get("invoice_source_email"): + for contact_dict in partner_dict.get("contacts") or []: + if contact_dict.get("email"): + source_email = contact_dict["email"] + if partner_dict.get("name"): + source_email = f"{partner_dict['name']} <{source_email}>" + vals["invoice_source_email"] = source_email + break + if not vals.get("invoice_source_email"): + vals["invoice_source_email"] = partner_dict.get("name") partner_data = { "is_company": True, "country_id": False, "state_id": False, - "supplier_rank": 1, } + if import_config["invoice_type"] == "in": + partner_data["supplier_rank"] = 1 + else: + partner_data["customer_rank"] = 1 if ( - parsed_inv["partner"].get("country_code") - and isinstance(parsed_inv["partner"]["country_code"], str) - and len(parsed_inv["partner"]["country_code"].strip()) == 2 + partner_dict.get("country_code") + and partner_dict["country_code"] + in import_config["speedy"]["country_code2id"] ): - country = self.env["res.country"].search( - [ - ( - "code", - "=", - parsed_inv["partner"]["country_code"].upper().strip(), - ) - ], - limit=1, - ) - if country: - partner_data["country_id"] = country.id + partner_data["country_id"] = import_config["speedy"]["country_code2id"][ + partner_dict["country_code"] + ] # There are already warnings when country code doesn't exist - if ( - partner_data.get("country_id") - and parsed_inv["partner"].get("state_code") - and isinstance(parsed_inv["partner"]["state_code"], str) + lang = self._get_lang_from_country_code( + partner_dict["country_code"], import_config + ) + if lang: + partner_data["lang"] = lang + if partner_data.get("country_id") and partner_dict.get( + "country_subdivision" ): - state = self.env["res.country.state"].search( - [ - ( - "code", - "=", - parsed_inv["partner"]["state_code"].upper().strip(), - ), - ("country_id", "=", partner_data["country_id"]), - ], - limit=1, + state_ids = list( + self.env["res.country.state"]._search( + [ + ( + "name", + "=", + partner_dict["country_subdivision"], + ), + ("country_id", "=", partner_data["country_id"]), + ], + limit=1, + ) ) - if state: - partner_data["state_id"] = state.id + if state_ids: + partner_data["state_id"] = state_ids[0] rpo = self.env["res.partner"] - for key, value in parsed_inv["partner"].items(): - if ( - value - and isinstance(value, str) - and hasattr(rpo, key) - and key not in ("country_code", "state_code") - ): - partner_data[key] = value + # if purchase module is installed + if ( + import_config["invoice_type"] == "in" + and hasattr(rpo, "property_purchase_currency_id") + and parsed_inv["BT-5"] in import_config["speedy"]["currency_code2id"] + ): + partner_data["property_purchase_currency_id"] = import_config["speedy"][ + "currency_code2id" + ][parsed_inv["BT-5"]] + + partner_dict_key2odoo_field = { + "name": "name", + "addr_l1": "street", + "addr_l2": "street2", + "addr_l3": "street3", + "postcode": "zip", + "city": "city", + "vat_identifier": "vat", + } + for partner_dict_key, odoo_field in partner_dict_key2odoo_field.items(): + value = partner_dict.get(partner_dict_key) + if value and isinstance(value, str) and hasattr(rpo, odoo_field): + partner_data[odoo_field] = value vals["import_partner_data"] = partner_data @api.model - def _prepare_create_invoice_journal(self, parsed_inv, import_config, vals): - if parsed_inv["type"] in ("in_invoice", "in_refund") and import_config.get( - "journal" + def _get_forced_journal_id(self, parsed_inv, import_config, vals): + forced_journal_id = False + if import_config["invoice_type"] == "in" and import_config.get( + "purchase_journal" ): - vals["journal_id"] = import_config["journal"].id - elif parsed_inv.get("journal"): - journal = self.env["business.document.import"]._match_journal( - parsed_inv["journal"], - parsed_inv["chatter_msg"], - company=import_config["company"], - raise_exception=False, - ) - if ( - parsed_inv["type"] in ("in_invoice", "in_refund") - and journal.type != "purchase" - ): - raise UserError( - _( - "You are importing a vendor bill/refund in journal '%s' " - "which is not a purchase journal." - ) - % journal.display_name + if import_config["purchase_journal"].type != "purchase": + msg = ( + f"Forced purchase journal " + f"{import_config['purchase_journal'].display_name} is " + f"in fact a {import_config['purchase_journal'].type} journal" ) + self._warning_log(import_config, msg) elif ( - parsed_inv["type"] in ("out_invoice", "out_refund") - and journal.type != "sale" + import_config["purchase_journal"].company_id != import_config["company"] ): - raise UserError( - _( - "You are importing a customer invoice/refund in journal '%s' " - "which is not a sale journal." - ) - % journal.display_name + msg = ( + f"Forced purchase journal " + f"{import_config['purchase_journal'].display_name} belongs to " + "company " + f"{import_config['purchase_journal'].company_id.display_name} " + "whereas invoice import is in company " + f"{import_config['company'].display_name}" ) - vals["journal_id"] = journal.id - else: - # we don't rely on auto-set of journal, because we need the journal - # to get the default account - if parsed_inv["type"] in ("out_invoice", "out_refund"): - journal_type = "sale" + self._warning_log(import_config, msg) else: - journal_type = "purchase" - journal = self.env["account.journal"].search( - [ - ("company_id", "=", import_config["company"].id), - ("type", "=", journal_type), - ], - limit=1, - ) - if not journal: - raise UserError( - _( - "No journal with type %(journal_type)s in company %(company)s.", - company=import_config["company"].display_name, - journal_type=journal._fields["type"].convert_to_export( - journal_type, journal - ), - ) + forced_journal_id = import_config["purchase_journal"].id + elif import_config["invoice_type"] == "out" and import_config.get( + "sale_journal" + ): + if import_config["sale_journal"].type != "sale": + msg = ( + f"Forced sale journal " + f"{import_config['sale_journal'].display_name} is in fact " + f"a {import_config['sale_journal'].type} journal" + ) + self._warning_log(import_config, msg) + elif import_config["sale_journal"].company_id != import_config["company"]: + msg = ( + f"Forced sale journal " + f"{import_config['sale_journal'].display_name} belongs to company " + f"{import_config['sale_journal'].company_id.display_name} whereas " + "invoice import is in company " + "{import_config['company'].display_name}" ) - vals["journal_id"] = journal.id + self._warning_log(import_config, msg) + else: + forced_journal_id = import_config["sale_journal"].id + return forced_journal_id @api.model def _prepare_create_invoice_vals(self, parsed_inv, import_config): - assert parsed_inv.get("pre-processed"), "pre-processing not done" company = import_config["company"] - bdio = self.env["business.document.import"] + refund = False + if parsed_inv.get("BT-3"): + refund = is_credit_note(parsed_inv) vals = { - "move_type": parsed_inv["type"], "company_id": company.id, - "invoice_origin": parsed_inv.get("origin"), - "ref": parsed_inv.get("invoice_number"), - "invoice_date": parsed_inv.get("date"), + "invoice_origin": import_config.get("origin"), + "invoice_date": parsed_inv.get("BT-2"), "invoice_line_ids": [], + "currency_id": import_config["invoice_currency"].id, } - if parsed_inv["type"] in ("out_invoice", "out_refund"): - partner_type = "customer" + if import_config["invoice_type"] == "in": + vals["move_type"] = refund and "in_refund" or "in_invoice" + vals["ref"] = parsed_inv.get("BT-1") else: - partner_type = "supplier" - partner = None - if parsed_inv.get("partner"): - partner = bdio._match_partner( - parsed_inv["partner"], - parsed_inv["chatter_msg"], - partner_type=partner_type, - raise_exception=False, - ) + vals["move_type"] = refund and "out_refund" or "out_invoice" + if parsed_inv.get("BT-1"): + vals["name"] = parsed_inv["BT-1"] + partner = import_config.get("partner") + cpartner = import_config.get("commercial_partner") if partner: - partner = partner.commercial_partner_id.with_company(company.id) vals["partner_id"] = partner.id - self._set_previous_invoice(parsed_inv, import_config, partner) - self._update_import_config_from_previous_invoice(import_config) + wire_transfer_codes = ("30", "42", "52", "58") + out_pay_method_line = cpartner.property_outbound_payment_method_line_id + if ( + parsed_inv.get("BT-81") + and parsed_inv["BT-81"] in wire_transfer_codes + and parsed_inv.get("BT-84") + and vals["move_type"] == "in_invoice" + and out_pay_method_line + and out_pay_method_line.payment_method_id.unece_code + in wire_transfer_codes + ): + partner_bank = self._match_bank_account( + cpartner, + parsed_inv, + import_config, + ) + if partner_bank: + vals["partner_bank_id"] = partner_bank.id else: self._prepare_create_invoice_no_partner(parsed_inv, import_config, vals) - if parsed_inv.get("currency"): - currency = bdio._match_currency( - parsed_inv["currency"], - parsed_inv["chatter_msg"], - import_config["company"], - raise_exception=False, - ) - vals["currency_id"] = currency.id - self._prepare_create_invoice_journal(parsed_inv, import_config, vals) + forced_journal_id = self._get_forced_journal_id(parsed_inv, import_config, vals) + if forced_journal_id: + vals["journal_id"] = forced_journal_id # Force due date of the invoice - if parsed_inv.get("date_due"): - vals["invoice_date_due"] = parsed_inv["date_due"] + if parsed_inv.get("BT-9"): + vals["invoice_date_due"] = parsed_inv["BT-9"] # Set invoice_payment_term_id to False because the due date is # set by invoice_date + invoice_payment_term_id otherwise vals["invoice_payment_term_id"] = False - # Bank info - if parsed_inv.get("iban") and vals["move_type"] == "in_invoice" and partner: - partner_bank = bdio._match_partner_bank( - partner, - parsed_inv["iban"], - parsed_inv.get("bic"), - parsed_inv["chatter_msg"], - create_if_not_found=company.invoice_import_create_bank_account, - ) - if partner_bank: - vals["partner_bank_id"] = partner_bank.id - self._last_update_import_config(parsed_inv, import_config, vals) # invoice lines - if parsed_inv.get("lines") and not import_config.get("single_line"): - self._prepare_line_vals_nline(parsed_inv, import_config, vals, partner) + if parsed_inv.get("BG-25") and not import_config.get("shrink_lines"): + for line_dict in parsed_inv["BG-25"]: + lvals = self._prepare_line_vals_bg25( + line_dict, parsed_inv, import_config, vals + ) + vals["invoice_line_ids"].append(Command.create(lvals)) + if lvals["discount"] is None: + bg27_line = 0 + for allowance_dict in line_dict.get("BG-27") or []: + bg27_line += 1 + alvals = self._prepare_line_vals_bg27( + bg27_line, + allowance_dict, + parsed_inv, + import_config, + lvals, + ) + if alvals["price_unit"]: + vals["invoice_line_ids"].append(Command.create(alvals)) + bg28_line = 0 + for charge_dict in line_dict.get("BG-28") or []: + bg28_line += 1 + clvals = self._prepare_line_vals_bg28( + bg28_line, charge_dict, parsed_inv, import_config, lvals + ) + if clvals["price_unit"]: + vals["invoice_line_ids"].append(Command.create(clvals)) + # BG-20 = global allowances / BG-21 = global charges + bg20_line = 0 + for allowance_dict in parsed_inv.get("BG-20") or []: + bg20_line += 1 + lvals = self._prepare_line_vals_bg20( + f"BG-20-{bg20_line}", + allowance_dict, + parsed_inv, + import_config, + vals, + ) + if lvals["price_unit"]: + vals["invoice_line_ids"].append(Command.create(lvals)) + bg21_line = 0 + for charge_dict in parsed_inv.get("BG-21") or []: + bg21_line += 1 + lvals = self._prepare_line_vals_bg21( + f"BG-21-{bg21_line}", + charge_dict, + parsed_inv, + import_config, + vals, + ) + if lvals["price_unit"]: + vals["invoice_line_ids"].append(Command.create(lvals)) + elif ( + parsed_inv.get("BG-23") + and import_config.get("shrink_lines") == "per_vat_rate" + ): + line_nr = 0 + for vatline_dict in parsed_inv["BG-23"]: + line_nr += 1 + lvals = self._prepare_line_vals_bg23( + str(line_nr), vatline_dict, parsed_inv, import_config, vals + ) + vals["invoice_line_ids"].append(Command.create(lvals)) else: - self._prepare_line_vals_1line(parsed_inv, import_config, vals, partner) + lvals = self._prepare_line_vals_single(parsed_inv, import_config, vals) + vals["invoice_line_ids"].append(Command.create(lvals)) # if module account_invoice_check_total from OCA/account-invoicing is installed + # Maybe we should depend on this module... ? Or have the equivalent + # that we only "enable" when importing an e-invoice ? + attachment_ids = [] + for attach_dict in parsed_inv.get("BG-24") or []: + if attach_dict.get("BT-125") and attach_dict.get("BT-125-2"): + attachment_ids.append( + Command.create( + { + "raw": attach_dict["BT-125"], + "name": attach_dict["BT-125-2"], + "res_model": "account.move", + } + ) + ) + if import_config.get("filename") and import_config.get("file_bin"): + attachment_ids.append( + Command.create( + { + "raw": import_config["file_bin"], + "name": import_config["filename"], + "res_model": "account.move", + } + ) + ) + if attachment_ids: + vals["attachment_ids"] = attachment_ids + narration_list = [] + for note_dict in parsed_inv.get("BG-1") or []: + if note_dict.get("BT-22"): + note = False + if note_dict.get("BT-21"): + note_categ = untdid_get_label("4451", note_dict["BT-21"]) + if note_categ: + note = ( + f"
  • {note_categ} : " + f"{note_dict['BT-22']}
  • " + ) + if not note: + note = f"
  • {note_dict['BT-22']}
  • " + narration_list.append(note) + if narration_list: + narration_str = "\n".join(narration_list) + narration = f"
      {narration_str}
    " + if import_config["invoice_type"] == "in": + label = self.env._("Notes extracted from the electronic invoice:") + narration = f"

    {label}

    {narration}" + vals["narration"] = narration if hasattr(self.env["account.move"], "check_total"): - vals["check_total"] = parsed_inv["amount_total"] + vals["check_total"] = parsed_inv["BT-112"] + vals["import_warnings"] = self._prepare_import_warnings(import_config) + if import_config.get("invoice_extra_vals") and isinstance( + import_config["invoice_extra_vals"], dict + ): + vals.update(import_config["invoice_extra_vals"]) return vals + def _prepare_import_warnings(self, import_config): + import_warnings = False + if import_config["line_warnings"]: + for line_ref, msg_list in import_config["line_warnings"].items(): + msg = f"
      {''.join([f'
    • {msg}
    • ' for msg in msg_list])}
    " + line_prefix = self.env._("Invoice Line External ID:") + import_config["global_warnings"].append( + f"{line_prefix} {line_ref} {msg}" + ) + if import_config["global_warnings"]: + title = self.env._("Electronic invoice import warnings:") + warning_lis = "".join( + [f"
  • {x}
  • " for x in import_config["global_warnings"]] + ) + import_warnings = f"

    {title}

      {warning_lis}
    " + return import_warnings and Markup(import_warnings) + @api.model - def _prepare_line_vals_1line(self, parsed_inv, import_config, vals, partner): - il_vals = { + def _prepare_line_vals_shrink_common(self, parsed_inv, import_config, vals): + product = import_config.get("product") + lvals = { "display_type": "product", "quantity": 1, + "product_id": product and product.id or False, } + self._prepare_line_set_account(lvals, product, import_config) if import_config.get("label"): - il_vals["name"] = import_config["label"] - elif parsed_inv.get("description"): - il_vals["name"] = parsed_inv["description"] + lvals["name"] = import_config["label"] + elif parsed_inv.get("BT-11-0"): + lvals["name"] = parsed_inv["BT-11-0"] + # For the moment, we only take into account the 'price_include' + # option of the first tax + self._prepare_line_set_global_start_end_dates(lvals, parsed_inv, import_config) + return lvals + + @api.model + def _prepare_line_vals_single(self, parsed_inv, import_config, vals): + cpartner = import_config.get("commercial_partner") # can be None + lvals = self._prepare_line_vals_shrink_common(parsed_inv, import_config, vals) # For the moment, we only take into account the 'price_include' # option of the first tax taxes = self.env["account.tax"] if import_config.get("product"): product = import_config["product"] - il_vals["product_id"] = product.id - if parsed_inv["type"] in ("out_invoice", "out_refund"): - account = product._get_product_accounts()["income"] + if import_config["invoice_type"] == "out": product_taxes = product.taxes_id else: - account = product._get_product_accounts()["expense"] product_taxes = product.supplier_taxes_id taxes = product_taxes.filtered( lambda tax: tax.company_id == import_config["company"] ) else: - if import_config.get("account"): - account = import_config["account"] - if import_config.get("taxes"): - taxes = import_config["taxes"] - fp = partner and partner.property_account_position_id or False - if fp: - account = fp.map_account(account) + taxes = import_config.get("taxes") + fp = cpartner and cpartner.property_account_position_id or False + if fp and taxes: taxes = fp.map_tax(taxes) - il_vals.update( - { - "account_id": account.id, - "tax_ids": [Command.set(taxes.ids)], - } - ) + if taxes: + lvals["tax_ids"] = [Command.set(taxes.ids)] if taxes and taxes[0].price_include: - il_vals["price_unit"] = parsed_inv.get("amount_total") + lvals["price_unit"] = parsed_inv["BT-112"] else: - il_vals["price_unit"] = parsed_inv.get("amount_untaxed") - if ( - import_config["start_end_dates_installed"] - and parsed_inv.get("date_start") - and parsed_inv.get("date_end") - ): - il_vals["start_date"] = parsed_inv.get("date_start") - il_vals["end_date"] = parsed_inv.get("date_end") + lvals["price_unit"] = parsed_inv.get("BT-109") + return lvals - vals["invoice_line_ids"].append(Command.create(il_vals)) + @api.model + def _prepare_line_vals_bg23( + self, line_nr, vatline_dict, parsed_inv, import_config, vals + ): + lvals = self._prepare_line_vals_shrink_common(parsed_inv, import_config, vals) + taxes = self.env["account.tax"] + taxes = self._match_vat_tax_bg23( + line_nr, + vatline_dict, + parsed_inv, + import_config, + ) + lvals.update( + { + "tax_ids": [Command.set(taxes and taxes.ids or [])], + "price_unit": vatline_dict.get("BT-116"), + "import_invoice_line_identifier": line_nr, + } + ) + return lvals @api.model - def _prepare_line_vals_nline(self, parsed_inv, import_config, vals, partner): - assert parsed_inv.get("lines") - bdio = self.env["business.document.import"] - for line in parsed_inv["lines"]: - product = False - if line.get("product"): - product = bdio._match_product( - line["product"], - parsed_inv["chatter_msg"], - seller=partner, - raise_exception=False, - ) - if not product and import_config.get("product"): - product = import_config["product"] - if product: - product = product.with_company(import_config["company"].id) - if parsed_inv["type"] in ("out_invoice", "out_refund"): - account = product._get_product_accounts()["income"] - else: - account = product._get_product_accounts()["expense"] + def _prepare_line_set_account(self, lvals, product, import_config): + # product can have a value or not + # fallback on import_config["product"] has already been made + # We don't update lvals when there is no account, so that the compute + # method will set a default value for the account + account = False + if product: + product = product.with_company(import_config["company"].id) + if import_config["invoice_type"] == "out": + account = product._get_product_accounts()["income"] else: - account = import_config["account"] + account = product._get_product_accounts()["expense"] + elif import_config.get("account"): + account = import_config["account"] + if account: + cpartner = import_config.get( + "commercial_partner" + ) # already has with_company + if cpartner: + fp = cpartner and cpartner.property_account_position_id or False + if fp: + account = fp.map_account(account) + lvals["account_id"] = account.id - if parsed_inv["type"] in ("out_invoice", "out_refund"): - type_tax_use = "sale" - else: - type_tax_use = "purchase" - taxes = bdio._match_taxes( - line.get("taxes"), - parsed_inv["chatter_msg"], - company=import_config["company"], - type_tax_use=type_tax_use, - raise_exception=False, - ) - if not taxes and product: - if parsed_inv["type"] in ("out_invoice", "out_refund"): - product_taxes = product.taxes_id - else: - product_taxes = product.supplier_taxes_id + @api.model + def _prepare_line_set_global_start_end_dates( + self, lvals, parsed_inv, import_config + ): + # We only set global start/end dates if it hasn't already been set at line-level + if import_config["start_end_dates_installed"]: + if ( + not lvals.get("start_date") + and not lvals.get("end_date") + and parsed_inv.get("BT-73") + and parsed_inv.get("BT-74") + ): + lvals["start_date"] = parsed_inv["BT-73"] + lvals["end_date"] = parsed_inv["BT-74"] - taxes = product_taxes.filtered( - lambda tax: tax.company_id == import_config["company"] - ) - if not taxes: - taxes = import_config["taxes"] + @api.model + def _prepare_line_vals_bg25(self, line_dict, parsed_inv, import_config, vals): + line_ident = line_dict["BT-126"] + product = self._match_product(line_dict, parsed_inv, import_config) + if not product and import_config.get("product"): + product = import_config["product"] + partner = import_config.get("commercial_partner") + if product and import_config["invoice_type"] == "out": + taxes = product.taxes_id.filtered( + lambda tax: tax.company_id == import_config["company"] + ) + # TODO replace by tax_include equivalent ? fp = partner and partner.property_account_position_id or False - if fp: - account = fp.map_account(account) + if fp and taxes: taxes = fp.map_tax(taxes) - uom = bdio._match_uom( - line.get("uom"), - parsed_inv["chatter_msg"], - product=product, - raise_exception=False, + else: + taxes = self._match_vat_tax_bg25( + line_dict, + parsed_inv, + import_config, ) - if product and product.uom_id.category_id != uom.category_id: - parsed_inv["chatter_msg"].append( - self.env._( - "Matched UoM is %(matched_uom)s, but this " - "UoM doesn't belong to the same category as UoM " - "%(product_uom)s configured on product " - "%(product)s. So Odoo has set the UoM of the product " - "(%(product_uom)s).", - matched_uom=uom.display_name, - product_uom=product.uom_id.display_name, - product=product.display_name, - ) + uom = self._match_uom(line_dict, parsed_inv, import_config) + + if uom and product and product.uom_id.category_id != uom.category_id: + import_config["line_warnings"][line_ident].append( + self.env._( + "Matched unit of measure (UoM) is " + "%(matched_uom)s, " + "but this UoM doesn't belong to the same category as UoM " + "%(product_uom)s configured on product " + "%(product)s. So Odoo has set the UoM of the product " + "(%(product_uom)s).", + matched_uom=uom.display_name, + product_uom=product.uom_id.display_name, + product=product.display_name, ) + ) + uom = product.uom_id + + if not uom: + if product: uom = product.uom_id + else: + uom = self.env.ref("uom.product_uom_unit") + price_unit = line_dict["BT-146"] + if line_dict.get("BT-149"): + price_unit /= line_dict["BT-149"] + # Special treatment for single line-level allowance with a percent + discount = None + if len(line_dict.get("BG-27", [])) == 1 and line_dict["BG-27"][0].get("rate"): + discount = line_dict["BG-27"][0]["rate"] + allowance_base = line_dict["BG-27"][0].get("base_amount") + if allowance_base: + line_base = line_dict["BT-129"] * price_unit + if import_config["invoice_currency"].compare_amounts( + allowance_base, line_base + ): + discount = None + lvals = { + "display_type": "product", + "import_invoice_line_identifier": line_ident, + "product_id": product and product.id or False, + "product_uom_id": uom.id, + "quantity": line_dict["BT-129"], + "price_unit": price_unit, + # TODO add support for tax incl ? + "discount": discount, + "tax_ids": [Command.set(taxes and taxes.ids or [])], + } + # to let the compute methods do their work for the default value + self._prepare_line_set_account(lvals, product, import_config) + if import_config.get("label"): + lvals["name"] = import_config["label"] + else: + name_list = [line_dict.get("BT-153"), line_dict.get("BT-154")] + lvals["name"] = "\n".join([x for x in name_list if x]) + if ( + import_config["start_end_dates_installed"] + and line_dict.get("BT-134") + and line_dict.get("BT-135") + ): + lvals["start_date"] = line_dict["BT-134"] + lvals["end_date"] = line_dict["BT-135"] + self._prepare_line_set_global_start_end_dates(lvals, parsed_inv, import_config) + return lvals - il_vals = { - "display_type": "product", - "product_id": product and product.id or False, - "product_uom_id": uom.id, - "account_id": account.id, - "tax_ids": [Command.set(taxes.ids)], - "quantity": line["qty"], - "price_unit": line["price_unit"], # TODO add support for tax incl ? - "discount": line.get("discount", 0), + def _prepare_line_vals_bg27( + self, bg27_line, allowance_dict, parsed_inv, import_config, lvals + ): + allowance_vals = dict(lvals) + untdid = "5189" + line_ident = f"{lvals['import_invoice_line_identifier']}-BG27-{bg27_line}" + allowance_vals.update( + { + "import_invoice_line_identifier": line_ident, + "name": self.env._( + "Allowance on %s", shorten(lvals.get("name", ""), 50) + ), + "price_unit": allowance_dict["amount"] * -1, } + ) + self._prepare_line_common_bg27_bg28( + allowance_dict, untdid, parsed_inv, import_config, allowance_vals + ) + return allowance_vals - if import_config.get("label"): - il_vals["name"] = import_config["label"] - elif line.get("name"): - il_vals["name"] = line["name"] - if import_config["start_end_dates_installed"]: - il_vals["start_date"] = line.get("date_start") or parsed_inv.get( - "date_start" - ) - il_vals["end_date"] = line.get("date_end") or parsed_inv.get("date_end") - vals["invoice_line_ids"].append(Command.create(il_vals)) + def _prepare_line_vals_bg28( + self, bg28_line, charge_dict, parsed_inv, import_config, lvals + ): + charge_vals = dict(lvals) # copies product, account, taxes, ... + untdid = "7161" + line_ident = f"{lvals['import_invoice_line_identifier']}-BG28-{bg28_line}" + charge_vals.update( + { + "import_invoice_line_identifier": line_ident, + "name": self.env._( + "Extra charge on %s", shorten(lvals.get("name", ""), 50) + ), + "price_unit": charge_dict["amount"], + } + ) + self._prepare_line_common_bg27_bg28( + charge_dict, untdid, parsed_inv, import_config, charge_vals + ) + return charge_vals - @api.model - def _set_previous_invoice(self, parsed_inv, import_config, partner): - if not import_config.get("previous_invoice"): - domain = [ - ("company_id", "=", import_config["company"].id), - ("commercial_partner_id", "=", partner.id), - ("state", "=", "posted"), - ] - if parsed_inv["type"] in ("out_invoice", "out_refund"): - domain.append(("move_type", "in", ("out_invoice", "out_refund"))) + def _prepare_line_common_bg27_bg28( + self, common_dict, untdid, parsed_inv, import_config, common_vals + ): + name_list = [common_vals["name"]] + untdid_5153_code2label = import_config["speedy"]["untdid_5153_code2label"] + if common_dict.get("reason"): + name_list.append(self.env._("Reason: %s", common_dict["reason"])) + if common_dict.get("reason_code"): + reason_code_label = untdid_get_label(untdid, common_dict["reason_code"]) + if reason_code_label: + name_list.append(self.env._("Coded reason: %s", reason_code_label)) + else: + name_list.append( + self.env._( + "Unknown reason code '%(code)s' (not in UNTDID %(untdid)s)", + code=common_dict["reason_code"], + untdid=untdid, + ) + ) + elif common_dict.get("non_vat_tax_code"): + if common_dict["non_vat_tax_code"] in untdid_5153_code2label: + name_list.append( + self.env._( + "Reason: Non-VAT Tax '%s'", + untdid_5153_code2label[common_dict["non_vat_tax_code"]], + ) + ) else: - domain.append(("move_type", "in", ("in_invoice", "in_refund"))) - inv = self.env["account.move"].search(domain, limit=1, order="date desc") - if inv: - import_config["previous_invoice"] = inv + name_list.append( + self.env._( + "Reason: Non-VAT Tax with code '%s' (not in UNTDID 5153)", + common_dict["non_vat_tax_code"], + ) + ) + name_last_line = [] + if common_dict.get("base_amount"): + base_fmt = format_amount( + self.env, common_dict["base_amount"], import_config["invoice_currency"] + ) + name_last_line.append(self.env._("Base: %s", base_fmt)) + if common_dict.get("rate"): + rate_fmt = formatLang(self.env, common_dict["rate"], digits=2) + name_last_line.append(self.env._("Rate: %s %%", rate_fmt)) + if name_last_line: + name_list.append(" - ".join(name_last_line)) - @api.model - def _update_import_config_from_previous_invoice(self, import_config): - if import_config.get("previous_invoice"): - inv = import_config["previous_invoice"] - ilines = inv.invoice_line_ids.filtered( - lambda x: x.display_type == "product" + common_vals.update( + { + "quantity": 1, + "discount": None, + "name": "\n".join(name_list), + } + ) + + def _prepare_line_vals_bg20( + self, line_ident, allowance_dict, parsed_inv, import_config, vals + ): + reason_code_label = untdid_get_label("5189", allowance_dict.get("reason_code")) + allowance_dict.update( + { + "reason_code_label": reason_code_label, + "amount_signed": allowance_dict["amount"] * -1, + "untdid": "5189", + "line_ident": line_ident, + } + ) + return self._prepare_line_vals_common_bg20_bg21( + allowance_dict, parsed_inv, import_config, vals + ) + + def _prepare_line_vals_bg21( + self, line_ident, charge_dict, parsed_inv, import_config, vals + ): + reason_code_label = untdid_get_label("7161", charge_dict.get("reason_code")) + charge_dict.update( + { + "reason_code_label": reason_code_label, + "amount_signed": charge_dict["amount"], + "untdid": "7161", + "line_ident": line_ident, + } + ) + return self._prepare_line_vals_common_bg20_bg21( + charge_dict, parsed_inv, import_config, vals + ) + + def _prepare_line_vals_common_bg20_bg21( + self, common_dict, parsed_inv, import_config, vals + ): + name_list = [] + if common_dict.get("reason"): + name_list.append(common_dict["reason"]) + if common_dict["reason_code_label"]: + name_list.append( + self.env._("Coded reason: %s", common_dict["reason_code_label"]) ) - if ilines: - iline = ilines[0] - if not import_config.get("product") and iline.product_id: - import_config["product"] = iline.product_id - else: - if not import_config.get("account") and iline.account_id: - import_config["account"] = iline.account_id - if not import_config.get("taxes") and iline.tax_ids: - import_config["taxes"] = iline.tax_ids - - def _last_update_import_config(self, parsed_inv, import_config, vals): - # if import_config settings are empty, get from global params - # import_config['product']: inject with_company() - # import_config['taxes']: filter on company and type_tax_use - # import_config['account']: check the company - # set import_config['start_end_dates_installed'] - if not import_config.get("taxes"): - if parsed_inv["type"] in ("out_invoice", "out_refund"): - import_config["taxes"] = import_config["company"].account_sale_tax_id - else: - import_config["taxes"] = import_config[ - "company" - ].account_purchase_tax_id - if not import_config.get("account"): - journal = self.env["account.journal"].browse(vals["journal_id"]) - import_config["account"] = journal.default_account_id - if not import_config.get("account"): - pcateg_obj = self.env["product.category"] - import_config["account"] = pcateg_obj._fields[ - "property_account_expense_categ_id" - ].get_company_dependent_fallback(pcateg_obj) - if import_config.get("product"): - import_config["product"] = import_config["product"].with_company( - import_config["company"].id + elif common_dict.get("reason_code"): + name_list.append( + self.env._( + "Unknown reason code '%(code)s' (not in UNTDID %(untdid)s)", + code=common_dict["reason_code"], + untdid=common_dict["untdid"], + ) ) - # Cleanup data - if import_config["taxes"]: - if parsed_inv["type"] in ("out_invoice", "out_refund"): - type_tax_use = "sale" - else: - type_tax_use = "purchase" - import_config["taxes"] = import_config["taxes"].filtered( - lambda x: x.company_id.id == import_config["company"].id - and x.type_tax_use == type_tax_use + name_last_line = [] + if common_dict.get("base_amount"): + base_fmt = format_amount( + self.env, common_dict["base_amount"], import_config["invoice_currency"] ) - if ( - import_config["account"] - and import_config["company"] not in import_config["account"].company_ids - ): - import_config["account"] = False - # set 'start_end_dates_installed' if the OCA module - # account_invoice_start_end_dates from https://github.com/OCA/account-closing - # is installed - line_model = self.env["account.move.line"] - import_config["start_end_dates_installed"] = ( - hasattr(line_model, "start_date") - and hasattr(line_model, "end_date") - or False + name_last_line.append(self.env._("Base: %s", base_fmt)) + if common_dict.get("rate"): + rate_fmt = formatLang(self.env, common_dict["rate"], digits=2) + name_last_line.append(self.env._("Rate: %s %%", rate_fmt)) + if name_last_line: + name_list.append(" - ".join(name_last_line)) + vat_tax = self._match_vat_tax_common( + common_dict["line_ident"], + common_dict["vat_category_code"], + int(round(common_dict.get("vat_rate", 0) * 100)), + parsed_inv, + import_config, ) + product = import_config.get("product") + lvals = { + "display_type": "product", + "product_id": product and product.id or False, + "name": "\n".join(name_list), + "quantity": 1, + "price_unit": common_dict["amount_signed"], + "import_invoice_line_identifier": common_dict["line_ident"], + "tax_ids": [Command.set(vat_tax and [vat_tax.id] or [])], + } + self._prepare_line_set_account(lvals, product, import_config) + self._prepare_line_set_global_start_end_dates(lvals, parsed_inv, import_config) + return lvals @api.model - def parse_invoice( - self, invoice_file_b64, invoice_filename, company, email_from=None - ): - assert invoice_file_b64, "No invoice file" - assert isinstance(invoice_file_b64, bytes) - logger.info("Starting to import invoice %s", invoice_filename) - file_data = base64.b64decode(invoice_file_b64) + def parse_invoice(self, invoice_file_bin, invoice_filename, import_config): + assert invoice_file_bin, "No invoice file" + assert isinstance(invoice_file_bin, bytes) + self._update_import_config(import_config) + import_config.update( + { + "filename": invoice_filename, + "file_bin": invoice_file_bin, + } + ) + logger.info(f"Starting to import invoice {invoice_filename}") filetype = mimetypes.guess_type(invoice_filename) logger.debug("Invoice mimetype: %s", filetype) if filetype and filetype[0] in ["application/xml", "text/xml"]: try: - xml_root = etree.fromstring(file_data) + xml_root = etree.fromstring(invoice_file_bin) except Exception as err: raise UserError( - _( + self.env._( "The XML file '%(filename)s' is not XML-compliant. " "Error: %(err)s", filename=invoice_filename, @@ -622,10 +878,10 @@ def parse_invoice( ) logger.debug("Starting to import the following XML file:") logger.debug(pretty_xml_bytes.decode("utf-8")) - parsed_inv = self.parse_xml_invoice(xml_root, company) + parsed_inv = self.parse_xml_invoice(xml_root, import_config) if parsed_inv is False: raise UserError( - _( + self.env._( "Odoo failed to read the XML invoice '%(filename)s'. " "Did you install the Odoo module to support this type " "of file?", @@ -634,108 +890,38 @@ def parse_invoice( ) # Fallback on PDF else: - parsed_inv = self.parse_pdf_invoice(file_data, company) - if "attachments" not in parsed_inv: - parsed_inv["attachments"] = {} - parsed_inv["attachments"][invoice_filename] = invoice_file_b64 - if email_from: - if "partner" not in parsed_inv: - parsed_inv["partner"] = {} - partner_name, email = parseaddr(email_from) - if not parsed_inv["partner"].get("email"): - parsed_inv["partner"]["email"] = email - if partner_name and not parsed_inv["partner"].get("name"): - parsed_inv["partner"]["name"] = partner_name - pp_parsed_inv = self._pre_process_parsed_inv(parsed_inv, company) - return pp_parsed_inv - - @api.model - def _pre_process_parsed_inv(self, parsed_inv, company): - if parsed_inv.get("pre-processed"): - return parsed_inv - parsed_inv["pre-processed"] = True - if "chatter_msg" not in parsed_inv: - parsed_inv["chatter_msg"] = [] - if not parsed_inv.get("currency_rec"): - parsed_inv["currency_rec"] = self.env[ - "business.document.import" - ]._match_currency( - parsed_inv.get("currency"), [], company=company, raise_exception=False - ) - # Rounding totals - self._pre_process_parsed_inv_rounding(parsed_inv, company) - if parsed_inv.get("type") in ("out_invoice", "out_refund"): - return parsed_inv - if "amount_total" not in parsed_inv: - # Designed to allow the import of an empty invoice with - # 1 invoice line at 0 that has the right account/product/analytic - parsed_inv["amount_total"] = 0 - if "amount_tax" in parsed_inv and "amount_untaxed" not in parsed_inv: - parsed_inv["amount_untaxed"] = ( - parsed_inv["amount_total"] - parsed_inv["amount_tax"] - ) - elif "amount_untaxed" not in parsed_inv and "amount_tax" not in parsed_inv: - # For invoices that never have taxes - parsed_inv["amount_untaxed"] = parsed_inv["amount_total"] - # Support the 2 refund methods; if method a) is used, we convert to - # method b) - if not parsed_inv.get("type"): - parsed_inv["type"] = "in_invoice" # default value - if ( - parsed_inv["type"] == "in_invoice" - and "amount_total" in parsed_inv - and parsed_inv["currency_rec"].compare_amounts( - parsed_inv["amount_total"], 0 - ) - < 0 - ): - parsed_inv["type"] = "in_refund" - for entry in ["amount_untaxed", "amount_total"]: - parsed_inv[entry] *= -1 - for line in parsed_inv.get("lines", []): - line["qty"] *= -1 - if "price_subtotal" in line: - line["price_subtotal"] *= -1 - # Handle taxes: - self._pre_process_parsed_inv_taxes(parsed_inv, company) - parsed_inv_for_log = dict(parsed_inv) - if "attachments" in parsed_inv_for_log: - parsed_inv_for_log.pop("attachments") - logger.debug("Result of invoice parsing parsed_inv=%s", parsed_inv_for_log) - # the 'company' dict in parsed_inv is NOT used to auto-detect - # the company, but to check that we are not importing an - # invoice for another company by mistake - if ( - parsed_inv.get("company") - and not config["test_enable"] - and not self.env.context.get("edi_skip_company_check") - ): - self.env["business.document.import"]._check_company( - parsed_inv["company"], - parsed_inv["chatter_msg"], - company, - raise_exception=True, - ) + parsed_inv = self.parse_pdf_invoice(invoice_file_bin, import_config) return parsed_inv - @api.model - def _pre_process_parsed_inv_rounding(self, parsed_inv, company): - for entry in ["amount_untaxed", "amount_total", "amount_tax"]: - if entry in parsed_inv: - parsed_inv[entry] = parsed_inv["currency_rec"].round(parsed_inv[entry]) - prec_price = self.env["decimal.precision"].precision_get("Product Price") - prec_disc = self.env["decimal.precision"].precision_get("Discount") - prec_qty = self.env["decimal.precision"].precision_get( - "Product Unit of Measure" - ) - for line in parsed_inv.get("lines", []): - line["qty"] = float_round(line["qty"], precision_digits=prec_qty) - line["price_unit"] = float_round( - line["price_unit"], precision_digits=prec_price - ) - line["discount"] = float_round( - line.get("discount", 0), precision_digits=prec_disc - ) + # @api.model + # def _pre_process_parsed_inv(self, parsed_inv, import_config): + # TODO this method is not called any more, but we may restore it one day + # Rounding totals + # self._pre_process_parsed_inv_rounding(parsed_inv, company) + # if parsed_inv.get("type") in ("out_invoice", "out_refund"): + # return parsed_inv + # Support the 2 refund methods; if method a) is used, we convert to + # method b) + # if not parsed_inv.get("type"): + # parsed_inv["type"] = "in_invoice" # default value + # if ( + # parsed_inv["type"] == "in_invoice" + # and "amount_total" in parsed_inv + # and parsed_inv["currency_rec"].compare_amounts( + # parsed_inv["amount_total"], 0 + # ) + # < 0 + # ): + # parsed_inv["type"] = "in_refund" + # for entry in ["amount_untaxed", "amount_total"]: + # parsed_inv[entry] *= -1 + # for line in parsed_inv.get("lines", []): + # line["qty"] *= -1 + # if "price_subtotal" in line: + # line["price_subtotal"] *= -1 + # Handle taxes: + # self._pre_process_parsed_inv_taxes(parsed_inv, company) + # return parsed_inv @api.model def _pre_process_parsed_inv_taxes( @@ -754,7 +940,7 @@ def _pre_process_parsed_inv_taxes( if line.get("taxes"): if len(line["taxes"]) > 1: parsed_inv["chatter_msg"].append( - _( + self.env._( "You are importing an invoice in company %(company)s " "that cannot deduct VAT and the imported invoice has " "several VAT taxes on the same line (%(line)s). We do " @@ -773,69 +959,368 @@ def _pre_process_parsed_inv_taxes( line["taxes"] = [] @api.model - def _invoice_already_exists(self, parsed_inv, commercial_partner, company_id): - if not parsed_inv.get("invoice_number"): + def _invoice_already_exists(self, parsed_inv, import_config): + invoice_number = parsed_inv.get("BT-1") + if not invoice_number: return False - existing_inv = self.env["account.move"].search( - [ - ("company_id", "=", company_id), + is_refund = is_credit_note(parsed_inv) + domain = [ + ("company_id", "=", import_config["company"].id), + ] + if import_config["invoice_type"] == "in": + commercial_partner = import_config.get("partner") + if not commercial_partner: + return False + move_type = is_refund and "in_refund" or "in_invoice" + domain += [ + ("move_type", "=", move_type), ("commercial_partner_id", "=", commercial_partner.id), - ("move_type", "=", parsed_inv["type"]), - ("ref", "=ilike", parsed_inv["invoice_number"]), - ], - limit=1, - ) + ("ref", "=like", invoice_number), + ] + match_log = ( + f"with move type '{move_type}' partner " + f"'{commercial_partner.display_name}' and reference '{invoice_number}'" + ) + else: + move_type = is_refund and "out_refund" or "out_invoice" + domain += [("name", "=", invoice_number), ("move_type", "=", move_type)] + match_log = ( + f"with move type '{move_type}' and invoice number '{invoice_number}'" + ) + + existing_inv = self.env["account.move"].search(domain, limit=1) + if existing_inv: + msg = ( + f"Found existing invoice {existing_inv.display_name} " + f"ID {existing_inv.id} {match_log}" + ) + self._warning_log(import_config, msg) + import_config["action_warnings"].append( + self.env._( + "Invoice '%(filename)s' already exists in Odoo: %(existing_inv)s.", + filename=import_config.get("filename"), + existing_inv=existing_inv.display_name, + ) + ) + else: + msg = f"No existing invoice found {match_log}" + self._info_log(import_config, msg) return existing_inv - def import_invoices(self): + def _process_invoice_already_exists(self, existing_inv, parsed_inv, import_config): + if import_config.get("if_already_exists") == "raise": + raise UserError( + self.env._( + "Invoice already exists in Odoo: %(existing_inv)s", + existing_inv=existing_inv.display_name, + ) + ) + elif import_config.get("if_already_exists") == "match": + if import_config[ + "invoice_currency" + ] == existing_inv.currency_id and not import_config[ + "invoice_currency" + ].compare_amounts(parsed_inv["BT-112"], existing_inv.amount_total): + import_config["existing_invoice_matched"] = True + return existing_inv + else: + raise UserError( + self.env._( + "The invoice to import already exists (%(existing_inv)s) " + "but it's total amount (%(existing_inv_total_amount)s) " + "doesn't match the total amount of the invoice to import " + "(%(import_inv_total_amount)s).", + existing_inv=existing_inv.display_name, + existing_inv_total_amount=format_amount( + self.env, + existing_inv.amount_total, + existing_inv.currency_id, + ), + import_inv_total_amount=format_amount( + self.env, + parsed_inv["BT-112"], + import_config["invoice_currency"], + ), + ) + ) + else: + return False + + @api.model + def _prepare_speedy(self): + """speedy is a data structure embedded into import_config + that is used to store the fixed data dictionnaries that are + designed to speed-up (country_code2id, currency_code2id, ...) + """ + currency_code2id = { + cur["name"]: cur["id"] + for cur in self.env["res.currency"] + .with_context(active_test=False) + .search_read([], ["name"]) + } + all_countries = self.env["res.country"].search_read([], ["code"]) + country_code2id = {cou["code"]: cou["id"] for cou in all_countries} + country_id2code = {cou["id"]: cou["code"] for cou in all_countries} + installed_langs = [ + lang["code"] for lang in self.env["res.lang"].search_read([], ["code"]) + ] + uom_unece2rec = { + uom.unece_code: uom + for uom in self.env["uom.uom"].search([("unece_code", "!=", False)]) + } + untdid_5153_code2label = { + unece["code"]: unece["name"] + for unece in self.env["unece.code.list"].search_read( + [("type", "=", "tax_type")], ["name", "code"] + ) + } + incoterms_code2id = { + inc["code"]: inc["id"] + for inc in self.env["account.incoterms"] + .with_context(active_test=False) + .search_read([], ["code"]) + } + speedy = { + "currency_code2id": currency_code2id, + "country_code2id": country_code2id, + "country_id2code": country_id2code, + "incoterms_code2id": incoterms_code2id, + "installed_langs": installed_langs, + "uom_unece2rec": uom_unece2rec, + "untdid_5153_code2label": untdid_5153_code2label, + } + return speedy + + # IMPORT CONFIG + # REQUIRED FIELDS when calling public method with import_config arg: + # - company (recordset) or company_id (useful in webservice mode) + # FIELDS that are always set after _update_import_config() are marked with (A) + # FIELDS that are always set after _commercial_partner_update_import_config() + # are marked with (cPA) + # It is important that fields that have a default value such as "if_already_exists" + # are always set after _update_import_config() so that the default value is set + # in a single place in the code + # { + ### + # 'company': company recordset, # required field + # 'company_id': company ID # for useful for webservice mode + # 'invoice_type': 'in' or 'out' # (A) defaults to 'in' + # 'action_warnings': [] # (A) warnings for the notif pop-up for the user + # when the wizard is used + # 'global_warnings': [], # (A) global warnings for the HTML field import_warnings + # 'line_warnings': [], # (A) per-line warnings for the HTML field import_warnings + # 'logs': [], # (A) invoice import logs + # 'filename': 'invoice_sample.pdf', + # 'file_bin': # file as bytes (NOT base64) + # 'create_bank_account': True or False, + # 'webservice_mode': True or False, # if True, create_invoice() returns + # an invoice ID + # "if_already_exists": "return_false", # (A) possible values : + # raise, match, return_false (default) + # "start_end_dates_installed": True or False # (A) True is OCA module + # account_invoice_start_end_dates is installed + # "debug_disable_company_check": True or False, + # 'partner': partner recordset, # force a partner. When this key is used, + # it already has with_company(company) + # 'partner_id': ID # same as previous field, for webservice + # 'email_from': "Alexis de Lattre ", # for message_new() + # 'sale_journal': sale journal recordset, # used to force a journal + # 'sale_journal_id': ID # same as previous field, for webservice mode + # 'purchase_journal': purchase journal recordset, # used to force a journal + # 'purchase_journal_id': ID # same as previous field, for webservice mode + # 'origin': 'invoice origin', # set the 'invoice_origin' char field + # 'invoice_extra_vals': {}, # extra values for invoice create() + # 'vat_tax2rec': {}, # (A) sale or purchase tax properties to recordset + # (sale or purchase tax depend on 'invoice_type') + # 'speedy: { # (A) + # 'currency_code2id': {}, 'country_code2id': {}, 'incoterms_code2id': {}, + # 'installed_langs': {}, 'uom_unece2rec': {}, 'untdid_5153_code2label': {}, + # }, + # 'updated': True, # technical key just to avoid double processing + # of _update_import_config() + # FIELDS below are set by _commercial_partner_update_import_config() + # 'shrink_lines': False, # selection: single/per_vat_rate + # 'analytic_distribution': Analytic distribution, + # 'account': Account recordset, + # 'taxes': taxes multi-recordset, + # 'label': 'Force invoice line description', + # 'product': product recordset, + # 'previous_invoice': invoice recordset, # used + # FIELD below is set by _invoice_currency_update_import_config() + # 'invoice_currency': currency recordset, # set by ... + # FIELDS below are set during the processing of the import + # 'existing_invoice_matched': True # only if "if_already_exists" = "match" + # 'xml_flavor': 'ubl-2.1-invoice' # set by get_flavor() from factur-x lib + # } + + @api.model + def _update_import_config(self, import_config, speedy=None): + # VERY IMPORTANT: add public methods that accept import_config + # as argument must call _update_import_config() at the beginning + # of the method. The "updated" key ensures that we don't do this + # several times + assert isinstance(import_config, dict) + if import_config.get("updated"): + return + # to make it work from JSON-RPC + if not import_config.get("company") and import_config.get("company_id"): + import_config["company"] = self.env["res.company"].browse( + import_config["company_id"] + ) + company = import_config.get("company") + if not company: + raise UserError(self.env._("Missing company in the import configuration.")) + if not import_config.get("invoice_type"): + import_config["invoice_type"] = "in" + if import_config["invoice_type"] not in ("out", "in"): + raise UserError( + self.env._( + "Wrong value for 'invoice_type' key in " + "import configuration (%s)", + import_config["invoice_type"], + ) + ) + # I set global_warnings like this so that devs can inject a warnings + # before calling create_invoice() for example + if not import_config.get("global_warnings"): + import_config["global_warnings"] = [] + if not import_config.get("if_already_exists"): + import_config["if_already_exists"] = "return_false" # set default value + if import_config["if_already_exists"] not in ("raise", "match", "return_false"): + raise UserError( + self.env._( + "Wrong value for 'if_already_exists' key in import_config (%s).", + import_config["if_already_exists"], + ) + ) + if not import_config.get("speedy"): + if speedy is None: + import_config["speedy"] = self._prepare_speedy() + else: + import_config["speedy"] = speedy + line_model = self.env["account.move.line"] + start_end_dates_installed = ( + hasattr(line_model, "start_date") + and hasattr(line_model, "end_date") + or False + ) + type_tax_use = import_config["invoice_type"] == "in" and "purchase" or "sale" + tax_domain = [ + ("type_tax_use", "=", type_tax_use), + ("company_id", "=", company.id), + ("unece_type_code", "=", "VAT"), + ("unece_categ_code", "!=", False), + ("price_include", "=", False), + ("amount_type", "=", "percent"), + ] + vat_tax2rec = { + ( + tax.unece_categ_code, + int(round(tax.amount * 100)), + tax.tax_exigibility, + ): tax + for tax in self.env["account.tax"].search(tax_domain, order="amount desc") + } + + # useful for DBs that have 1 customer partner record + # and 1 supplier partner record for the same legal entity + if import_config["invoice_type"] == "in": + partner_match_order = "supplier_rank DESC, complete_name ASC, id DESC" + else: + partner_match_order = "customer_rank desc, complete_name ASC, id DESC" + + import_config.update( + { + "create_bank_account": company.invoice_import_create_bank_account, + "action_warnings": [], + "logs": [], + # key = import_invoice_line_identifier ; value = list of logs + "line_warnings": defaultdict(list), + "start_end_dates_installed": start_end_dates_installed, + "vat_tax2rec": vat_tax2rec, + "partner_match_order": partner_match_order, + } + ) + # we may have a partner key in import config when create_invoice() is called + # from outside of this module + if import_config.get("partner_id") and not import_config.get("partner"): + import_config["partner"] = ( + self.env["res.partner"] + .browse(import_config["partner_id"]) + .with_company(import_config["company"]) + ) + # Same as for 'company': allow journal ID to make it work with JSON+RPC + if ( + import_config["invoice_type"] == "out" + and import_config.get("sale_journal_id") + and not import_config.get("sale_journal") + ): + import_config["sale_journal"] = self.env["account.journal"].browse( + import_config["sale_journal_id"] + ) + elif ( + import_config["invoice_type"] == "in" + and import_config.get("purchase_journal_id") + and not import_config.get("purchase_journal") + ): + import_config["purchase_journal"] = self.env["account.journal"].browse( + import_config["purchase_journal_id"] + ) + import_config["updated"] = True + + def _invoice_currency_update_import_config(self, parsed_inv, import_config): + if not import_config.get("invoice_currency"): + if parsed_inv.get("BT-5"): + currency_code2id = import_config["speedy"]["currency_code2id"] + currency_id = currency_code2id.get(parsed_inv["BT-5"]) + if currency_id: + currency = self.env["res.currency"].browse(currency_id) + if not currency.active: + import_config["global_warnings"].append( + self.env._( + "Currency %s is inactive in Odoo: " + "you should activate it.", + currency.name, + ) + ) + else: + import_config["global_warnings"].append( + self.env._( + "Currency ISO code '%(invoice_currency_code)s' not found " + "in Odoo: using company currency " + "'%(company_currency_code)s'.", + invoice_currency_code=parsed_inv["BT-5"], + company_currency_code=import_config[ + "company" + ].currency_id.name, + ) + ) + currency = import_config["company"].currency_id + else: + currency = import_config["company"].currency_id + import_config["invoice_currency"] = currency + + def import_invoices_button(self): """Method called by the button of the wizard""" self.ensure_one() - company = self.company_id if not self.invoice_attachment_ids: - raise UserError(_("You must select the vendor bills to import.")) + raise UserError(self.env._("You must select the vendor bills to import.")) invoice_ids = [] - warnings = [] + speedy = self._prepare_speedy() + action_warnings = [] for attach in self.invoice_attachment_ids: - parsed_inv = self.parse_invoice(attach.datas, attach.name, company) - import_config = {"company": company} - if parsed_inv.get("partner"): - partner = self.env["business.document.import"]._match_partner( - parsed_inv["partner"], - parsed_inv["chatter_msg"], - raise_exception=False, - ) - if partner: - # To speed-up next match - parsed_inv["partner"] = {"recordset": partner} - existing_inv = self._invoice_already_exists( - parsed_inv, partner.commercial_partner_id, company.id - ) - if existing_inv: - logger.warning( - "This invoice already exists " - "in Odoo (ID %d number %s supplier number %s)", - existing_inv.id, - existing_inv.name, - parsed_inv.get("invoice_number"), - ) - warnings.append( - _( - "Invoice '%(filename)s' already exists in Odoo: " - "%(existing_inv)s.", - filename=attach.name, - existing_inv=existing_inv.display_name, - ) - ) - continue - - import_config = partner._convert_to_import_config(company) - invoice = self.create_invoice( - parsed_inv, - import_config, - origin=_("Import of file %s", attach.name), - ) - invoice_ids.append(invoice.id) + import_config = { + "company": self.company_id, + "origin": self.env._("Import of file %s", attach.name), + "debug_disable_company_check": self.debug_disable_company_check, + } + self._update_import_config(import_config, speedy) + parsed_inv = self.parse_invoice(attach.raw, attach.name, import_config) + invoice = self.create_invoice(parsed_inv, import_config) + if invoice: + invoice_ids.append(invoice.id) + action_warnings += import_config.get("action_warnings") next_action = self.env["ir.actions.actions"]._for_xml_id( "account.action_move_in_invoice_type" @@ -853,111 +1338,158 @@ def import_invoices(self): } ) else: - if warnings: - raise UserError("\n".join(warnings)) - raise UserError(_("No invoice created.")) + if action_warnings: + raise UserError("\n".join(action_warnings)) + raise UserError(self.env._("No invoice created.")) + msg_type = "success" + sticky = False + if action_warnings: + msg_type = "warning" + sticky = True + sticky = bool(action_warnings) + action_warnings.append( + self.env._("%s vendor bill(s) created", len(invoice_ids)) + ) action = { "type": "ir.actions.client", "tag": "display_notification", "params": { - "type": "success", - "title": _("Import Vendor Bills"), - "message": _("%d vendor bill(s) created", len(invoice_ids)), + "type": msg_type, + "title": self.env._("Import Vendor Bills"), + "message": "\n".join(action_warnings), "next": next_action, + "sticky": sticky, }, } - if warnings: - action["params"].update( - { - "type": "warning", - "message": "\n".join(warnings), - "sticky": True, - } - ) return action + def _check_import_company(self, parsed_inv, import_config): + if import_config.get("debug_disable_company_check") or self.env.context.get( + "edi_skip_company_check" + ): + return True + company_dict = self._get_company_dict(parsed_inv, import_config) + if not company_dict: + return True + raw_vat = company_dict.get("vat_identifier") + company = import_config["company"] + if raw_vat: + invoice_vat = ( + "".join(x for x in raw_vat if not x.isspace()).upper() or False + ) + import_company_vat = company.partner_id.vat + if import_company_vat: + if import_company_vat != invoice_vat: + raise UserError( + self.env._( + "Trying to import invoice in company %(company)s " + "that has VAT number '%(import_company_vat)s' " + "but the invoice to import contains VAT number " + "'%(invoice_vat)s'", + company=company.display_name, + import_company_vat=import_company_vat, + invoice_vat=invoice_vat, + ) + ) + else: + msg = ( + f"Importing invoice in company {company.display_name} that " + f"has VAT number {import_company_vat}: it matches the " + "VAT number written in the invoice" + ) + self._info_log(import_config, msg) + else: + msg = ( + f"Company {import_config['company'].display_name} has no " + "VAT number, so we can't check that we are importing in the " + "right company using VAT number" + ) + self._warning_log(import_config, msg) + else: + msg = ( + "No VAT number for our company in the invoice we are importing, " + "so we can't check that we are importing in the right company " + "using VAT number" + ) + self._warning_log(import_config, msg) + @api.model - def create_invoice(self, parsed_inv, import_config, origin=None): + def create_invoice(self, parsed_inv, import_config): + parsed_inv_for_log = get_data_dict_copy_for_logs(parsed_inv) + logger.info(f"Start to create invoice from data {parsed_inv_for_log}") + self._update_import_config(import_config) + self._invoice_currency_update_import_config(parsed_inv, import_config) amo = self.env["account.move"] - parsed_inv = self._pre_process_parsed_inv(parsed_inv, import_config["company"]) + partner = import_config.get("partner") + # if called from outside of this module, the partner may be already known, + # (for example when creating a customer invoice or module simple_pdf) + if not partner: + partner = self._match_partner(parsed_inv, import_config) + if partner: + partner_wc = partner.with_company(import_config["company"]) + import_config["partner"] = partner_wc + commercial_partner = partner_wc.commercial_partner_id + import_config["commercial_partner"] = commercial_partner + commercial_partner._commercial_partner_update_import_config(import_config) + # self._pre_process_parsed_inv(parsed_inv, import_config) + self._check_import_company(parsed_inv, import_config) + existing_inv = self._invoice_already_exists(parsed_inv, import_config) + if existing_inv: + return self._process_invoice_already_exists( + existing_inv, parsed_inv, import_config + ) vals = self._prepare_create_invoice_vals(parsed_inv, import_config) logger.debug("Invoice vals for creation: %s", vals) invoice = amo.create(vals) self._post_process_invoice(parsed_inv, import_config, invoice) logger.info("Invoice ID %d created", invoice.id) - self.env["business.document.import"].post_create_or_update(parsed_inv, invoice) - invoice.message_post( - body=Markup( - _( - "This invoice has been created automatically via file import. " - "Origin: %s.", - origin or _("unspecified"), - ) - ) - ) + chatter_msg = self._prepare_chatter_message(parsed_inv, import_config) + if chatter_msg: + invoice.message_post(body=Markup(chatter_msg)) + if import_config.get("webservice_mode"): + return invoice.id return invoice - @api.model - def create_invoice_webservice( - self, - invoice_file_b64, - invoice_filename, - company_id, - origin, - email_from=None, - ): - # for invoice_file_b64, we accept it as bytes AND str - # to avoid "Object of type bytes is not JSON serializable" - assert invoice_file_b64 - if isinstance(invoice_file_b64, str): - invoice_file_b64 = invoice_file_b64.encode("utf8") - assert isinstance(invoice_file_b64, bytes) - assert isinstance(invoice_filename, str) - if company_id is None: - company = self.env.company - company_id = company.id - else: - company = self.env["res.company"].browse(company_id) - logger.info( - "Starting to import invoice file %s in company ID %d", - invoice_filename, - company_id, - ) - parsed_inv = self.parse_invoice( - invoice_file_b64, invoice_filename, company, email_from=email_from - ) - partner = self.env["business.document.import"]._match_partner( - parsed_inv["partner"], parsed_inv["chatter_msg"], raise_exception=False - ) - if partner: - partner = partner.commercial_partner_id - # To avoid a second full _match_partner() inside create_invoice() - parsed_inv["partner"]["recordset"] = partner - existing_inv = self._invoice_already_exists(parsed_inv, partner, company_id) - if existing_inv: - logger.warning( - "This supplier invoice already exists " - "in Odoo (ID %d number %s supplier number %s)", - existing_inv.id, - existing_inv.name, - parsed_inv.get("invoice_number"), - ) - return False - import_config = partner._convert_to_import_config(company) - else: - import_config = {"company": company} - invoice = self.create_invoice(parsed_inv, import_config, origin) - return invoice.id + def _prepare_chatter_message(self, parsed_inv, import_config): + chatter_msg = [ + self.env._("This invoice has been created automatically via file import.") + ] + if import_config.get("origin"): + label = self.env._("Origin:") + chatter_msg.append(f"{label} {import_config['origin']}.") + if import_config.get("logs"): + label = self.env._("Detailed import logs below:") + logs = [] + for log_level, msg in import_config["logs"]: + if log_level == "info": + logs.append( + f'' + f"INFO {msg}" + ) + elif log_level == "warning": + logs.append( + f'' + f"WARNING {msg}" + ) + elif log_level == "error": + logs.append( + f'' + f"ERROR {msg}" + ) + chatter_msg.append(f"{label}

    {'
    '.join(logs)}

    ") + return " ".join(chatter_msg) @api.model def _prepare_global_adjustment_line(self, diff_amount, invoice, import_config): cur = invoice.currency_id diff_amount_cmp = cur.compare_amounts(diff_amount, 0) company = invoice.company_id + # TODO : if a product or account is forced, + # I think we should use this account (and product) ? if diff_amount_cmp > 0: if not company.adjustment_debit_account_id: raise UserError( - _( + self.env._( "You must configure the 'Adjustment Debit Account' " "on the Accounting Configuration page of company %(company)s.", company=company.display_name, @@ -968,7 +1500,7 @@ def _prepare_global_adjustment_line(self, diff_amount, invoice, import_config): else: if not company.adjustment_credit_account_id: raise UserError( - _( + self.env._( "You must configure the 'Adjustment Credit Account' " "on the Accounting Configuration page of company %(company)s.", company=company.display_name, @@ -979,23 +1511,23 @@ def _prepare_global_adjustment_line(self, diff_amount, invoice, import_config): if invoice.fiscal_position_id: account = invoice.fiscal_position_id.map_account(account) - il_vals = { + lvals = { "move_id": invoice.id, "display_type": "product", - "name": _("Adjustment"), + "name": self.env._("Adjustment"), "account_id": account.id, "quantity": sign, "price_unit": diff_amount * sign, } - logger.debug("Prepared global adjustment invoice line %s", il_vals) - return il_vals + logger.debug("Prepared global adjustment invoice line %s", lvals) + return lvals def _prepare_adjustment_line(self, iline, diff_amount): vals = { "move_id": iline.move_id.id, "display_type": "product", "account_id": iline.account_id.id, - "name": _("Adjustment on %s") % iline.name, + "name": self.env._("Adjustment on %s") % iline.name, "quantity": 1, "price_unit": diff_amount, "tax_ids": [Command.set(iline.tax_ids.ids)], @@ -1004,18 +1536,20 @@ def _prepare_adjustment_line(self, iline, diff_amount): @api.model def _post_process_invoice(self, parsed_inv, import_config, invoice): - if parsed_inv.get("type") in ("out_invoice", "out_refund"): + if import_config.get("invoice_type") == "out": return amlo = self.env["account.move.line"] inv_cur = invoice.currency_id # If untaxed amount is wrong, create adjustment lines - if parsed_inv["currency_rec"].compare_amounts( - parsed_inv["amount_untaxed"], invoice.amount_untaxed + # TODO wrong when DEEE !!! + if invoice.currency_id.compare_amounts( + parsed_inv["BT-109"], invoice.amount_untaxed ): # Try to find the line that has a problem - for i in range(len(parsed_inv["lines"])): - if "price_subtotal" not in parsed_inv["lines"][i]: + for i in range(len(parsed_inv["BG-25"])): + if "price_subtotal" not in parsed_inv["BG-25"][i]: continue + # TODO continue... at the moment, it will quit here iline = invoice.invoice_line_ids[i] odoo_subtotal = iline.price_subtotal parsed_subtotal = parsed_inv["lines"][i]["price_subtotal"] @@ -1034,40 +1568,44 @@ def _post_process_invoice(self, parsed_inv, import_config, invoice): adj_line = amlo.create(vals) logger.info("Adjustment invoice line created ID %d", adj_line.id) # Fallback: create global adjustment line - if parsed_inv["currency_rec"].compare_amounts( - parsed_inv["amount_untaxed"], invoice.amount_untaxed + if invoice.currency_id.compare_amounts( + parsed_inv["BT-109"], invoice.amount_untaxed ): - diff_amount = inv_cur.round( - parsed_inv["amount_untaxed"] - invoice.amount_untaxed + diff_amount = inv_cur.round(parsed_inv["BT-109"] - invoice.amount_untaxed) + parsed_amount_untaxed_fmt = format_amount( + self.env, parsed_inv["BT-109"], invoice.currency_id ) - logger.info( - "Amount untaxed difference found " "(source: %s, odoo:%s, diff:%s)", - parsed_inv["amount_untaxed"], - invoice.amount_untaxed, - diff_amount, + amount_untaxed_fmt = format_amount( + self.env, invoice.amount_untaxed, invoice.currency_id + ) + msg = ( + f"Amount untaxed difference found: source " + f"{parsed_amount_untaxed_fmt}, odoo {amount_untaxed_fmt}, " + f"diff {format_amount(self.env, diff_amount, invoice.currency_id)}" ) - il_vals = self._prepare_global_adjustment_line( + self._info_log(import_config, msg) + lvals = self._prepare_global_adjustment_line( diff_amount, invoice, import_config ) - mline = amlo.create(il_vals) + mline = amlo.create(lvals) logger.info("Global adjustment invoice line created ID %d", mline.id) - assert not parsed_inv["currency_rec"].compare_amounts( - parsed_inv["amount_untaxed"], invoice.amount_untaxed + assert not invoice.currency_id.compare_amounts( + parsed_inv["BT-109"], invoice.amount_untaxed ) # Force tax amount if necessary - if parsed_inv["currency_rec"].compare_amounts( - invoice.amount_total, parsed_inv["amount_total"] + if invoice.currency_id.compare_amounts( + invoice.amount_total, parsed_inv["BT-112"] ): initial_amount_tax = invoice.amount_tax - invoice._check_total_amount(parsed_inv["amount_total"]) + invoice._check_total_amount(parsed_inv["BT-112"]) # 2 scenarios: forcing tax total was not possible (because # there is no tax at all in invoice lines for example) or # it worked - if parsed_inv["currency_rec"].compare_amounts( - invoice.amount_total, parsed_inv["amount_total"] + if invoice.currency_id.compare_amounts( + invoice.amount_total, parsed_inv["BT-112"] ): - parsed_inv["chatter_msg"].append( - _( + import_config["global_warnings"].append( + self.env._( "The total amount of the imported invoice is " "%(real_amount_total)s whereas the total amount computed by " "Odoo is %(current_amount_total)s. It is the " @@ -1081,14 +1619,14 @@ def _post_process_invoice(self, parsed_inv, import_config, invoice): "Default Taxes on the partner " "(if there are no products on invoice lines).", real_amount_total=format_amount( - self.env, parsed_inv["amount_total"], invoice.currency_id + self.env, parsed_inv["BT-112"], invoice.currency_id ), current_amount_total=format_amount( self.env, invoice.amount_total, invoice.currency_id ), real_amount_tax=format_amount( self.env, - parsed_inv["amount_total"] - parsed_inv["amount_untaxed"], + parsed_inv["BT-112"] - parsed_inv["BT-109"], invoice.currency_id, ), current_amount_tax=format_amount( @@ -1098,8 +1636,8 @@ def _post_process_invoice(self, parsed_inv, import_config, invoice): ) else: - parsed_inv["chatter_msg"].append( - _( + import_config["global_warnings"].append( + self.env._( "The total tax amount has been " "forced to %(forced_amount)s (amount " "computed by Odoo was: %(initial_amount)s).", @@ -1112,72 +1650,6 @@ def _post_process_invoice(self, parsed_inv, import_config, invoice): ) ) - def xpath_to_dict_helper(self, xml_root, xpath_dict, namespaces): - for key, value in xpath_dict.items(): - if isinstance(value, list): - isdate = isfloat = ischar_to_clean = False - if "date" in key: - isdate = True - elif "amount" in key: - isfloat = True - elif key == "name": - ischar_to_clean = True - xpath_dict[key] = self.multi_xpath_helper( - xml_root, - value, - namespaces, - isdate=isdate, - isfloat=isfloat, - ischar_to_clean=ischar_to_clean, - ) - if not xpath_dict[key]: - logger.debug("No value extracted for %s", key) - elif isinstance(value, dict): - xpath_dict[key] = self.xpath_to_dict_helper(xml_root, value, namespaces) - return xpath_dict - - def multi_xpath_helper( - self, - xml_root, - xpath_list, - namespaces, - isdate=False, - isfloat=False, - ischar_to_clean=False, - ): - assert isinstance(xpath_list, list) - for xpath in xpath_list: - xpath_res = xml_root.xpath(xpath, namespaces=namespaces) - if xpath_res and xpath_res[0].text: - if isdate: - if ( - xpath_res[0].attrib - and xpath_res[0].attrib.get("format") != "102" - ): - raise UserError(_("Only the date format 102 is supported.")) - date_dt = datetime.strptime(xpath_res[0].text, "%Y%m%d") - date_str = fields.Date.to_string(date_dt) - return date_str - elif isfloat: - res_float = float(xpath_res[0].text) - return res_float - elif ischar_to_clean: - res_char = xpath_res[0].text - if res_char and isinstance(res_char, str): - # With the experience, we'll probably have more things to clean - res_char = res_char.replace("\n", " ") - return res_char - else: - return xpath_res[0].text - return False - - def raw_multi_xpath_helper(self, xml_root, xpath_list, namespaces): - for xpath in xpath_list: - xpath_res = xml_root.xpath(xpath, namespaces=namespaces) - if xpath_res: - return xpath_res - return [] - @api.model def message_new(self, msg_dict, custom_values=None): """Process the message data from a fetchmail configuration @@ -1263,34 +1735,422 @@ def message_new(self, msg_dict, custom_values=None): attach_bytes = attach.content.encode("utf-8") else: attach_bytes = attach.content - origin = _( - "email sent by {email_from} on {date} " - "with subject {subject}", + origin = self.env._( + "email sent by %(email_from)s on %(date)s " + "with subject %(subject)s", email_from=msg_dict.get("email_from") and html.escape(msg_dict["email_from"]), date=msg_dict.get("date"), subject=msg_dict.get("subject") and html.escape(msg_dict["subject"]), ) + company = self.env + import_config = { + "company_id": company_id, + "email_from": msg_dict.get("email_from"), + "origin": origin, + # "debug_disable_company_check": True, + } + try: - invoice_id = self.create_invoice_webservice( - base64.b64encode(attach_bytes), - filename, - company_id, - origin, - email_from=msg_dict.get("email_from"), + parsed_inv = self.parse_invoice( + attach_bytes, filename, import_config ) + invoice = self.create_invoice(parsed_inv, import_config) logger.info( - "Invoice ID %d created from email attachment %s.", - invoice_id, - filename, + f"Invoice ID {invoice.id} created from " + f"email attachment {filename}." ) - except Exception as e: + except Exception as err: logger.error( - "Failed to import invoice from mail attachment %s. Error: %s", - filename, - e, + f"Failed to import invoice from mail attachment {filename}. " + f"Error: {err}" ) else: logger.info("The email has no attachments, skipped.") return self.create({}) + + def _get_partner_dict(self, parsed_inv, import_config): + if import_config["invoice_type"] == "in": + partner_dict = parsed_inv.get("BG-4") + else: + partner_dict = parsed_inv.get("BG-7") + return partner_dict or {} + + def _get_company_dict(self, parsed_inv, import_config): + if import_config["invoice_type"] == "in": + company_dict = parsed_inv.get("BG-7") + else: + company_dict = parsed_inv.get("BG-4") + return company_dict or {} + + @api.model + def _match_partner(self, parsed_inv, import_config): + partner_dict = self._get_partner_dict(parsed_inv, import_config) + partner = False + if partner_dict.get("vat_identifier"): + raw_vat = partner_dict["vat_identifier"] + vat = "".join(x for x in raw_vat if not x.isspace()).upper() or False + if vat: + partner = self.env["res.partner"].search( + [ + ("vat", "=", vat), + # ('parent_id', '=', False), + ("company_id", "in", (import_config["company"].id, False)), + ], + limit=1, + order=import_config["partner_match_order"], + ) + if partner: + msg = f"Partner '{partner.display_name}' matched via VAT number" + self._info_log(import_config, msg) + return partner + if import_config.get("email_from"): + _partner_name, email = parseaddr(import_config["email_from"]) + if email: + partner = self.env["res.partner"].search( + [ + ("email", "=like", email), + ("company_id", "in", (import_config["company"].id, False)), + ], + limit=1, + order=import_config["partner_match_order"], + ) + if partner: + msg = f"Partner '{partner.display_name}' matched via email" + self._info_log(import_config, msg) + return partner + return partner + + @api.model + def _match_product(self, line_dict, parsed_inv, import_config): + # parsed_inv is useful when inheriting this method + # modify the behavior for a specific supplier + product_obj = self.env["product.product"] + base_domain = [("company_id", "in", (False, import_config["company"].id))] + msg_prefix = f"Invoice line {line_dict['BT-126']}:" + if line_dict.get("BT-157"): + product = product_obj.search( + base_domain + [("barcode", "=", line_dict["BT-157"])], limit=1 + ) + if product: + msg = ( + f"{msg_prefix} product {product.display_name} " + "matched via barcode (BT-157)" + ) + self._info_log(import_config, msg) + return product + else: + msg = ( + f"{msg_prefix} no product with barcode '{line_dict['BT-157']}' " + "found in Odoo" + ) + self._info_log(import_config, msg) + if line_dict.get("BT-156"): + product = product_obj.search( + base_domain + [("default_code", "=", line_dict["BT-156"])], limit=1 + ) + if product: + msg = ( + f"{msg_prefix} product {product.display_name} matched via buyer " + "product identifier (BT-156)" + ) + self._info_log(import_config, msg) + return product + else: + msg = ( + f"{msg_prefix} no product with internal reference " + f"'{line_dict['BT-156']}' (buyer product identifier BT-156) " + "found in Odoo" + ) + self._info_log(import_config, msg) + if line_dict.get("BT-155") and import_config.get("commercial_partner"): + sinfo = self.env["product.supplierinfo"].search( + base_domain + + [ + ("product_code", "=", line_dict["BT-155"]), + ("partner_id", "=", import_config["commercial_partner"].id), + "|", + ("product_id", "!=", False), + ("product_tmpl_id", "!=", False), + ], + limit=1, + ) + if sinfo: + if sinfo.product_id: + product = sinfo.product_id + msg = ( + f"{msg_prefix} product {product.display_name} matched " + "via seller product identifier (BT-155)" + ) + self._info_log(import_config, msg) + return product + else: + product_template = sinfo.product_tmpl_id + assert product_template + if len(product_template.product_variant_ids) == 1: + product = sinfo.product_tmpl_id.product_variant_ids + msg = ( + f"{msg_prefix} product {product.display_name} matched " + "via seller product identifier (BT-155)" + ) + self._info_log(import_config, msg) + return product + elif len(product_template.product_variant_ids) > 1: + product = product_template.product_variant_ids[0] + msg = ( + f"{msg_prefix} product template " + "{product_template.display_name} matched " + "via seller product identifier (BT-155), but this " + "product template is multi-variant. First variant " + "{product.display_name} selected." + ) + self._warning_log(import_config, msg) + return product + else: + msg = ( + f"{msg_prefix} no product with code '{line_dict['BT-155']}' " + "(seller product identifier BT-155) found in the supplier info " + "records of Odoo" + ) + self._info_log(import_config, msg) + # msg = f"NO product with code {line_dict['BT-155']}" + # import_config['line_warnings'][line_dict.get('BT-126')].append(msg) + return None + + @api.model + def _match_uom(self, line_dict, parsed_inv, import_config): + uom_unece2rec = import_config["speedy"]["uom_unece2rec"] + import_remap = { + "EA": "C62", + } + if line_dict.get("BT-130"): + if line_dict["BT-130"] in uom_unece2rec: + return uom_unece2rec[line_dict["BT-130"]] + if line_dict["BT-130"] in import_remap: + uom_code = import_remap[line_dict["BT-130"]] + if uom_code in uom_unece2rec: + return uom_unece2rec[uom_code] + msg = ( + f"Line {line_dict.get('BT-126')}: no unit of measure with " + f"UNECE code '{line_dict['BT-130']}' in Odoo" + ) + # import_config['line_warnings'][line_dict.get('BT-126')].append(msg) + self._warning_log(import_config, msg) + return False + + def _match_vat_tax_common( + self, line_ident, vat_categ_code, vat_rate_int, parsed_inv, import_config + ): + bt8_to_odoo = { + "invoice": "on_invoice", + "delivery": "on_invoice", + "payment": "on_payment", + } + tax_exigibility = ( + parsed_inv.get("BT-8") and bt8_to_odoo.get(parsed_inv["BT-8"]) or False + ) + msg_prefix = f"Invoice line {line_ident}:" + vat_tax2rec = import_config["vat_tax2rec"] + tax = vat_tax2rec.get((vat_categ_code, vat_rate_int, tax_exigibility)) + if tax: + return tax + for (tax_vat_categ_code, tax_vat_rate_int, _exig), tax in vat_tax2rec.items(): + if ( + tax_vat_categ_code == vat_categ_code + and tax_vat_rate_int == vat_rate_int + ): + return tax + for (tax_vat_categ_code, _rate, _exig), tax in vat_tax2rec.items(): + if tax_vat_categ_code == vat_categ_code: + msg = ( + f"{msg_prefix} approx match on tax {tax.display_name} " + "using only VAT categ code" + ) + self._warning_log(import_config, msg) + return tax + return False + + @api.model + def _match_vat_tax_bg25(self, line_dict, parsed_inv, import_config): + vat_categ_code = line_dict.get("BT-151") + vat_rate_int = ( + line_dict.get("BT-152") and int(round(line_dict["BT-152"] * 100)) or False + ) + line_ident = line_dict["BT-126"] + return self._match_vat_tax_common( + line_ident, vat_categ_code, vat_rate_int, parsed_inv, import_config + ) + + @api.model + def _match_vat_tax_bg23(self, line_nr, vat_line_dict, parsed_inv, import_config): + vat_categ_code = vat_line_dict.get("BT-118") + vat_rate_int = ( + vat_line_dict.get("BT-119") + and int(round(vat_line_dict["BT-119"] * 100)) + or False + ) + return self._match_vat_tax_common( + line_nr, vat_categ_code, vat_rate_int, parsed_inv, import_config + ) + + @api.model + def _match_bank_account(self, commercial_partner, parsed_inv, import_config): + assert import_config["invoice_type"] == "in" + bank_account_number = parsed_inv.get("BT-84") + bank_acc_obj = self.env["res.partner.bank"] + if bank_account_number: + sanitized_acc_number = sanitize_account_number(bank_account_number) + bank_account = bank_acc_obj.with_context(active_test=False).search( + [ + ("partner_id", "=", commercial_partner.id), + ("sanitized_acc_number", "=", sanitized_acc_number), + ], + limit=1, + ) + if bank_account: + msg = ( + f"Matched on bank account {bank_account.display_name} " + f"of partner {commercial_partner.display_name} " + f"(allow_out_payment={bank_account.allow_out_payment})" + ) + if not bank_account.allow_out_payment: + import_config["global_warnings"].append( + self.env._( + "Matched with bank account " + "%(bank_account)s, " + "but this bank account is not allowed yet " + "for outgoing payments.", + bank_account=bank_account.display_name, + ) + ) + if not bank_account.active: + import_config["global_warnings"].append( + self.env._( + "Matched with bank account " + "%(bank_account)s, " + "but this bank account is archived. " + "You should re-activate it.", + bank_account=bank_account.display_name, + ) + ) + self._info_log(import_config, msg) + return bank_account + if import_config["create_bank_account"]: + bank_id = False + bic = parsed_inv.get("BT-86") + if bic and len(bic) in (8, 11): + if len(bic) == 8: + alt_bic = f"{bic}XXX" + bank_domain = [("bic", "in", (bic, alt_bic))] + elif len(bic) == 11 and bic.endswith("XXX"): + alt_bic = bic[:8] + bank_domain = [("bic", "in", (bic, alt_bic))] + else: + bank_domain = [("bic", "=", bic)] + bank_ids = list(self.env["res.bank"]._search(bank_domain, limit=1)) + bank_id = bank_ids and bank_ids[0] or False + country_id2code = import_config["speedy"]["country_id2code"] + # for SEPA countries, we don't create bank accounts that are not + # IBANs and where the bank account number doesn't start with the + # country code + sepa_country_group = self.env.ref("base.sepa_zone") + sepa_country_codes = [ + country_id2code[country.id] + for country in sepa_country_group.country_ids + if country_id2code.get(country.id) + ] + vendor_country_code = parsed_inv.get("BG-4", {}).get("country_code") + if vendor_country_code and vendor_country_code in sepa_country_codes: + if not iban_is_valid(sanitized_acc_number): + import_config["global_warnings"].append( + self.env._( + "Bank account '%(acc_number)s' hasn't been created " + "automatically because the vendor's country is in the " + "SEPA zone and the bank account number is not " + "a valid IBAN.", + acc_number=sanitized_acc_number, + ) + ) + return False + if not sanitized_acc_number.startswith(vendor_country_code): + import_config["global_warnings"].append( + self.env._( + "Bank account '%(acc_number)s' hasn't been created " + "automatically because the vendor's country " + "(%(vendor_country_code)s) is different from the " + "country of the IBAN.", + acc_number=sanitized_acc_number, + vendor_country_code=vendor_country_code, + ) + ) + return False + vals = { + "partner_id": commercial_partner.id, + "acc_number": sanitized_acc_number, + "bank_id": bank_id, + } + if hasattr(bank_acc_obj, "acc_type_manual"): + # if OCA module partner_bank_acc_type_constraint is installed + if iban_is_valid(sanitized_acc_number): + vals["acc_type_manual"] = "iban" + else: + vals["acc_type_manual"] = "bank" + bank_account = bank_acc_obj.create(vals) + import_config["global_warnings"].append( + self.env._( + "New bank account %(bank_account)s created. " + "You must check that this bank account really belongs to " + "the vendor and then allow it for outgoing payments.", + bank_account=bank_account.display_name, + ) + ) + else: + import_config["global_warnings"].append( + self.env._( + "The electronic invoice states the bank account number " + "%(bank_account_number)s, " + "but this bank account doesn't exist in Odoo for partner " + "%(commercial_partner)s and the option to " + "auto-create bank accounts upon invoice import is not enabled.", + bank_account_number=bank_account_number, + commercial_partner=commercial_partner.display_name, + ) + ) + return bank_account + return False + + @api.model + def _info_log(self, import_config, msg): + logger.info(msg) + import_config["logs"].append(("info", msg)) + + @api.model + def _warning_log(self, import_config, msg): + logger.warning(msg) + import_config["logs"].append(("warning", msg)) + + @api.model + def _error_log(self, import_config, msg): + logger.error(msg) + import_config["logs"].append(("error", msg)) + + @api.model + def _get_lang_from_country_code(self, country_code, import_config): + assert country_code + try: + locale = Locale.parse(f"und_{country_code.upper()}") + country_lang_code = locale.language + except Exception as err: + msg = f"Failed to get lang from country code '{country_code}': Error: {err}" + self._warning_log(import_config, msg) + return False + full_lang_code = f"{country_lang_code}_{country_code}" + installed_langs = import_config["speedy"]["installed_langs"] + for lang_code in installed_langs: + if lang_code == full_lang_code: + return lang_code + for lang_code in installed_langs: + if country_lang_code == lang_code[:2]: + return lang_code + return False diff --git a/account_invoice_import/wizard/account_invoice_import_partner_create.py b/account_invoice_import/wizard/account_invoice_import_partner_create.py index 29efb00a6b..5fe57b1156 100644 --- a/account_invoice_import/wizard/account_invoice_import_partner_create.py +++ b/account_invoice_import/wizard/account_invoice_import_partner_create.py @@ -45,12 +45,24 @@ def default_get(self, fields_list): res["import_partner_data"] = import_partner_data if import_partner_data.get("vat"): res["partner_vat"] = import_partner_data["vat"] + # Partner may have been created in the meantime + existing_partner = self.env["res.partner"].search( + [ + ("company_id", "in", (False, move.company_id.id)), + ("vat", "=", import_partner_data["vat"]), + ], + limit=1, + ) + if existing_partner: + update_partner_id = existing_partner.commercial_partner_id.id + res.update( + { + "update_partner_id": update_partner_id, + "create_or_update": "update", + } + ) if import_partner_data.get("name"): res["partner_name"] = import_partner_data["name"] - # Partner may have been created in the meantime - res["update_partner_id"] = self.env[ - "business.document.import" - ]._match_partner(import_partner_data, [], raise_exception=False) return res def create_partner(self): diff --git a/account_invoice_import/wizard/account_invoice_import_view.xml b/account_invoice_import/wizard/account_invoice_import_view.xml index e200063766..402c713631 100644 --- a/account_invoice_import/wizard/account_invoice_import_view.xml +++ b/account_invoice_import/wizard/account_invoice_import_view.xml @@ -11,22 +11,35 @@

    Upload below the vendor bills/refunds as PDF or XML file: Odoo will create a draft vendor bills refund for each file.

    + >Upload below the vendor bills/refunds as PDF or XML file: Odoo will create a draft vendor bill/refund for each file.

    Supported formats:

    -
      +
        +
      • UBL (Universal Business Language) XML,
      • +
      • CII (Cross Industry Invoice) XML,
      • +
      • Factur-X (PDF invoice with embedded CII XML file),
      • +

      If the format you need is not listed above, you should install an additionnal Odoo module that adds support for that format (e.g. account_invoice_import_facturx, account_invoice_import_ubl, account_invoice_import_simple_pdf, etc).

      + >If the format you need is not listed above, you should install an additionnal Odoo module that adds support for that format.

    - + + +