diff --git a/README.md b/README.md index 5de1866..5552c20 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ Odoo modules for e-invoicing and e-reporting in France starting september 1st 2026. -This set of modules depends on several OCA modules. At the moment, we don't require any specific pull request, but you should make sure that you are running up-to-date code for the following OCA repositories: +This set of modules depends on several OCA modules. Make sure that you are running up-to-date code for the following OCA repositories: * [OCA/community-data-files](https://github.com/OCA/community-data-files) * [OCA/edi](https://github.com/OCA/edi) * [OCA/l10n-france](https://github.com/OCA/l10n-france) * [OCA/account-financial-tools](https://github.com/OCA/account-financial-tools) -For example, on OCA/community-data-files, you need to have code dated after july 17th 2026... so, when we say that you need up-to-date code, we mean it ! +For the module **account_invoice_import** from OCA/edi, use the code from [pull request 1386](https://github.com/OCA/edi/pull/1386) You should also make sure that the code of Odoo 18.0 you are running on is up-to-date. @@ -28,19 +28,19 @@ You should also make sure that the code of Odoo 18.0 you are running on is up-to [//]: # (addons) -Available addons ----------------- +Unported addons +--------------- addon | version | maintainers | summary --- | --- | --- | --- -[account_invoice_en16931](account_invoice_en16931/) | 18.0.1.0.0 | alexis-via | Base module to generate electronic invoices -[account_invoice_en16931_py3o](account_invoice_en16931_py3o/) | 18.0.1.0.0 | alexis-via | Glue module to generate EN16931 invoices with Py3o -[l10n_fr_account_invoice_en16931](l10n_fr_account_invoice_en16931/) | 18.0.1.0.0 | alexis-via | Localization of Invoice EN16931 for France -[l10n_fr_einvoicing](l10n_fr_einvoicing/) | 18.0.1.2.0 | alexis-via | Community implementation of the e-invoicing reform for France -[l10n_fr_einvoicing_dashboard_banner](l10n_fr_einvoicing_dashboard_banner/) | 18.0.1.0.0 | alexis-via | Add widgets for eInvoicing flows in Accounting Dashboard Banner -[l10n_fr_einvoicing_import](l10n_fr_einvoicing_import/) | 18.0.1.0.0 | alexis-via | Import vendor bills/refunds from accredited platform -[l10n_fr_einvoicing_payment_batch_oca](l10n_fr_einvoicing_payment_batch_oca/) | 18.0.1.0.0 | alexis-via | Option to auto-send payment sent event -[l10n_fr_einvoicing_purchase](l10n_fr_einvoicing_purchase/) | 18.0.1.0.0 | alexis-via | Display directory line on purchase order report -[l10n_fr_einvoicing_sale](l10n_fr_einvoicing_sale/) | 18.0.1.0.0 | alexis-via | eInvoicing for France in Sales +[account_invoice_en16931](account_invoice_en16931/) | 19.0.1.0.0 (unported) | alexis-via | Base module to generate electronic invoices +[account_invoice_en16931_py3o](account_invoice_en16931_py3o/) | 19.0.1.0.0 (unported) | alexis-via | Glue module to generate EN16931 invoices with Py3o +[l10n_fr_account_invoice_en16931](l10n_fr_account_invoice_en16931/) | 19.0.1.0.0 (unported) | alexis-via | Localization of Invoice EN16931 for France +[l10n_fr_einvoicing](l10n_fr_einvoicing/) | 19.0.1.0.0 (unported) | alexis-via | Community implementation of the e-invoicing reform for France +[l10n_fr_einvoicing_dashboard_banner](l10n_fr_einvoicing_dashboard_banner/) | 19.0.1.0.0 (unported) | alexis-via | Add widgets for eInvoicing flows in Accounting Dashboard Banner +[l10n_fr_einvoicing_import](l10n_fr_einvoicing_import/) | 19.0.1.0.0 (unported) | alexis-via | Import vendor bills/refunds from accredited platform +[l10n_fr_einvoicing_payment_batch_oca](l10n_fr_einvoicing_payment_batch_oca/) | 19.0.1.0.0 (unported) | alexis-via | Option to auto-send payment sent event +[l10n_fr_einvoicing_purchase](l10n_fr_einvoicing_purchase/) | 19.0.1.0.0 (unported) | alexis-via | Display directory line on purchase order report +[l10n_fr_einvoicing_sale](l10n_fr_einvoicing_sale/) | 19.0.1.0.0 (unported) | alexis-via | eInvoicing for France in Sales [//]: # (end addons) diff --git a/account_invoice_en16931/__manifest__.py b/account_invoice_en16931/__manifest__.py index d3c6070..c4d9b2d 100644 --- a/account_invoice_en16931/__manifest__.py +++ b/account_invoice_en16931/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Invoice EN16931", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Base module to generate electronic invoices", @@ -19,7 +19,12 @@ "intrastat_base", ], "excludes": ["account_einvoice_generate"], - "external_dependencies": {"python": ["factur-x>=6.5"]}, + # fonttools is what odoo.tools.pdf.convert_to_pdfa() uses to rebuild the + # glyph width arrays produced by wkhtmltopdf; without it the PDF/A-3 + # output fails veraPDF rule 6.2.11.5 and Odoo only logs a warning. + # No upper pin needed on 19.0: convert_to_pdfa() handles both the old + # getGlyphSet()._hmtx and the current hMetrics API. + "external_dependencies": {"python": ["factur-x>=6.7", "fonttools"]}, "data": [ "security/ir.model.access.csv", "wizards/account_invoice_en16931_generate_view.xml", diff --git a/account_invoice_en16931/i18n/account_invoice_en16931.pot b/account_invoice_en16931/i18n/account_invoice_en16931.pot index 9b2a5c9..adc8532 100644 --- a/account_invoice_en16931/i18n/account_invoice_en16931.pot +++ b/account_invoice_en16931/i18n/account_invoice_en16931.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-17 06:35+0000\n" -"PO-Revision-Date: 2026-07-17 06:35+0000\n" +"POT-Creation-Date: 2026-08-08 11:49+0000\n" +"PO-Revision-Date: 2026-08-08 11:49+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -32,6 +32,33 @@ msgstr "" msgid "Archive Format" msgstr "" +#. module: account_invoice_en16931 +#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids +#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_attachment_ids +msgid "" +"Attachments added to the electronic invoice. In UBL and CII XML, these " +"attachments are added in the XML (BG-24 / BT-125). In Factur-X, these " +"attachments are added as additional attachments of the PDF." +msgstr "" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__business_process_type +msgid "Business Process Type" +msgstr "" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__business_process_type +msgid "Business Term BT-23 in EN16931" +msgstr "" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_type_code +#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_type_code +msgid "Business Term BT-3 in EN16931" +msgstr "" + #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_invoice_en16931_generate__invoice_format__cii msgid "CII XML" @@ -49,7 +76,7 @@ msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__380 -msgid "Commercial invoice" +msgid "Commercial Invoice" msgstr "" #. module: account_invoice_en16931 @@ -81,7 +108,7 @@ msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__384 -msgid "Corrected invoice" +msgid "Corrected Invoice" msgstr "" #. module: account_invoice_en16931 @@ -102,7 +129,7 @@ msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__381 -msgid "Credit note" +msgid "Credit Note" msgstr "" #. module: account_invoice_en16931 @@ -133,12 +160,6 @@ msgstr "" msgid "Default PDF Invoice Generation" msgstr "" -#. module: account_invoice_en16931 -#. odoo-python -#: code:addons/account_invoice_en16931/models/res_company.py:0 -msgid "Discount decimal precision is %s. For EN16931, the maximum value is 2." -msgstr "" - #. module: account_invoice_en16931 #: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__display_name msgid "Display Name" @@ -176,17 +197,17 @@ msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__472 -msgid "Factored Corrective Invoice, invoice type, Corrected" +msgid "Factored Corrective Invoice" msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__396 -msgid "Factored credit note" +msgid "Factored Credit Note" msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__393 -msgid "Factored invoice" +msgid "Factored Invoice" msgstr "" #. module: account_invoice_en16931 @@ -219,6 +240,22 @@ msgid "" "%(err)s" msgstr "" +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Failed to generate the UBL-2.1 XML file with profile 'extended-ctc-fr'. " +"Error: %(err)s" +msgstr "" + +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Field 'Invoice Type Code' is required on customer invoices/refunds, but it " +"is not set on '%s'." +msgstr "" + #. module: account_invoice_en16931 #: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__file_data msgid "File Data" @@ -244,12 +281,36 @@ msgstr "" msgid "ID" msgstr "" +#. module: account_invoice_en16931 +#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form +msgid "" +"If this option is enabled, an error will be raised if the Saxon server is " +"down, unreachable or experience a technical failure during schematron " +"validation." +msgstr "" + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "Invoice" msgstr "" +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Invoice '%(invoice)s' has 2 e-invoice attachments with the same filename " +"'%(filename)s'." +msgstr "" + +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Invoice '%(move)s' has Invoice Type Code '%(type_code)s' which is for " +"refunds." +msgstr "" + #. module: account_invoice_en16931 #: model:ir.model,name:account_invoice_en16931.model_account_invoice_en16931_generate msgid "Invoice EN16931 Generate" @@ -270,7 +331,7 @@ msgstr "" #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "" -"Invoice line '%(inv_line)s' has several VAT taxes (%(vat_taxes)s). EN-16931 " +"Invoice line '%(inv_line)s' has several VAT taxes (%(vat_taxes)s). EN16931 " "only allows one VAT tax." msgstr "" @@ -339,12 +400,12 @@ msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__503 -msgid "Prepayment credit note, credit note type, Corrected" +msgid "Prepayment Credit Note" msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__386 -msgid "Prepayment invoice" +msgid "Prepayment Invoice" msgstr "" #. module: account_invoice_en16931 @@ -361,12 +422,25 @@ msgid "" "value is 4." msgstr "" +#. module: account_invoice_en16931 +#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_config_settings__saxon_validation_blocking +msgid "Raise Error if Saxon Validation Fails" +msgstr "" + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "Refund" msgstr "" +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Refund '%(move)s' has Invoice Type Code '%(type_code)s' which is for " +"invoices." +msgstr "" + #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__res_company__en16931_default_pdf_invoice__none msgid "Regular PDF invoice" @@ -378,38 +452,38 @@ msgid "Report Action" msgstr "" #. module: account_invoice_en16931 -#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__500 -msgid "Self Prepayment invoice, invoice type, Original" -msgstr "" - -#. module: account_invoice_en16931 -#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__473 -msgid "Self billed Factored corrective invoice, invoice type, Corrected" +#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__471 +msgid "Self-billed Corrective Invoice" msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__261 -msgid "Self billed credit note" +msgid "Self-billed Credit Note" msgstr "" #. module: account_invoice_en16931 -#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__501 -msgid "Self billed factored invoice, invoice type, Original" +#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__473 +msgid "Self-billed Factored Corrective Invoice" msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__502 -msgid "Self billet factored Credit Note, Credit note type, Corrected" +msgid "Self-billed Factored Credit Note" msgstr "" #. module: account_invoice_en16931 -#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__471 -msgid "Self-billed corrective invoice, invoice type, Corrected" +#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__501 +msgid "Self-billed Factored Invoice" msgstr "" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__389 -msgid "Self-billed invoice" +msgid "Self-billed Invoice" +msgstr "" + +#. module: account_invoice_en16931 +#: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__500 +msgid "Self-billed Prepayment Invoice" msgstr "" #. module: account_invoice_en16931 @@ -499,7 +573,7 @@ msgstr "" #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "" -"There is no VAT tax on invoice line '%(inv_line)s' of invoice '%(invoice)s'." +"There is no VAT tax on invoice line '%(inv_line)s'." " You must set a VAT tax on each invoice line in company '%(company)s' " "because it is a VAT-registered company." msgstr "" @@ -587,6 +661,23 @@ msgstr "" msgid "VAT tax '%s' has no UNECE Tax Category." msgstr "" +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"You cannot add an e-invoice attachment with filename '%s' because this " +"filename is reserved." +msgstr "" + +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"You cannot add e-invoice attachment '%(filename)s' whose MIME type is " +"'%(mimetype)s'. Allowed MIME types for e-invoice attachments are: " +"%(allowed_mimetypes)s." +msgstr "" + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/wizards/account_invoice_en16931_generate.py:0 @@ -610,8 +701,20 @@ msgstr "" msgid "draft_invoice" msgstr "" +#. module: account_invoice_en16931 +#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form +msgid "eInvoice" +msgstr "" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__invoice_attachment_ids +msgid "eInvoice Attachments" +msgstr "" + #. module: account_invoice_en16931 #: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form msgid "eInvoicing" msgstr "" diff --git a/account_invoice_en16931/i18n/fr.po b/account_invoice_en16931/i18n/fr.po index ae0bc94..45d2dfb 100644 --- a/account_invoice_en16931/i18n/fr.po +++ b/account_invoice_en16931/i18n/fr.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 21:09+0000\n" -"PO-Revision-Date: 2026-07-24 21:09+0000\n" +"POT-Creation-Date: 2026-08-08 11:49+0000\n" +"PO-Revision-Date: 2026-08-08 11:49+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -32,6 +32,37 @@ msgstr "(pas de date)" msgid "Archive Format" msgstr "Format d'archive" +#. module: account_invoice_en16931 +#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids +#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_attachment_ids +msgid "" +"Attachments added to the electronic invoice. In UBL and CII XML, these " +"attachments are added in the XML (BG-24 / BT-125). In Factur-X, these " +"attachments are added as additional attachments of the PDF." +msgstr "" +"Pièces jointes ajoutées à la facture électronique. En UBL et CII XML, ces " +"pièces jointes sont ajoutées dans le XML (BG-24 / BT-125). En Factur-X, ces " +"pièces jointes sont ajoutées comme des pièces jointes supplémentaires du " +"PDF." + +#. module: account_invoice_en16931 +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__business_process_type +msgid "Business Process Type" +msgstr "Type de processus métier" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__business_process_type +msgid "Business Term BT-23 in EN16931" +msgstr "Terme métier BT-23 dans l'EN16931" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,help:account_invoice_en16931.field_account_bank_statement_line__invoice_type_code +#: model:ir.model.fields,help:account_invoice_en16931.field_account_move__invoice_type_code +msgid "Business Term BT-3 in EN16931" +msgstr "Terme métier BT-3 dans l'EN16931" + #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_invoice_en16931_generate__invoice_format__cii msgid "CII XML" @@ -61,7 +92,7 @@ msgstr "Sociétés" #: model:ir.model.fields,field_description:account_invoice_en16931.field_res_company__no_vat_taxes_vatex_id #: model:ir.model.fields,field_description:account_invoice_en16931.field_res_config_settings__no_vat_taxes_vatex_id msgid "Company VAT Exemption Reason" -msgstr "Motif d'exemption de TVA de la société" +msgstr "Motif d'exonération de TVA de la société" #. module: account_invoice_en16931 #: model:ir.model.fields,field_description:account_invoice_en16931.field_res_company__no_vat_taxes @@ -135,14 +166,6 @@ msgstr "BROUILLON-TEST_UNIQUEMENT" msgid "Default PDF Invoice Generation" msgstr "Génération de facture PDF par défaut" -#. module: account_invoice_en16931 -#. odoo-python -#: code:addons/account_invoice_en16931/models/res_company.py:0 -msgid "Discount decimal precision is %s. For EN16931, the maximum value is 2." -msgstr "" -"La précision décimale des remises est %s. Pour EN16931, la précision " -"maximale est de 2." - #. module: account_invoice_en16931 #: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__display_name msgid "Display Name" @@ -233,6 +256,16 @@ msgstr "" "La génération du fichier XML %(flavor)s avec le profil %(level)s a échouée. " "Erreur: %(err)s" +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Failed to generate the UBL-2.1 XML file with profile 'extended-ctc-fr'. " +"Error: %(err)s" +msgstr "" +"Échec lors de la génération du fichier XML UBL-2.1 avec le profil 'extended-" +"ctc-fr'. Erreur : %(err)s" + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 @@ -240,6 +273,8 @@ msgid "" "Field 'Invoice Type Code' is required on customer invoices/refunds, but it " "is not set on '%s'." msgstr "" +"Le champ 'Code du type de facture' est obligatoire sur les factures/avoirs " +"client, mais il n'est pas renseigné sur '%s'." #. module: account_invoice_en16931 #: model:ir.model.fields,field_description:account_invoice_en16931.field_account_invoice_en16931_generate__file_data @@ -266,12 +301,32 @@ msgstr "Générer les factures électroniques" msgid "ID" msgstr "" +#. module: account_invoice_en16931 +#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form +msgid "" +"If this option is enabled, an error will be raised if the Saxon server is " +"down, unreachable or experience a technical failure during schematron " +"validation." +msgstr "" +"Si cette option est activée, un message d'erreur bloquant sera affiché si le serveur Saxon " +"est en panne, injoignable ou a un problème technique lors de la validation des schematrons." + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "Invoice" msgstr "Facture" +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"Invoice '%(invoice)s' has 2 e-invoice attachments with the same filename " +"'%(filename)s'." +msgstr "" +"La facture '%(invoice)s' a 2 pièces jointes pour la facturation électronique" +" avec le même nom de fichier '%(filename)s'." + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 @@ -279,6 +334,8 @@ msgid "" "Invoice '%(move)s' has Invoice Type Code '%(type_code)s' which is for " "refunds." msgstr "" +"La facture '%(move)s' a un code du type de facture '%(type_code)s' qui est " +"dédié aux avoirs." #. module: account_invoice_en16931 #: model:ir.model,name:account_invoice_en16931.model_account_invoice_en16931_generate @@ -377,7 +434,7 @@ msgstr "Facture PDF avec UBL XML en pièce jointe" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__503 msgid "Prepayment Credit Note" -msgstr "Avoir de facture d'acompte" +msgstr "Avoir d'acompte" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__386 @@ -402,6 +459,11 @@ msgstr "" "La précision décimale de l'unité de mesure du produit est de %s. Pour " "EN16931, la valeur maximale est de 4." +#. module: account_invoice_en16931 +#: model:ir.model.fields,field_description:account_invoice_en16931.field_res_config_settings__saxon_validation_blocking +msgid "Raise Error if Saxon Validation Fails" +msgstr "Erreur bloquante si la validation Saxon échoue" + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 @@ -415,6 +477,8 @@ msgid "" "Refund '%(move)s' has Invoice Type Code '%(type_code)s' which is for " "invoices." msgstr "" +"L'avoir '%(move)s' a un code du type de facture '%(type_code)s' qui est pour" +" les factures." #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__res_company__en16931_default_pdf_invoice__none @@ -429,37 +493,37 @@ msgstr "Action de rapport" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__471 msgid "Self-billed Corrective Invoice" -msgstr "Facture rectificative auto-facturée" +msgstr "Facture rectificative en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__261 msgid "Self-billed Credit Note" -msgstr "Avoir auto-facturé" +msgstr "Avoir en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__473 msgid "Self-billed Factored Corrective Invoice" -msgstr "Facture rectificative auto-facturée affacturée" +msgstr "Facture rectificative affacturée en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__502 msgid "Self-billed Factored Credit Note" -msgstr "Avoir auto-facturé affacturé" +msgstr "Avoir affacturé en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__501 msgid "Self-billed Factored Invoice" -msgstr "Facture auto-facturée affacturée" +msgstr "Facture affacturée en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__389 msgid "Self-billed Invoice" -msgstr "Facture auto-facturée" +msgstr "Facture en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_move__invoice_type_code__500 msgid "Self-billed Prepayment Invoice" -msgstr "Facture d'acompte auto-facturée" +msgstr "Facture d'acompte en auto-facturation" #. module: account_invoice_en16931 #: model:ir.model.fields.selection,name:account_invoice_en16931.selection__account_invoice_en16931_generate__type__single @@ -535,7 +599,7 @@ msgstr "" "La société actuelle n'a aucune taxe de TVA active. Soit c'est une erreur et " "il faudrait mettre à jour la configuration des taxes. Ou c'est normal (car " "la société n'est pas assujettie à la TVA par exemple) et il vous faut alors " -"configurer le motif d'exemption de TVA ci-dessous." +"configurer le motif d'exonération de TVA ci-dessous." #. module: account_invoice_en16931 #: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form @@ -562,12 +626,12 @@ msgstr "" #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "" -"There is no VAT tax on invoice line '%(inv_line)s' of invoice '%(invoice)s'." +"There is no VAT tax on invoice line '%(inv_line)s'." " You must set a VAT tax on each invoice line in company '%(company)s' " "because it is a VAT-registered company." msgstr "" -"Il n'y a pas de taxe de TVA sur la ligne '%(inv_line)s' de la facture " -"'%(invoice)s'. Il est obligatoire de configurer une taxe de TVA sur chaque " +"Il n'y a pas de taxe de TVA sur la ligne '%(inv_line)s'." +" Il est obligatoire de configurer une taxe de TVA sur chaque " "ligne de facture pour la société '%(company)s' car elle est assujettie à la " "TVA." @@ -621,7 +685,7 @@ msgid "" " Exemption Reason." msgstr "" "La taxe de TVA '%(tax)s' a pour catégorie UNECE '%(categ)s', et devrait donc" -" être configurée avec un motif d'exemption de TVA." +" être configurée avec un motif d'exonération de TVA." #. module: account_invoice_en16931 #. odoo-python @@ -631,7 +695,7 @@ msgid "" " VAT Exemption Reason." msgstr "" "La taxe de TVA '%(tax)s' a pour catégorie UNECE '%(categ)s', et ne devrait " -"donc pas être configurée avec un motif d'exemption de TVA." +"donc pas être configurée avec un motif d'exonération de TVA." #. module: account_invoice_en16931 #. odoo-python @@ -670,6 +734,29 @@ msgstr "" msgid "VAT tax '%s' has no UNECE Tax Category." msgstr "La taxe de TVA '%s' n'a pas de catégorie UNECE." +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"You cannot add an e-invoice attachment with filename '%s' because this " +"filename is reserved." +msgstr "" +"Vous ne pouvez pas ajouter une pièce jointe pour la facturation électronique" +" avec le nom de fichier '%s' car ce nom de fichier est réservé." + +#. module: account_invoice_en16931 +#. odoo-python +#: code:addons/account_invoice_en16931/models/account_move.py:0 +msgid "" +"You cannot add e-invoice attachment '%(filename)s' whose MIME type is " +"'%(mimetype)s'. Allowed MIME types for e-invoice attachments are: " +"%(allowed_mimetypes)s." +msgstr "" +"Vous ne pouvez pas ajouter une pièce jointe pour la facturation électronique" +" '%(filename)s' dont le type MIME est '%(mimetype)s'. Les types MIME " +"autorisés pour les pièces jointes pour la facturation électronique sont : " +"%(allowed_mimetypes)s." + #. module: account_invoice_en16931 #. odoo-python #: code:addons/account_invoice_en16931/wizards/account_invoice_en16931_generate.py:0 @@ -691,10 +778,22 @@ msgstr "" #. odoo-python #: code:addons/account_invoice_en16931/models/account_move.py:0 msgid "draft_invoice" -msgstr "" +msgstr "facture_brouillon" + +#. module: account_invoice_en16931 +#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form +msgid "eInvoice" +msgstr "Facture électronique" + +#. module: account_invoice_en16931 +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_bank_statement_line__invoice_attachment_ids +#: model:ir.model.fields,field_description:account_invoice_en16931.field_account_move__invoice_attachment_ids +msgid "eInvoice Attachments" +msgstr "Pièces jointes pour la facturation électronique" #. module: account_invoice_en16931 #: model_terms:ir.ui.view,arch_db:account_invoice_en16931.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:account_invoice_en16931.view_move_form msgid "eInvoicing" msgstr "Facturation électronique" diff --git a/account_invoice_en16931/models/account_move.py b/account_invoice_en16931/models/account_move.py index 03161fe..c11ebb2 100644 --- a/account_invoice_en16931/models/account_move.py +++ b/account_invoice_en16931/models/account_move.py @@ -5,6 +5,7 @@ import base64 import logging +import sys from io import BytesIO from pprint import pformat from urllib.parse import urljoin @@ -15,11 +16,7 @@ from odoo import api, fields, models from odoo.exceptions import UserError, ValidationError -from odoo.tools import ( - float_compare, - html2plaintext, - is_html_empty, -) +from odoo.tools import config, float_compare, html2plaintext, is_html_empty from odoo.tools.misc import format_amount, format_date logger = logging.getLogger(__name__) @@ -27,8 +24,33 @@ try: from facturx import generate_from_file, generate_xml except (OSError, ImportError) as err: - logger.debug("Cannot import facturx. Error details below.") - logger.debug(err) + # Odoo 19 registers an import hook on stdnum (odoo/_monkeypatches/stdnum.py) + # which swaps the package loader for a SimpleNamespace exposing only + # create_module and exec_module. That fake loader has no + # get_resource_reader, so importlib.resources can no longer reach the .dat + # files that stdnum.iban and its siblings read at import time, and importing + # facturx dies on "Can't open orphan path". The patch it installs is a no-op + # from python-stdnum 2.0 on, so dropping the hook and importing again is + # safe. Remove once odoo/odoo fixes the loader (still there on master). + logger.info("Cannot import facturx, retrying without the stdnum import hook.") + logger.info(err) + try: + from odoo._monkeypatches import HOOK_IMPORT + + HOOK_IMPORT.hooks.discard("stdnum") + for _stdnum_module in [ + name + for name in list(sys.modules) + if name == "stdnum" or name.startswith("stdnum.") + ]: + del sys.modules[_stdnum_module] + from facturx import generate_from_file, generate_xml + except (OSError, ImportError) as retry_err: + # Left at warning on purpose: a silent debug here means the module loads + # fine and only fails much later, on generation, with a NameError on + # generate_xml that says nothing about the real cause. + logger.warning("Cannot import facturx. Error details below.") + logger.warning(retry_err) DIRECT_DEBIT_CODES = ("49", "59") @@ -46,6 +68,17 @@ "473", ) REFUND_TYPE_CODES = ("261", "381", "396", "502", "503") +RESERVED_INV_ATTACHMENT_FILENAMES = ("factur-x.xml", "factur-xubl.xml") +INV_ATTACHMENT_ALLOWED_MIMETYPES = ( + "application/pdf", + "image/png", + "image/jpeg", + "text/csv", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.oasis.opendocument.spreadsheet", + "text/xml", + "application/xml", +) class AccountMove(models.Model): @@ -53,33 +86,35 @@ class AccountMove(models.Model): invoice_type_code = fields.Selection( [ - ("261", "Self-billed Credit Note"), # Avoir auto-facturé - ("380", "Commercial Invoice"), # Facture - ("381", "Credit Note"), # Avoir - ("384", "Corrected Invoice"), # Facture rectificative - ("386", "Prepayment Invoice"), # Facture d'acompte - ("389", "Self-billed Invoice"), # Facture auto-facturée - ("393", "Factored Invoice"), # Facture affacturée - ("396", "Factored Credit Note"), # Avoir affacturé + ("261", "Self-billed Credit Note"), + ("380", "Commercial Invoice"), + ("381", "Credit Note"), + ("384", "Corrected Invoice"), + ("386", "Prepayment Invoice"), + ("389", "Self-billed Invoice"), + ("393", "Factored Invoice"), + ("396", "Factored Credit Note"), ( "471", "Self-billed Corrective Invoice", - ), # Facture rectificative auto-facturée - ("472", "Factored Corrective Invoice"), # Facture rectificative affacturée + ), + ("472", "Factored Corrective Invoice"), ( "473", "Self-billed Factored Corrective Invoice", - ), # Facture rectificative auto-facturée affacturée + ), ( "500", "Self-billed Prepayment Invoice", - ), # Facture d'acompte auto-facturée - ("501", "Self-billed Factored Invoice"), # Facture auto-facturée affacturée - ("502", "Self-billed Factored Credit Note"), # Avoir auto-facturé affacturé - ("503", "Prepayment Credit Note"), # Avoir de facture d'acompte + ), + ("501", "Self-billed Factored Invoice"), + ("502", "Self-billed Factored Credit Note"), + ("503", "Prepayment Credit Note"), ], compute="_compute_invoice_type_code", store=True, + tracking=True, + help="Business Term BT-3 in EN16931", ) # we disallow manual modification for the moment, because we would # need to filter depending on invoice vs refund @@ -88,18 +123,90 @@ class AccountMove(models.Model): # It's also useful for in invoice/refund to store the value that was # present in the XML of the Vendor bill, so that it can then be used # for life cycles (info needed in CDAR XML) + business_process_type = fields.Selection( + [], copy=False, tracking=True, help="Business Term BT-23 in EN16931" + ) + invoice_attachment_ids = fields.Many2many( + "ir.attachment", + "account_move_invoice_attachment_rel", + string="eInvoice Attachments", + copy=False, + help="Attachments added to the electronic invoice. In UBL and CII XML, " + "these attachments are added in the XML (BG-24 / BT-125). In Factur-X, " + "these attachments are added as additional attachments of the PDF.", + ) - @api.depends("move_type") + @api.depends("move_type", "invoice_line_ids") def _compute_invoice_type_code(self): + # sale module + has_is_downpayment = hasattr(self.env["account.move.line"], "is_downpayment") + if has_is_downpayment: + qty_prec = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) for move in self: type_code = False if move.is_invoice(include_receipts=True): - if move.move_type in ("in_refund", "out_refund"): - type_code = "381" - else: - type_code = "380" + if has_is_downpayment: + for line in move.invoice_line_ids: + if ( + line.display_type == "product" + and line.is_downpayment + and float_compare( + line.quantity, 0, precision_digits=qty_prec + ) + > 0 + ): + if move.move_type in ("in_refund", "out_refund"): + type_code = "503" + else: + type_code = "386" + break + if not type_code: + if move.move_type in ("in_refund", "out_refund"): + type_code = "381" + else: + type_code = "380" + move.invoice_type_code = type_code + @api.constrains("invoice_attachment_ids") + def _check_invoice_attachment_ids(self): + for move in self: + filenames = set() + for attach in move.invoice_attachment_ids: + if attach.name.lower() in RESERVED_INV_ATTACHMENT_FILENAMES: + raise ValidationError( + self.env._( + "You cannot add an e-invoice attachment with " + "filename '%s' because this filename is reserved.", + attach.name, + ) + ) + if attach.name in filenames: + raise ValidationError( + self.env._( + "Invoice '%(invoice)s' has 2 e-invoice attachments " + "with the same filename '%(filename)s'.", + invoice=move.display_name, + filename=attach.name, + ) + ) + filenames.add(attach.name) + if attach.mimetype not in INV_ATTACHMENT_ALLOWED_MIMETYPES: + raise ValidationError( + self.env._( + "You cannot add e-invoice attachment '%(filename)s' " + "whose MIME type is '%(mimetype)s'. Allowed MIME types " + "for e-invoice attachments are: %(allowed_mimetypes)s.", + filename=attach.name, + mimetype=attach.mimetype, + allowed_mimetypes=", ".join( + INV_ATTACHMENT_ALLOWED_MIMETYPES + ), + ) + ) + @api.constrains("move_type", "invoice_type_code") def _check_invoice_type_code(self): type_code2label = dict( @@ -140,77 +247,39 @@ def _check_invoice_type_code(self): ) def _post(self, soft=True): - for move in self.filtered(lambda x: x.is_sale_document()): - move.company_id._en16931_checks() - errors = [] - if not move.company_id.no_vat_taxes: - for line in move.invoice_line_ids.filtered( - lambda x: x.display_type == "product" - ): - vat_tax = False - for tax in line.tax_ids: - # either we check both active and inactive taxes in - # company_id._en16931_checks() or we block invoice validation - # on inactive taxes - if not tax.active: - errors.append( - self.env._( - "Invoice line '%(inv_line)s' has tax '%(tax)s' " - "which is not active.", - inv_line=line.display_name, - tax=tax.display_name, - ) - ) - if tax.unece_type_code == "VAT": - if vat_tax: - errors.append( - self.env._( - "Invoice line '%(inv_line)s' has several " - "VAT taxes (%(vat_taxes)s). EN16931 only " - "allows one VAT tax.", - inv_line=line.display_name, - vat_taxes=", ".join( - [ - t.display_name - for t in line.tax_ids - if t.unece_type_code == "VAT" - ] - ), - ) - ) - else: - vat_tax = tax - if not vat_tax: - errors.append( - self.env._( - "There is no VAT tax on invoice line '%(inv_line)s' " - "of invoice '%(invoice)s'. You must set a VAT tax on " - "each invoice line in company '%(company)s' because " - "it is a VAT-registered company.", - inv_line=line.display_name, - invoice=move.display_name, - company=move.company_id.display_name, - ) + for move in self: + if ( + move.is_sale_document() + and not config["test_enable"] + and not self.env.context.get("skip_en16931_checks_upon_post") + ): + if move.company_id.en16931_issuer: + move.company_id._en16931_checks() + errors = [] + if not move.company_id.no_vat_taxes: + for line in move.invoice_line_ids.filtered( + lambda x: x.display_type == "product" + ): + line._post_check_en16931_sale_document(errors) + if move.currency_id.compare_amounts(move.amount_untaxed, 0) < 0: + errors.append( + self.env._( + "Total Untaxed Amount (%(amount_untaxed)s) is negative. " + "This is not supported by the EN16931 standard.", + amount_untaxed=format_amount( + self.env, move.amount_untaxed, move.currency_id + ), ) - if move.currency_id.compare_amounts(move.amount_untaxed, 0) < 0: - errors.append( - self.env._( - "Total Untaxed Amount (%(amount_untaxed)s) is negative. " - "This is not supported by the EN16931 standard.", - amount_untaxed=format_amount( - self.env, move.amount_untaxed, move.currency_id - ), ) - ) - if errors: - raise UserError( - self.env._( - "Errors on invoice '%(inv)s' for EN16931 " - "e-invoicing:\n%(err_msg)s", - inv=move.display_name, - err_msg="\n".join([f"- {error}" for error in errors]), + if errors: + raise UserError( + self.env._( + "Errors on invoice '%(inv)s' for EN16931 " + "e-invoicing:\n%(err_msg)s", + inv=move.display_name, + err_msg="\n".join([f"- {error}" for error in errors]), + ) ) - ) return super()._post(soft=soft) def _en16931_checks_upon_invoice_generation(self): @@ -307,39 +376,11 @@ def _prepare_bt20(self, speedy): def _prepare_bt23(self, speedy): self.ensure_one() - # OCA module intrastat_base - has_is_accessory_cost = hasattr( - self.env["product.template"], "is_accessory_cost" - ) - # If an invoice line has no product, we consider it is a service - line_types = [ - ( - line.product_id and line.product_id.type or "service", - has_is_accessory_cost and line.product_id.is_accessory_cost or False, - ) - for line in self.invoice_line_ids - if line.display_type == "product" - ] - service_only = all( - [ptype == "service" for (ptype, is_accessory_cost) in line_types] - ) - at_least_one_product = any( - [ptype == "consu" for (ptype, is_accessory_cost) in line_types] - ) - all_products_or_accessory_costs = all( - [ - ptype == "consu" or is_accessory_cost - for (ptype, is_accessory_cost) in line_types - ] - ) - paid = self.payment_state == "paid" - if service_only: - business_process_type = paid and "S2" or "S1" - elif at_least_one_product and all_products_or_accessory_costs: - business_process_type = paid and "B2" or "B1" + if self.business_process_type: + # [3:] to skip the country prefix + return self.business_process_type[3:] else: - business_process_type = paid and "M2" or "M1" - return business_process_type + return None def _prepare_bt34_with_scheme(self, speedy): self.ensure_one() @@ -500,6 +541,19 @@ def _prepare_bg24(self, speedy, pdf_invoice_bin): "BT-125-2": filename, } ) + for attach in self.invoice_attachment_ids: + if attach.type == "binary": + bg24.append( + { + "BT-122": attach.name, + "BT-125": attach.datas, + "BT-125-1": attach.mimetype, + "BT-125-2": attach.name, + # for Factur-X + "modification_datetime": attach.write_date, + "creation_datetime": attach.create_date, + } + ) return bg24 def _prepare_en16931_payment_data(self, speedy): @@ -666,7 +720,7 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): seller_partner_data = self.company_id.partner_id._en16931_partner_data() if self.user_id: vals["BT-41"] = self.user_id.name - phone = self.user_id.partner_id.mobile or self.user_id.partner_id.phone + phone = self.user_id.partner_id.phone if phone: vals["BT-42"] = phone vals["BT-43"] = self.user_id.partner_id.email @@ -740,7 +794,9 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): vals["BG-24"] = self._prepare_bg24(speedy, pdf_invoice_bin) return vals - def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False): + def generate_en16931_xml( + self, flavor, level, invoice_format, pdf_invoice_bin=False + ): self.ensure_one() assert self.is_sale_document() data_dict = self._generate_en16931_dict(pdf_invoice_bin=pdf_invoice_bin) @@ -748,6 +804,7 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False): if ( hasattr(self, "fr_directory_partner_entity_type") and self.fr_directory_company_entity_type == "private" + and not self.env.context.get("chorus_old_xml_syntax") ): if self.fr_directory_partner_entity_type == "private": check_schematron = "fr-ctc" @@ -758,18 +815,56 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False): saxon_server_codedb_base_url = self._get_saxon_server_codedb_base_url() if saxon_server_codedb_dir: saxon_server_codedb_base_url = None + saxon_server_raise_if_http_error = self._get_saxon_server_raise_if_http_error() logger.debug( f"Calling generate_xml with " f"saxon_server_codedb_dir={saxon_server_codedb_dir} and " - f"saxon_server_codedb_base_url={saxon_server_codedb_base_url}" + f"saxon_server_codedb_base_url={saxon_server_codedb_base_url} and " + f"saxon_server_raise_if_http_error={saxon_server_raise_if_http_error}" ) - res = {} - for flavor, level in flavor2level.items(): - try: - xml_bytes = generate_xml( - data_dict, + attachments = {} + # for Factur-X, we prefer to have attachments in PDF rather than inside XML + # (and we don't want to have both !) + if invoice_format.startswith("facturx"): + for attach in data_dict.get("BG-24", []): + if attach.get("BT-125") and attach.get("BT-125-2"): + vals = {"filedata": base64.decodebytes(attach["BT-125"])} + if attach.get("modification_datetime"): + vals["modification_datetime"] = attach["modification_datetime"] + if attach.get("creation_datetime"): + vals["creation_datetime"] = attach["creation_datetime"] + attachments[attach["BT-125-2"]] = vals + data_dict.pop("BG-24") + try: + xml_bytes = generate_xml( + data_dict, + flavor=flavor, + level=level, + check_xsd=True, + check_schematron=check_schematron, + saxon_server_url=saxon_server_url, + saxon_server_codedb_base_url=saxon_server_codedb_base_url, + saxon_server_codedb_dir=saxon_server_codedb_dir, + saxon_server_raise_if_http_error=saxon_server_raise_if_http_error, + ) + except Exception as err: + logger.warning("data_dict dumped below") + logger.warning(pformat(data_dict)) + raise UserError( + self.env._( + "Failed to generate the %(flavor)s XML file " + "with profile %(level)s. Error: %(err)s", flavor=flavor, level=level, + err=str(err), + ) + ) from err + if invoice_format == "facturx_ubl": + try: + ubl_xml_bytes = generate_xml( + data_dict, + flavor="ubl-2.1", + level="extended-ctc-fr", check_xsd=True, check_schematron=check_schematron, saxon_server_url=saxon_server_url, @@ -781,15 +876,18 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False): logger.warning(pformat(data_dict)) raise UserError( self.env._( - "Failed to generate the %(flavor)s XML file " - "with profile %(level)s. Error: %(err)s", - flavor=flavor, - level=level, + "Failed to generate the UBL-2.1 XML file " + "with profile 'extended-ctc-fr'. Error: %(err)s", err=str(err), ) ) from err - res[flavor] = xml_bytes - return res + # Factur-X standard v1.09, end of section 6.4, specifies + # that, if we add a UBL XML as attachment, filename should be + # factur-xubl.xml. I don't like this name, but it's the standard ! + attachments["factur-xubl.xml"] = { + "filedata": ubl_xml_bytes, + } + return xml_bytes, attachments def _prepare_facturx_pdf_metadata(self): self.ensure_one() @@ -827,58 +925,44 @@ def _prepare_facturx_pdf_metadata(self): } return pdf_metadata - def _get_pdf_invoice_variant(self): - """Returns the variant, but only if it is possible to generate the XML + def _get_pdf_invoice_format(self): + """Returns the invoice_format, but only if it is possible to generate the XML Otherwize return False""" self.ensure_one() - variant = self.company_id.en16931_default_pdf_invoice + invoice_format = self.company_id.en16931_default_pdf_invoice # I want to allow embedded XML even on draft invoice # So I write here the conditions to be able to generate a valid XML if ( - variant - and variant != "none" + invoice_format + and invoice_format != "none" and self.is_sale_document() and self.partner_id and self.state != "cancel" and self.invoice_line_ids.filtered(lambda x: x.display_type == "product") ): - return variant + return invoice_format else: return False - def _prepare_facturx_attachments(self): - # This method is designed to be inherited in other modules - self.ensure_one() - return {} - def _prepare_ubl_attachment_filename(self): self.ensure_one() return "UBL-invoice.xml" - def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, variant): + def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, invoice_format): self.ensure_one() assert pdf_bytesio, "Missing pdf_bytesio" - if variant in ("facturx", "facturx_ubl"): + if invoice_format.startswith("facturx"): pdf_metadata = self._prepare_facturx_pdf_metadata() lang = ( self.partner_id.lang and self.partner_id.lang.replace("_", "-") or None ) # Generate a new PDF with XML file as attachment - attachments = self._prepare_facturx_attachments() - flavor2level = {"factur-x": "extended"} - if variant == "facturx_ubl": - flavor2level["ubl-2.1"] = "extended-ctc-fr" - flavor2xmlbytes = self.generate_en16931_xml(flavor2level) - if variant == "facturx_ubl": - # Factur-X standard v1.09, end of section 6.4, specifies - # that, if we add a UBL XML as attachment, filename should be - # factur-xubl.xml. I don't like this name, but it's the standard - attachments["factur-xubl.xml"] = { - "filedata": flavor2xmlbytes["ubl-2.1"] - } + xml_bytes, attachments = self.generate_en16931_xml( + "factur-x", "extended", invoice_format + ) generate_from_file( pdf_bytesio, - flavor2xmlbytes["factur-x"], + xml_bytes, flavor="factur-x", level="extended", check_xsd=False, @@ -888,10 +972,11 @@ def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, variant): attachments=attachments, ) logger.info("Factur-X PDF invoice successfully generated") - elif variant == "pdf_ubl": - flavor2level = {"ubl-2.1": "extended-ctc-fr"} - flavor2xmlbytes = self.generate_en16931_xml(flavor2level) - ubl_xml_bytes = flavor2xmlbytes["ubl-2.1"] + self._en16931_pdf_to_pdfa(pdf_bytesio) + elif invoice_format == "pdf_ubl": + ubl_xml_bytes = self.generate_en16931_xml( + "ubl-2.1", "extended-ctc-fr", invoice_format + )[0] pdf_writer = PdfWriter(clone_from=pdf_bytesio) embedded_file = pdf_writer.add_attachment( filename=self._prepare_ubl_attachment_filename(), data=ubl_xml_bytes @@ -903,9 +988,41 @@ def _regular_pdf_invoice_to_en16931_pdf_invoice(self, pdf_bytesio, variant): } ) pdf_writer.write(pdf_bytesio) + self._en16931_pdf_to_pdfa(pdf_bytesio) + + def _en16931_pdf_to_pdfa(self, pdf_bytesio): + """Turn the Factur-X PDF into a valid PDF/A-3. + + factur-x embeds the XML and writes the Factur-X XMP declaring + pdfaid:part=3 — but it converts nothing. The source PDF comes from + wkhtmltopdf and is not PDF/A: no sRGB OutputIntent, glyph width arrays + inconsistent with the embedded fonts, PDF header not 1.7. veraPDF fails + on those (ISO 19005-3 clauses 6.2.4.3 and 6.2.11.5), so a file that + claims PDF/A-3 is rejected by a conformance check. + + odoo.tools.pdf ships OdooPdfFileWriter.convert_to_pdfa(); run the + already-Factur-X PDF through it. cloneReaderDocumentRoot keeps the + embedded XML and the Factur-X XMP; convert_to_pdfa() adds the + OutputIntent, rebuilds the glyph widths and fixes the header/ID. + """ + # Imported here to keep the module import list portable across versions. + from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter + + pdf_bytesio.seek(0) + reader = OdooPdfFileReader(pdf_bytesio, strict=False) + writer = OdooPdfFileWriter() + writer.cloneReaderDocumentRoot(reader) + if not writer.is_pdfa: + writer.convert_to_pdfa() + out = BytesIO() + writer.write(out) + pdf_bytesio.seek(0) + pdf_bytesio.truncate(0) + pdf_bytesio.write(out.getvalue()) + pdf_bytesio.seek(0) def _get_pdf_invoice_bin(self): - """Inherit if you use a reporting engine other than qweb""" + """This works with both qweb and py3o""" self.ensure_one() pdf_invoice_bin, _filetype = ( self.env["ir.actions.report"] @@ -927,21 +1044,27 @@ def _get_en16931_invoice_bin(self, invoice_format, b64=False): elif invoice_format == "ubl_pdf": pdf_invoice_bin = self._get_pdf_invoice_bin() invoice_bin = self.generate_en16931_xml( - {"ubl-2.1": "extended-ctc-fr"}, pdf_invoice_bin=pdf_invoice_bin - )["ubl-2.1"] + "ubl-2.1", + "extended-ctc-fr", + invoice_format, + pdf_invoice_bin=pdf_invoice_bin, + )[0] elif invoice_format == "ubl": - invoice_bin = self.generate_en16931_xml({"ubl-2.1": "extended-ctc-fr"})[ - "ubl-2.1" - ] + invoice_bin = self.generate_en16931_xml( + "ubl-2.1", "extended-ctc-fr", invoice_format + )[0] elif invoice_format == "cii_pdf": pdf_invoice_bin = self._get_pdf_invoice_bin() invoice_bin = self.generate_en16931_xml( - {"facturx": "extended-ctc-fr"}, pdf_invoice_bin=pdf_invoice_bin - )["facturx"] + "facturx", + "extended-ctc-fr", + invoice_format, + pdf_invoice_bin=pdf_invoice_bin, + )[0] elif invoice_format == "cii": - invoice_bin = self.generate_en16931_xml({"facturx": "extended-ctc-fr"})[ - "facturx" - ] + invoice_bin = self.generate_en16931_xml( + "facturx", "extended-ctc-fr", invoice_format + )[0] else: raise ValueError("Wrong value for invoice_format arg") if b64: @@ -978,3 +1101,14 @@ def _get_saxon_server_codedb_base_url(self): if web_base_url: return urljoin(web_base_url, "en16931/") return None + + @api.model + def _get_saxon_server_raise_if_http_error(self): + saxon_validation_blocking = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("en16931.saxon_validation_blocking") + ) + if saxon_validation_blocking and saxon_validation_blocking == "True": + return True + return False diff --git a/account_invoice_en16931/models/account_move_line.py b/account_invoice_en16931/models/account_move_line.py index b89c233..5784825 100644 --- a/account_invoice_en16931/models/account_move_line.py +++ b/account_invoice_en16931/models/account_move_line.py @@ -23,9 +23,53 @@ class AccountMoveLine(models.Model): _inherit = "account.move.line" + def _en16931_get_vat_taxes(self): + """Method designed to be inherited to support exotic setups""" + self.ensure_one() + return self.tax_ids.filtered(lambda x: x.unece_type_code == "VAT") + + def _post_check_en16931_sale_document(self, errors): + self.ensure_one() + assert self.display_type == "product" + for tax in self.tax_ids: + # either we check both active and inactive taxes in + # company_id._en16931_checks() or we block invoice validation + # on inactive taxes + if not tax.active: + errors.append( + self.env._( + "Invoice line '%(inv_line)s' has tax '%(tax)s' " + "which is not active.", + inv_line=self.display_name, + tax=tax.display_name, + ) + ) + vat_taxes = self._en16931_get_vat_taxes() + if not vat_taxes: + errors.append( + self.env._( + "There is no VAT tax on invoice line '%(inv_line)s'. " + "You must set a VAT tax on " + "each invoice line in company '%(company)s' because " + "it is a VAT-registered company.", + inv_line=self.display_name, + company=self.company_id.display_name, + ) + ) + elif len(vat_taxes) > 1: + errors.append( + self.env._( + "Invoice line '%(inv_line)s' has several " + "VAT taxes (%(vat_taxes)s). EN16931 only " + "allows one VAT tax.", + inv_line=self.display_name, + vat_taxes=", ".join([tax.display_name for tax in vat_taxes]), + ) + ) + def _check_en16931(self, speedy): self.ensure_one() - vat_tax = self.tax_ids.filtered(lambda x: x.unece_type_code == "VAT") + vat_tax = self._en16931_get_vat_taxes() if speedy["company_no_vat_taxes"]: assert not vat_tax vat_dict = speedy["vat_info4company_no_vat_taxes"] @@ -45,7 +89,13 @@ def _check_en16931(self, speedy): ) assert vat_tax.unece_categ_code vat_dict = {"categ_code": vat_tax.unece_categ_code} - if vat_tax.unece_categ_code in ("S", "K", "G"): + # BT-152 is required on the line for every VAT category but O: + # BR-S-05 wants the rate, BR-Z-05, BR-E-05, BR-AE-05, BR-G-05 and + # BR-IC-05 all want an explicit 0, and only BR-O-05 wants it left + # out. Restricting this to S/K/G dropped BT-152 on exempt lines, + # which BR-E-05 rejects and which then breaks BR-FXEXT-E-08: the + # schematron sums no line at all against the E breakdown. + if vat_tax.unece_categ_code != "O": vat_dict["vat_rate"] = vat_tax.amount if vat_tax.unece_categ_code not in ("S", "Z"): assert vat_tax.unece_vatex_code diff --git a/account_invoice_en16931/models/ir_actions_report.py b/account_invoice_en16931/models/ir_actions_report.py index 92d6ddb..f73de98 100644 --- a/account_invoice_en16931/models/ir_actions_report.py +++ b/account_invoice_en16931/models/ir_actions_report.py @@ -26,8 +26,10 @@ def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None): and not self.env.context.get("regular_pdf_invoice") ): move = amo.browse(res_ids) - variant = move._get_pdf_invoice_variant() - if variant: + invoice_format = move._get_pdf_invoice_format() + if invoice_format: pdf_bytesio = collected_streams[move.id]["stream"] - move._regular_pdf_invoice_to_en16931_pdf_invoice(pdf_bytesio, variant) + move._regular_pdf_invoice_to_en16931_pdf_invoice( + pdf_bytesio, invoice_format + ) return collected_streams diff --git a/account_invoice_en16931/models/res_company.py b/account_invoice_en16931/models/res_company.py index c175237..aaea692 100644 --- a/account_invoice_en16931/models/res_company.py +++ b/account_invoice_en16931/models/res_company.py @@ -19,6 +19,14 @@ class ResCompany(models.Model): default="facturx", string="Default PDF Invoice Generation", ) + en16931_issuer = fields.Boolean( + string="Issues EN16931 e-Invoices", + help="Check the EN16931 configuration of this company when a customer " + "invoice is posted. Leave unchecked for companies that never emit an " + "EN16931 document: their tax configuration is then only checked when " + "such a document is actually generated. Localisation modules may set " + "this field for the companies they cover.", + ) no_vat_taxes = fields.Boolean( compute="_compute_no_vat_taxes", string="Company has no VAT Taxes" ) @@ -69,15 +77,6 @@ def _en16931_checks(self): qty_prec, ) ) - disc_prec = dpo.precision_get("Discount") - if disc_prec > 2: - errors.append( - self.env._( - "Discount decimal precision is %s. For EN16931, the maximum " - "value is 2.", - disc_prec, - ) - ) if errors: raise UserError( self.env._( diff --git a/account_invoice_en16931/models/res_partner.py b/account_invoice_en16931/models/res_partner.py index 6c11190..f57eb9a 100644 --- a/account_invoice_en16931/models/res_partner.py +++ b/account_invoice_en16931/models/res_partner.py @@ -31,7 +31,7 @@ def _en16931_partner_data(self, country_required=True): "zip": self.zip, "city": self.city, "country_code": country and country.code or False, - "phone": self.phone or self.mobile, + "phone": self.phone, "email": self.email, "vat": self.commercial_partner_id.vat, } diff --git a/account_invoice_en16931/views/account_move.xml b/account_invoice_en16931/views/account_move.xml index 7f64091..fa822e0 100644 --- a/account_invoice_en16931/views/account_move.xml +++ b/account_invoice_en16931/views/account_move.xml @@ -9,9 +9,37 @@ account.move - - - + + + + + + + + + + + + -
+ +
- - + + + + + diff --git a/l10n_fr_einvoicing_dashboard_banner/__manifest__.py b/l10n_fr_einvoicing_dashboard_banner/__manifest__.py index 7c9b0ec..74425d6 100644 --- a/l10n_fr_einvoicing_dashboard_banner/__manifest__.py +++ b/l10n_fr_einvoicing_dashboard_banner/__manifest__.py @@ -4,7 +4,7 @@ { "name": "France eInvoicing Dashboard Banner", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Add widgets for eInvoicing flows in Accounting Dashboard Banner", @@ -16,5 +16,5 @@ "l10n_fr_einvoicing", ], "post_init_hook": "create_fr_einvoicing_dashboard_cells", - "installable": True, + "installable": False, } diff --git a/l10n_fr_einvoicing_directory_import/__init__.py b/l10n_fr_einvoicing_directory_import/__init__.py new file mode 100644 index 0000000..aee8895 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/l10n_fr_einvoicing_directory_import/__manifest__.py b/l10n_fr_einvoicing_directory_import/__manifest__.py new file mode 100644 index 0000000..6b5991a --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Sudokeys +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "France eInvoicing Directory Import/Export (CSV)", + "version": "19.0.1.0.0", + "category": "Accounting/Localizations/EDI", + "summary": "Maintain fr.directory.line manually via CSV export/import " + "when the AFNOR directory API is not available", + "author": "Sudokeys", + "website": "https://github.com/akretion/fr-einvoicing", + "license": "AGPL-3", + "depends": [ + "l10n_fr_einvoicing", + ], + "data": [ + "security/ir.model.access.csv", + "wizards/fr_directory_csv_wizard_views.xml", + ], + "installable": True, +} diff --git a/l10n_fr_einvoicing_directory_import/examples/exemple-retour-annuaire.csv b/l10n_fr_einvoicing_directory_import/examples/exemple-retour-annuaire.csv new file mode 100644 index 0000000..1db94a5 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/examples/exemple-retour-annuaire.csv @@ -0,0 +1,4 @@ +siren,siret,routing_code,routing_code_name,state,commitment_required +497828095,49782809500012,,,active,0 +450487905,45048790500018,SERVICE_COMPTA,Comptabilité,active,1 +503908576,,,,upcoming,0 diff --git a/l10n_fr_einvoicing_directory_import/i18n/fr.po b/l10n_fr_einvoicing_directory_import/i18n/fr.po new file mode 100644 index 0000000..a43a146 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/i18n/fr.po @@ -0,0 +1,247 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_fr_einvoicing_directory_import +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-27 08:13+0000\n" +"PO-Revision-Date: 2026-07-27 08:13+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "%(c)s SIREN exported in %(f)s file(s) (max 5000 lines / 1 MB each)." +msgstr "%(c)s SIREN exportés dans %(f)s fichier(s) (max 5000 lignes / 1 Mo chacun)." + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "1. Export SIREN (directory deposit)" +msgstr "1. Export des SIREN (dépôt annuaire)" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "2. Import the directory return" +msgstr "2. Import du retour de l'annuaire" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"facturation.chorus-" +"pro.gouv.fr/annuaire" +msgstr "" +"facturation.chorus-" +"pro.gouv.fr/annuaire" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Close" +msgstr "Fermer" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_file +msgid "Directory return CSV" +msgstr "CSV retour de l'annuaire" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.ui.menu,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_menu +msgid "Directory — CSV import/export" +msgstr "Annuaire — Import/Export CSV" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "Empty or unreadable CSV file." +msgstr "Fichier CSV vide ou illisible." + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_filename +msgid "Export Filename" +msgstr "Nom du fichier exporté" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,help:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing +msgid "" +"Export only companies that do not have any directory line yet (i.e. not " +"registered in the directory)." +msgstr "" +"N'exporte que les sociétés qui n'ont encore aucune ligne d'annuaire " +"(c.-à-d. non enregistrées dans l'annuaire)." + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.actions.act_window,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_action +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "France eInvoicing — Directory (CSV import/export)" +msgstr "Facturation électronique FR — Annuaire (import/export CSV)" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Generate SIREN CSV" +msgstr "Générer le CSV des SIREN" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Generated file" +msgstr "Fichier généré" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__id +msgid "ID" +msgstr "ID" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_filename +msgid "Import Filename" +msgstr "Nom du fichier importé" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "" +"Import done: %(c)s created, %(u)s updated, %(s)s skipped, %(a)s ambiguous." +msgstr "" +"Import terminé : %(c)s créée(s), %(u)s mise(s) à jour, %(s)s ignorée(s), " +"%(a)s ambiguë(s)." + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Import return CSV" +msgstr "Importer le CSV retour" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_csv_wizard +msgid "Import/Export eInvoicing directory via CSV" +msgstr "Import/Export de l'annuaire e-facturation via CSV" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_uid +msgid "Last Updated by" +msgstr "Mis à jour par" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_date +msgid "Last Updated on" +msgstr "Mis à jour le" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "No SIREN column found. Columns: %s" +msgstr "Aucune colonne SIREN trouvée. Colonnes : %s" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "No SIREN to export for the selected companies." +msgstr "Aucun SIREN à exporter pour les sociétés sélectionnées." + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"Official directory (deposit the SIREN file, then download the\n" +" return):" +msgstr "" +"Annuaire officiel (déposez le fichier SIREN, puis téléchargez le\n" +" retour) :" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"One SIREN per line, without header — to deposit on the\n" +" official directory. Files are capped at 5000 lines / 1 MB;\n" +" a ZIP of several CSV is produced when needed." +msgstr "" +"Un SIREN par ligne, sans en-tête — à déposer sur l'annuaire\n" +" officiel. Les fichiers sont limités à 5000 lignes / 1 Mo ;\n" +" un ZIP de plusieurs CSV est produit si besoin." + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing +msgid "Only companies without a directory line" +msgstr "Uniquement les sociétés sans ligne d'annuaire" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "Please upload the directory return CSV first." +msgstr "Chargez d'abord le CSV retour de l'annuaire." + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_summary +msgid "Result Summary" +msgstr "Résumé de l'import" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Return CSV" +msgstr "CSV retour" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "" +"Row %(n)s: SIREN %(s)s is shared by several companies — linked to %(p)s." +msgstr "" +"Ligne %(n)s : le SIREN %(s)s est partagé par plusieurs sociétés — rattaché " +"à %(p)s." + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "Row %(n)s: no partner found for SIREN %(s)s." +msgstr "Ligne %(n)s : aucun partenaire trouvé pour le SIREN %(s)s." + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_file +msgid "SIREN file" +msgstr "Fichier SIREN" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"Upload the CSV returned by the directory (Chorus Pro format),\n" +" then import it to create/update the directory lines." +msgstr "" +"Chargez le CSV renvoyé par l'annuaire (format Chorus Pro),\n" +" puis importez-le pour créer/mettre à jour les lignes " +"d'annuaire." + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_partner_ids +msgid "Updated companies" +msgstr "Sociétés mises à jour" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "View updated companies" +msgstr "Voir les sociétés mises à jour" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_line +msgid "eInvoicing Directory Line for France" +msgstr "Ligne d'annuaire e-facturation pour la France" diff --git a/l10n_fr_einvoicing_directory_import/i18n/l10n_fr_einvoicing_directory_import.pot b/l10n_fr_einvoicing_directory_import/i18n/l10n_fr_einvoicing_directory_import.pot new file mode 100644 index 0000000..6d9ae53 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/i18n/l10n_fr_einvoicing_directory_import.pot @@ -0,0 +1,230 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_fr_einvoicing_directory_import +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e-20260630\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-27 08:20+0000\n" +"PO-Revision-Date: 2026-07-27 08:20+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "%(c)s SIREN exported in %(f)s file(s) (max 5000 lines / 1 MB each)." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "1. Export SIREN (directory deposit)" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "2. Import the directory return" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"facturation.chorus-" +"pro.gouv.fr/annuaire" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Close" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_file +msgid "Directory return CSV" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.ui.menu,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_menu +msgid "Directory — CSV import/export" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "Empty or unreadable CSV file." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_filename +msgid "Export Filename" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,help:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing +msgid "" +"Export only companies that do not have any directory line yet (i.e. not " +"registered in the directory)." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.actions.act_window,name:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_action +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "France eInvoicing — Directory (CSV import/export)" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Generate SIREN CSV" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Generated file" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__id +msgid "ID" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__import_filename +msgid "Import Filename" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "" +"Import done: %(c)s created, %(u)s updated, %(s)s skipped, %(a)s ambiguous." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Import return CSV" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_csv_wizard +msgid "Import/Export eInvoicing directory via CSV" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "No SIREN column found. Columns: %s" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "No SIREN to export for the selected companies." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"Official directory (deposit the SIREN file, then download the\n" +" return):" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"One SIREN per line, without header — to deposit on the\n" +" official directory. Files are capped at 5000 lines / 1 MB;\n" +" a ZIP of several CSV is produced when needed." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__only_missing +msgid "Only companies without a directory line" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +msgid "Please upload the directory return CSV first." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_summary +msgid "Result Summary" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "Return CSV" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "" +"Row %(n)s: SIREN %(s)s is shared by several companies — linked to %(p)s." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py:0 +msgid "Row %(n)s: no partner found for SIREN %(s)s." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__export_file +msgid "SIREN file" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#. odoo-python +#: code:addons/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py:0 +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_directory_import.field_fr_directory_csv_wizard__result_partner_ids +msgid "Updated companies" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "" +"Upload the CSV returned by the directory (Chorus Pro format),\n" +" then import it to create/update the directory lines." +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_directory_import.fr_directory_csv_wizard_form +msgid "View updated companies" +msgstr "" + +#. module: l10n_fr_einvoicing_directory_import +#: model:ir.model,name:l10n_fr_einvoicing_directory_import.model_fr_directory_line +msgid "eInvoicing Directory Line for France" +msgstr "" diff --git a/l10n_fr_einvoicing_directory_import/models/__init__.py b/l10n_fr_einvoicing_directory_import/models/__init__.py new file mode 100644 index 0000000..5d82855 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/models/__init__.py @@ -0,0 +1,2 @@ +from . import fr_directory_line +from . import account_move diff --git a/l10n_fr_einvoicing_directory_import/models/account_move.py b/l10n_fr_einvoicing_directory_import/models/account_move.py new file mode 100644 index 0000000..c9365fe --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/models/account_move.py @@ -0,0 +1,37 @@ +# Copyright 2026 Sudokeys +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import fields, models, tools + +logger = logging.getLogger(__name__) + + +class AccountMove(models.Model): + _inherit = "account.move" + + # Odoo does not index this column. Resolving a partner's directory status + # walks its invoices, so the directory import runs one such lookup per + # company: without an index each one is a sequential scan of the whole + # account_move table (877k rows on the FPV database, tens of seconds each), + # and the import gets killed by the server time limit long before finishing. + # Indexing it also benefits every partner-centric accounting screen. + commercial_partner_id = fields.Many2one(index=True) + + def init(self): + # `index=True` above is enough on a clean database, but the field is + # inherited: any module redefining it without `index` would drop the + # index again. Creating it here as well makes the module self-healing — + # `create_index` is a no-op when the index already exists. + res = super().init() + tools.create_index( + self._cr, + "account_move_commercial_partner_id_index", + self._table, + ["commercial_partner_id"], + ) + logger.info( + "account_move.commercial_partner_id index ensured " + "(directory import performance)" + ) + return res diff --git a/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py b/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py new file mode 100644 index 0000000..4173724 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/models/fr_directory_line.py @@ -0,0 +1,416 @@ +# Copyright 2026 Sudokeys +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# +# Official French e-invoicing directory (Chorus Pro): +# https://facturation.chorus-pro.gouv.fr/annuaire/ +import csv +import io +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +logger = logging.getLogger(__name__) + +# Map the status returned by the directory to the ``state`` field. +STATE_ALIASES = { + "": "inactive", + "enabled": "active", + "active": "active", + "registered": "active", + "upcoming": "upcoming", + "in_progress": "upcoming", + "disabled": "disabled", + "inactive": "inactive", +} +VALID_TYPES = ("siren", "siret", "routing_code", "suffix", "error") +BOOL_TRUE = {"1", "true", "vrai", "oui", "yes", "x", "o"} + +# Partners processed per transaction during the import. Each batch is +# committed on its own: marking partners invalidates a stored compute on +# account.move, whose recompute would otherwise pile up over the entire +# invoice history and exhaust memory on a production-sized database. +DIRECTORY_IMPORT_BATCH = 200 + +# The State directory caps each deposited file at 5000 lines and 1 MB. +DIRECTORY_MAX_LINES = 5000 +DIRECTORY_MAX_BYTES = 1_000_000 + + +class FrDirectoryLine(models.Model): + _inherit = "fr.directory.line" + + # ------------------------------------------------------------------ + # Export: CSV of SIREN numbers to deposit on the State directory + # ------------------------------------------------------------------ + @api.model + def _directory_export_siren_list(self, partners): + """Return the ordered list of unique SIREN numbers of the partners.""" + sirens = [] + seen = set() + for partner in partners: + siren = partner._get_siren(raise_if_none=False) + if siren and siren not in seen: + seen.add(siren) + sirens.append(siren) + return sirens + + @api.model + def _directory_export_siren_chunks(self, partners): + """Return a list of CSV chunks (bytes), one SIREN per line, WITHOUT a + header and with ``\\n`` line endings. Each chunk stays within the State + directory limits (<= 5000 lines and <= 1 MB), splitting when needed.""" + chunks = [] + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + lines = 0 + for siren in self._directory_export_siren_list(partners): + if lines >= DIRECTORY_MAX_LINES or ( + buf.tell() + len(siren) + 1 > DIRECTORY_MAX_BYTES + ): + chunks.append(buf.getvalue().encode("utf-8")) + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + lines = 0 + writer.writerow([siren]) + lines += 1 + if lines: + chunks.append(buf.getvalue().encode("utf-8")) + return chunks + + @api.model + def _directory_export_siren_csv(self, partners): + """Return a single CSV (bytes) with every SIREN (no size limit). + + Kept for callers that do not care about the directory 5000-lines / 1 MB + deposit limits; the wizard uses :meth:`_directory_export_siren_chunks`. + """ + out = io.StringIO() + writer = csv.writer(out, lineterminator="\n") + for siren in self._directory_export_siren_list(partners): + writer.writerow([siren]) + return out.getvalue().encode("utf-8") + + # ------------------------------------------------------------------ + # Import: directory return CSV -> create/update directory lines + # ------------------------------------------------------------------ + @api.model + def _directory_import_csv(self, content): # noqa: C901 + """Create/update directory lines from the directory return CSV. + + Understands the Chorus Pro directory export (columns "SIREN" and + "Adresse de facturation" / "Adresse de facturation active") as well as + a canonical format (siren, siret, identifier, routing_code, state...). + Each row is matched to its commercial partner by SIREN and upserted by + (partner, identifier), like the native API sync. The delimiter (``,`` + or ``;``) is auto-detected. + """ + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError: + text = content.decode("latin-1") + sample = text[:2000] + delimiter = ";" if sample.count(";") > sample.count(",") else "," + reader = csv.DictReader(io.StringIO(text), delimiter=delimiter) + if not reader.fieldnames: + raise UserError(_("Empty or unreadable CSV file.")) + cols = self._directory_detect_columns(reader.fieldnames) + if "siren" not in cols: + raise UserError( + _( + "No SIREN column found. Columns: %s", + ", ".join(reader.fieldnames), + ) + ) + + index = self._directory_partner_index() + created = updated = skipped = ambiguous = 0 + errors = [] + affected = set() + synced = set() + + # Pass 1 — parse the whole file and resolve partners, without touching + # the database. A directory return holds one row per company: doing an + # ORM search and a write per row costs one query each and re-triggers + # the stored computes on res.partner every time, which blows past the + # server's request time limit on real files (thousands of rows). + parsed = [] + for line_no, row in enumerate(reader, start=2): + siren = (row.get(cols["siren"]) or "").strip().replace(" ", "") + if not siren.isdigit() or len(siren) != 9: + skipped += 1 + continue + vals = self._directory_row_to_vals(row, cols, siren) + partner, issue = self._directory_match_partner( + siren, vals.get("siret"), index + ) + if not partner: + skipped += 1 + errors.append( + _( + "Row %(n)s: no partner found for SIREN %(s)s.", + n=line_no, + s=siren, + ) + ) + continue + if issue == "ambiguous": + ambiguous += 1 + errors.append( + _( + "Row %(n)s: SIREN %(s)s is shared by several companies — " + "linked to %(p)s.", + n=line_no, + s=siren, + p=partner.display_name, + ) + ) + synced.add(partner.id) + parsed.append((partner, vals)) + + # Pre-load every existing line of the partners involved in one query, + # indexed by (partner, identifier) — the key used for the upsert. + existing_map = {} + if parsed: + partner_ids = list({partner.id for partner, _vals in parsed}) + for line in self.with_context(active_test=False).search( + [("partner_id", "in", partner_ids)] + ): + existing_map[(line.partner_id.id, line.identifier)] = line + + # Pass 2 — write in batches of partners, committing between each. + # + # Marking a partner as present in the directory invalidates the stored + # `fr_einvoicing_required` on account.move, which depends on the + # partner's entity type. Doing it for every partner in one transaction + # makes Odoo recompute that field over the partner's whole invoice + # history at flush time — on a production database (877k moves here) + # the process runs out of memory. Committing per batch keeps each + # recompute bounded and releases the cache as we go. + # + # Trade-off: the import is no longer atomic. That is deliberate — a + # directory return is idempotent (upsert by partner + identifier), so + # re-running it after a failure resumes where it stopped. + Line = self.sudo().with_context(tracking_disable=True) + by_partner = {} + for partner, vals in parsed: + by_partner.setdefault(partner.id, []).append(vals) + all_partner_ids = list(by_partner) + batches = range(0, len(all_partner_ids), DIRECTORY_IMPORT_BATCH) + + for offset in batches: + batch_ids = all_partner_ids[offset : offset + DIRECTORY_IMPORT_BATCH] + to_create = [] + write_groups = {} + for partner_id in batch_ids: + for vals in by_partner[partner_id]: + existing = existing_map.get((partner_id, vals["identifier"])) + if existing: + wvals = { + key: value + for key, value in vals.items() + if key != "identifier" + and (existing[key] or False) != (value or False) + } + if wvals: + write_groups.setdefault( + tuple(sorted(wvals.items(), key=lambda kv: kv[0])), [] + ).append(existing.id) + updated += 1 + affected.add(partner_id) + else: + to_create.append(dict(vals, partner_id=partner_id)) + created += 1 + affected.add(partner_id) + for wvals_items, line_ids in write_groups.items(): + Line.browse(line_ids).write(dict(wvals_items)) + if to_create: + Line.create(to_create) + + batch_synced = [pid for pid in batch_ids if pid in synced] + if batch_synced: + self._directory_mark_partners_registered( + self.env["res.partner"].browse(batch_synced) + ) + + # Flush, commit, then drop the cache: without this the recomputes + # of the whole run pile up until the final flush. + self.env.flush_all() + self.env.cr.commit() + self.env.invalidate_all() + logger.info( + "Directory CSV import: %s/%s partners processed.", + min(offset + DIRECTORY_IMPORT_BATCH, len(all_partner_ids)), + len(all_partner_ids), + ) + logger.info( + "Directory CSV import: %s created, %s updated, %s skipped, " + "%s ambiguous.", + created, + updated, + skipped, + ambiguous, + ) + return { + "created": created, + "updated": updated, + "skipped": skipped, + "ambiguous": ambiguous, + "errors": errors, + "partner_ids": list(affected), + } + + @api.model + def _directory_mark_partners_registered(self, partners): + """Mark partners as present in the directory after a CSV import. + + The native module fills these partner-level fields during the API sync; + the CSV import must do the same, otherwise the directory section on the + partner (status, default line selector) stays hidden and BT-49 cannot + resolve. Entity type defaults to ``private`` when not already set. + """ + today = fields.Date.context_today(self) + # One write per partner would mean thousands of UPDATE plus as many + # recomputes of the stored directory fields. Partners sharing the exact + # same values are written together, and tracking is disabled so the + # import doesn't fill the chatter. + Partner = partners.sudo().with_context(tracking_disable=True) + groups = {} + for partner in Partner.commercial_partner_id: + vals = {"fr_directory_last_sync_date": today} + if not partner.fr_directory_entity_type: + vals["fr_directory_entity_type"] = "private" + siren = partner._get_siren(raise_if_none=False) + if siren: + vals["fr_directory_siren"] = siren + siret = partner._get_siret(raise_if_none=False) + if siret: + vals["fr_directory_siret"] = siret + # Convenience: when the partner ends up with a single active line and + # no default set, use it as the default routing line (BT-49). The + # field is a manual selector natively; the directory return usually + # confirms one address per company, so this saves a manual pick. + if not partner.default_fr_directory_line_id: + active_lines = partner.fr_directory_line_ids + if len(active_lines) == 1: + vals["default_fr_directory_line_id"] = active_lines.id + groups.setdefault( + tuple(sorted(vals.items(), key=lambda kv: kv[0])), [] + ).append(partner.id) + for vals_items, partner_ids in groups.items(): + Partner.browse(partner_ids).write(dict(vals_items)) + + @api.model + def _directory_partner_index(self): + """Index companies by SIREN and by SIRET for partner matching.""" + Partner = self.env["res.partner"] + companies = Partner.with_context(active_test=False).search( + [("is_company", "=", True)] + ) + by_siren = {} + by_siret = {} + for partner in companies: + siren = partner._get_siren(raise_if_none=False) + if siren: + by_siren[siren] = by_siren.get(siren, Partner) | partner + siret = partner._get_siret(raise_if_none=False) + if siret: + by_siret.setdefault(siret, partner) + return {"siren": by_siren, "siret": by_siret} + + @api.model + def _directory_match_partner(self, siren, siret, index): + """Return (commercial_partner, anomaly). + + Prefer the SIRET (disambiguates when several companies share a SIREN); + otherwise match by SIREN. ``anomaly`` is 'ambiguous' when the SIREN maps + to several distinct companies. + """ + empty = self.env["res.partner"] + if siret and siret in index["siret"]: + return index["siret"][siret].commercial_partner_id, None + partners = index["siren"].get(siren, empty) + commercials = partners.commercial_partner_id + if not commercials: + return empty, "no_partner" + if len(commercials) == 1: + return commercials, None + return commercials[0], "ambiguous" + + @api.model + def _directory_detect_columns(self, fieldnames): + """Map each column to a role (Chorus Pro or canonical format).""" + cols = {} + for src in fieldnames: + low = (src or "").strip().lower() + if low == "siren": + cols["siren"] = src + elif "adresse de facturation" in low and "active" in low: + cols["active"] = src + elif "adresse de facturation" in low: + cols["identifier"] = src + elif low in ("identifier", "adresse"): + cols.setdefault("identifier", src) + elif low in ("state", "etat", "état"): + cols["state"] = src + elif low == "siret": + cols["siret"] = src + elif low in ("routing_code", "code_routage", "code routage"): + cols["routing_code"] = src + elif low in ("routing_code_name", "libelle", "libellé"): + cols["routing_code_name"] = src + elif low in ("commitment_required", "engagement"): + cols["commitment"] = src + return cols + + @api.model + def _directory_row_to_vals(self, row, cols, siren): + """Turn a CSV row into fr.directory.line values.""" + + def val(role): + return (row.get(cols[role]) or "").strip() if role in cols else "" + + identifier = val("identifier") or siren + rtype, siret, routing_code = self._directory_parse_identifier(identifier, siren) + if "active" in cols: + state = "active" if val("active").lower() in BOOL_TRUE else "disabled" + elif "state" in cols: + state = STATE_ALIASES.get(val("state").lower(), "active") + else: + state = "active" + return { + "identifier": identifier, + "type": rtype, + "siren": siren, + "siret": (val("siret") or siret) or False, + "routing_code": (val("routing_code") or routing_code) or False, + "routing_code_name": val("routing_code_name") or False, + "state": state, + "commitment_required": ( + val("commitment").lower() in BOOL_TRUE + if "commitment" in cols + else False + ), + } + + @api.model + def _directory_parse_identifier(self, identifier, siren): + """Derive (type, siret, routing_code) from the identifier. + + Formats: SIREN | SIREN_SIRET | SIREN_SIRET_RoutingCode | SIREN_Suffix. + The routing code may contain "_": it is whatever follows the SIRET. + """ + parts = identifier.split("_") + siret = routing_code = False + if len(parts) == 1: + return "siren", siret, routing_code + second = parts[1] + is_siret = len(second) == 14 and second.isdigit() + if is_siret: + siret = second + if len(parts) == 2: + return "siret", siret, routing_code + routing_code = "_".join(parts[2:]) + return "routing_code", siret, routing_code + # 2nd segment is not a SIRET => addressing suffix + return "suffix", siret, routing_code diff --git a/l10n_fr_einvoicing_directory_import/pyproject.toml b/l10n_fr_einvoicing_directory_import/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/l10n_fr_einvoicing_directory_import/security/ir.model.access.csv b/l10n_fr_einvoicing_directory_import/security/ir.model.access.csv new file mode 100644 index 0000000..5ba3abb --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fr_directory_csv_wizard,fr.directory.csv.wizard,model_fr_directory_csv_wizard,account.group_account_manager,1,1,1,1 diff --git a/l10n_fr_einvoicing_directory_import/static/description/icon.png b/l10n_fr_einvoicing_directory_import/static/description/icon.png new file mode 100644 index 0000000..1dcc49c Binary files /dev/null and b/l10n_fr_einvoicing_directory_import/static/description/icon.png differ diff --git a/l10n_fr_einvoicing_directory_import/tests/__init__.py b/l10n_fr_einvoicing_directory_import/tests/__init__.py new file mode 100644 index 0000000..210cc1d --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/tests/__init__.py @@ -0,0 +1 @@ +from . import test_directory_csv diff --git a/l10n_fr_einvoicing_directory_import/tests/test_directory_csv.py b/l10n_fr_einvoicing_directory_import/tests/test_directory_csv.py new file mode 100644 index 0000000..0fbf955 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/tests/test_directory_csv.py @@ -0,0 +1,413 @@ +# Copyright 2026 Sudokeys +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# +# Plain TransactionCase on purpose: the from-source Odoo images used to run +# these modules strip the `tests/` directories of the standard addons, so +# helpers such as odoo.addons.account.tests.common are not importable. +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + +# SIREN and SIRET with valid checksums: l10n_fr_siret rejects anything else, +# and _get_siren() silently returns None for an invalid SIREN. +SIREN_A = "100000009" +SIRET_A = "10000000900009" +SIREN_B = "100000017" +SIRET_B = "10000001700002" +SIREN_C = "100000025" + + +class DirectoryCase(TransactionCase): + def setUp(self): + super().setUp() + # _directory_import_csv() commits between batches on purpose (a single + # transaction would pile up the account.move recomputes until it runs + # out of memory). Inside a test that commit releases the savepoint + # TransactionCase rolls back to, which aborts the whole run. Neutralise + # the commit only: the flush that precedes it still writes the rows. + # + # 19.0 patches `commit` on the cursor *instance* (see the `forbidden` + # helper in odoo/tests/common.py), which shadows any patch set on the + # class, so the instance is what has to be patched here. + self.patch(self.env.cr, "commit", lambda: None) + + +class TestDirectoryCsv(DirectoryCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Line = cls.env["fr.directory.line"] + cls.Partner = cls.env["res.partner"] + cls.partner_a = cls.Partner.create( + { + "name": "Directory Test A", + "is_company": True, + "company_registry": SIRET_A, + "country_id": cls.env.ref("base.fr").id, + } + ) + cls.partner_b = cls.Partner.create( + { + "name": "Directory Test B", + "is_company": True, + "company_registry": SIRET_B, + "country_id": cls.env.ref("base.fr").id, + } + ) + + # ------------------------------------------------------------------ + # Identifier parsing + # ------------------------------------------------------------------ + def test_parse_identifier_siren_only(self): + self.assertEqual( + self.Line._directory_parse_identifier(SIREN_A, SIREN_A), + ("siren", False, False), + ) + + def test_parse_identifier_siret(self): + identifier = f"{SIREN_A}_{SIRET_A}" + self.assertEqual( + self.Line._directory_parse_identifier(identifier, SIREN_A), + ("siret", SIRET_A, False), + ) + + def test_parse_identifier_routing_code(self): + identifier = f"{SIREN_A}_{SIRET_A}_SERVICE1" + self.assertEqual( + self.Line._directory_parse_identifier(identifier, SIREN_A), + ("routing_code", SIRET_A, "SERVICE1"), + ) + + def test_parse_identifier_routing_code_keeps_underscores(self): + """The routing code is everything after the SIRET, underscores included.""" + identifier = f"{SIREN_A}_{SIRET_A}_SERVICE_COMPTA_2" + self.assertEqual( + self.Line._directory_parse_identifier(identifier, SIREN_A), + ("routing_code", SIRET_A, "SERVICE_COMPTA_2"), + ) + + def test_parse_identifier_suffix(self): + """A second segment that is not a 14-digit SIRET is an addressing suffix.""" + self.assertEqual( + self.Line._directory_parse_identifier(f"{SIREN_A}_ABC", SIREN_A), + ("suffix", False, False), + ) + + # ------------------------------------------------------------------ + # Column detection + # ------------------------------------------------------------------ + def test_detect_columns_chorus_pro(self): + cols = self.Line._directory_detect_columns( + ["SIREN", "Adresse de facturation", "Adresse de facturation active"] + ) + self.assertEqual(cols["siren"], "SIREN") + self.assertEqual(cols["identifier"], "Adresse de facturation") + self.assertEqual(cols["active"], "Adresse de facturation active") + + def test_detect_columns_canonical(self): + cols = self.Line._directory_detect_columns( + ["siren", "siret", "identifier", "routing_code", "state", "engagement"] + ) + self.assertEqual(cols["siret"], "siret") + self.assertEqual(cols["identifier"], "identifier") + self.assertEqual(cols["routing_code"], "routing_code") + self.assertEqual(cols["state"], "state") + self.assertEqual(cols["commitment"], "engagement") + + def test_detect_columns_ignores_unknown(self): + cols = self.Line._directory_detect_columns(["SIREN", "Raison sociale"]) + self.assertEqual(cols, {"siren": "SIREN"}) + + # ------------------------------------------------------------------ + # Row -> values + # ------------------------------------------------------------------ + def test_row_to_vals_inactive_address(self): + """Chorus Pro answers 'present but inactive' for most companies.""" + cols = {"siren": "SIREN", "identifier": "Adresse", "active": "Adresse active"} + vals = self.Line._directory_row_to_vals( + { + "SIREN": SIREN_A, + "Adresse": f"{SIREN_A}_{SIRET_A}", + "Adresse active": "0", + }, + cols, + SIREN_A, + ) + self.assertEqual(vals["state"], "disabled") + self.assertEqual(vals["type"], "siret") + self.assertEqual(vals["siret"], SIRET_A) + + def test_row_to_vals_active_address(self): + cols = {"siren": "SIREN", "identifier": "Adresse", "active": "Adresse active"} + vals = self.Line._directory_row_to_vals( + {"SIREN": SIREN_A, "Adresse": SIREN_A, "Adresse active": "oui"}, + cols, + SIREN_A, + ) + self.assertEqual(vals["state"], "active") + self.assertEqual(vals["type"], "siren") + + def test_row_to_vals_state_alias(self): + cols = {"siren": "siren", "state": "state"} + vals = self.Line._directory_row_to_vals( + {"siren": SIREN_A, "state": "in_progress"}, cols, SIREN_A + ) + self.assertEqual(vals["state"], "upcoming") + + def test_row_to_vals_defaults_to_siren_identifier(self): + """Without an identifier column the SIREN itself is the identifier.""" + vals = self.Line._directory_row_to_vals( + {"siren": SIREN_A}, {"siren": "siren"}, SIREN_A + ) + self.assertEqual(vals["identifier"], SIREN_A) + self.assertEqual(vals["state"], "active") + + # ------------------------------------------------------------------ + # Export + # ------------------------------------------------------------------ + def test_export_siren_list_dedupes(self): + partners = self.partner_a | self.partner_b | self.partner_a + self.assertEqual( + self.Line._directory_export_siren_list(partners), + [SIREN_A, SIREN_B], + ) + + def test_export_siren_list_skips_partners_without_siren(self): + no_siren = self.Partner.create({"name": "No SIREN", "is_company": True}) + self.assertEqual( + self.Line._directory_export_siren_list(self.partner_a | no_siren), + [SIREN_A], + ) + + def test_export_siren_csv_has_no_header(self): + csv_bytes = self.Line._directory_export_siren_csv(self.partner_a) + self.assertEqual(csv_bytes, (SIREN_A + "\n").encode("utf-8")) + + def test_export_chunks_split_on_line_limit(self): + """The State directory caps each deposited file at 5000 lines.""" + sirens = [str(100000000 + i) for i in range(12000)] + self.patch( + type(self.Line), + "_directory_export_siren_list", + lambda self, partners: sirens, + ) + chunks = self.Line._directory_export_siren_chunks(self.partner_a) + self.assertEqual(len(chunks), 3) + self.assertEqual(chunks[0].count(b"\n"), 5000) + self.assertEqual(chunks[1].count(b"\n"), 5000) + self.assertEqual(chunks[2].count(b"\n"), 2000) + + def test_export_chunks_single_file_below_limits(self): + chunks = self.Line._directory_export_siren_chunks( + self.partner_a | self.partner_b + ) + self.assertEqual(len(chunks), 1) + self.assertEqual(chunks[0], f"{SIREN_A}\n{SIREN_B}\n".encode("utf-8")) + + # ------------------------------------------------------------------ + # Partner matching + # ------------------------------------------------------------------ + def test_match_partner_prefers_siret(self): + """A SIRET disambiguates companies sharing a SIREN.""" + twin = self.Partner.create( + { + "name": "Directory Test A bis", + "is_company": True, + "company_registry": SIREN_A + "00017", + "country_id": self.env.ref("base.fr").id, + } + ) + index = self.Line._directory_partner_index() + partner, issue = self.Line._directory_match_partner( + SIREN_A, "10000000900017", index + ) + self.assertEqual(partner, twin) + self.assertFalse(issue) + + def test_match_partner_reports_ambiguous_siren(self): + self.Partner.create( + { + "name": "Directory Test A bis", + "is_company": True, + "company_registry": SIREN_A + "00017", + "country_id": self.env.ref("base.fr").id, + } + ) + index = self.Line._directory_partner_index() + partner, issue = self.Line._directory_match_partner(SIREN_A, None, index) + self.assertEqual(issue, "ambiguous") + self.assertTrue(partner) + + def test_match_partner_unknown_siren(self): + index = self.Line._directory_partner_index() + partner, issue = self.Line._directory_match_partner(SIREN_C, None, index) + self.assertFalse(partner) + self.assertEqual(issue, "no_partner") + + # ------------------------------------------------------------------ + # Import + # ------------------------------------------------------------------ + def _import(self, text): + return self.Line._directory_import_csv(text.encode("utf-8")) + + def test_import_creates_lines(self): + res = self._import( + "SIREN;Adresse de facturation;Adresse de facturation active\n" + f"{SIREN_A};{SIREN_A}_{SIRET_A};1\n" + f"{SIREN_B};{SIREN_B};0\n" + ) + self.assertEqual(res["created"], 2) + self.assertEqual(res["skipped"], 0) + line = self.Line.with_context(active_test=False).search( + [ + ("partner_id", "=", self.partner_a.id), + ] + ) + self.assertEqual(line.identifier, f"{SIREN_A}_{SIRET_A}") + self.assertEqual(line.state, "active") + self.assertEqual(line.siret, SIRET_A) + + def test_import_is_idempotent(self): + """Upsert on (partner, identifier): the batched import is not atomic, + so re-running a failed file must not duplicate lines.""" + csv_text = ( + "SIREN;Adresse de facturation;Adresse de facturation active\n" + f"{SIREN_A};{SIREN_A}_{SIRET_A};1\n" + ) + self._import(csv_text) + res = self._import(csv_text) + self.assertEqual(res["created"], 0) + self.assertEqual(res["updated"], 0) + self.assertEqual( + self.Line.with_context(active_test=False).search_count( + [("partner_id", "=", self.partner_a.id)] + ), + 1, + ) + + def test_import_updates_changed_state(self): + base = "SIREN;Adresse de facturation;Adresse de facturation active\n" + self._import(base + f"{SIREN_A};{SIREN_A};0\n") + res = self._import(base + f"{SIREN_A};{SIREN_A};1\n") + self.assertEqual(res["updated"], 1) + line = self.Line.with_context(active_test=False).search( + [("partner_id", "=", self.partner_a.id)] + ) + self.assertEqual(line.state, "active") + + def test_import_marks_partner_registered(self): + """Without these partner fields the directory section stays hidden and + BT-49 cannot resolve.""" + self._import( + "SIREN;Adresse de facturation;Adresse de facturation active\n" + f"{SIREN_A};{SIREN_A};1\n" + ) + self.partner_a.invalidate_recordset() + self.assertEqual(self.partner_a.fr_directory_entity_type, "private") + self.assertTrue(self.partner_a.fr_directory_last_sync_date) + self.assertEqual(self.partner_a.fr_directory_siren, SIREN_A) + + def test_import_sets_single_active_line_as_default(self): + self._import( + "SIREN;Adresse de facturation;Adresse de facturation active\n" + f"{SIREN_A};{SIREN_A};1\n" + ) + self.partner_a.invalidate_recordset() + self.assertEqual( + self.partner_a.default_fr_directory_line_id.identifier, SIREN_A + ) + + def test_import_skips_unknown_and_malformed_siren(self): + res = self._import( + "SIREN;Adresse de facturation\n" + f"{SIREN_C};{SIREN_C}\n" # valid but no partner + "12345;12345\n" # not 9 digits + f"{SIREN_A};{SIREN_A}\n" + ) + self.assertEqual(res["created"], 1) + self.assertEqual(res["skipped"], 2) + self.assertTrue(any(SIREN_C in err for err in res["errors"])) + + def test_import_detects_comma_delimiter(self): + res = self._import( + "SIREN,Adresse de facturation,Adresse de facturation active\n" + f"{SIREN_A},{SIREN_A},1\n" + ) + self.assertEqual(res["created"], 1) + + def test_import_accepts_utf8_bom(self): + res = self.Line._directory_import_csv( + (f"SIREN;Adresse de facturation\n{SIREN_A};{SIREN_A}\n").encode("utf-8-sig") + ) + self.assertEqual(res["created"], 1) + + def test_import_without_siren_column_raises(self): + with self.assertRaises(UserError): + self._import("Raison sociale;Adresse\nACME;whatever\n") + + def test_import_empty_file_raises(self): + with self.assertRaises(UserError): + self._import("") + + def test_import_reports_ambiguous_siren(self): + self.Partner.create( + { + "name": "Directory Test A bis", + "is_company": True, + "company_registry": SIREN_A + "00017", + "country_id": self.env.ref("base.fr").id, + } + ) + res = self._import(f"SIREN;Adresse de facturation\n{SIREN_A};{SIREN_A}\n") + self.assertEqual(res["ambiguous"], 1) + # Assert on the SIREN, not on the wording: the message goes through _() + # and the test database may run in any language. + self.assertTrue(any(SIREN_A in err for err in res["errors"])) + + +class TestDirectoryCsvWizard(DirectoryCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "Wizard Test", + "is_company": True, + "company_registry": SIRET_A, + "country_id": cls.env.ref("base.fr").id, + } + ) + + def test_export_uses_active_ids(self): + wizard = ( + self.env["fr.directory.csv.wizard"] + .with_context(active_ids=self.partner.ids) + .create({"only_missing": False}) + ) + wizard.action_export_siren() + self.assertEqual(wizard.export_filename, "directory_siren.csv") + self.assertTrue(wizard.export_file) + + def test_export_only_missing_excludes_registered_partners(self): + """A partner already holding a line — even a disabled one — is + registered in the directory and must not be deposited again.""" + self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": SIREN_A, + "type": "siren", + "siren": SIREN_A, + "state": "disabled", + } + ) + wizard = ( + self.env["fr.directory.csv.wizard"] + .with_context(active_ids=self.partner.ids) + .create({"only_missing": True}) + ) + with self.assertRaises(UserError): + wizard.action_export_siren() + + def test_import_requires_a_file(self): + wizard = self.env["fr.directory.csv.wizard"].create({}) + with self.assertRaises(UserError): + wizard.action_import() diff --git a/l10n_fr_einvoicing_directory_import/wizards/__init__.py b/l10n_fr_einvoicing_directory_import/wizards/__init__.py new file mode 100644 index 0000000..b69a70f --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/wizards/__init__.py @@ -0,0 +1 @@ +from . import fr_directory_csv_wizard diff --git a/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py new file mode 100644 index 0000000..cd83876 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard.py @@ -0,0 +1,123 @@ +# Copyright 2026 Sudokeys +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +import io +import zipfile + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class FrDirectoryCsvWizard(models.TransientModel): + _name = "fr.directory.csv.wizard" + _description = "Import/Export eInvoicing directory via CSV" + + # Step 1: export the SIREN numbers to deposit on the directory + only_missing = fields.Boolean( + string="Only companies without a directory line", + default=True, + help="Export only companies that do not have any directory line yet " + "(i.e. not registered in the directory).", + ) + export_file = fields.Binary(string="SIREN file", readonly=True) + export_filename = fields.Char(readonly=True) + # Step 2: import the directory return CSV + import_file = fields.Binary(string="Directory return CSV") + import_filename = fields.Char() + result_summary = fields.Text(readonly=True) + result_partner_ids = fields.Many2many( + "res.partner", string="Updated companies", readonly=True + ) + + def action_export_siren(self): + self.ensure_one() + partners = self.env["res.partner"].browse( + self.env.context.get("active_ids") or [] + ) + if not partners: + partners = self.env["res.partner"].search( + [("is_company", "=", True), ("parent_id", "=", False)] + ) + if self.only_missing: + # Drop companies that already have at least one directory line + # (active or not): they are already registered. + with_lines = ( + self.env["fr.directory.line"] + .with_context(active_test=False) + .search([("partner_id", "in", partners.ids)]) + .partner_id + ) + partners = partners - with_lines + Line = self.env["fr.directory.line"] + chunks = Line._directory_export_siren_chunks(partners) + if not chunks: + raise UserError(_("No SIREN to export for the selected companies.")) + # A single CSV, or a ZIP of several CSV when the directory limits + # (5000 lines / 1 MB per file) require splitting. + if len(chunks) == 1: + data, name = chunks[0], "directory_siren.csv" + else: + zbuf = io.BytesIO() + with zipfile.ZipFile(zbuf, "w", zipfile.ZIP_DEFLATED) as zf: + for i, chunk in enumerate(chunks, start=1): + zf.writestr("directory_siren_%02d.csv" % i, chunk) + data, name = zbuf.getvalue(), "directory_siren.zip" + count = len(Line._directory_export_siren_list(partners)) + self.write( + { + "export_file": base64.b64encode(data), + "export_filename": name, + "result_summary": _( + "%(c)s SIREN exported in %(f)s file(s) " + "(max 5000 lines / 1 MB each).", + c=count, + f=len(chunks), + ), + } + ) + return self._reopen() + + def action_import(self): + self.ensure_one() + if not self.import_file: + raise UserError(_("Please upload the directory return CSV first.")) + res = self.env["fr.directory.line"]._directory_import_csv( + base64.b64decode(self.import_file) + ) + summary = _( + "Import done: %(c)s created, %(u)s updated, " + "%(s)s skipped, %(a)s ambiguous.", + c=res["created"], + u=res["updated"], + s=res["skipped"], + a=res.get("ambiguous", 0), + ) + if res["errors"]: + summary += "\n\n" + "\n".join(res["errors"][:50]) + self.write( + { + "result_summary": summary, + "result_partner_ids": [(6, 0, res.get("partner_ids", []))], + } + ) + return self._reopen() + + def action_view_partners(self): + """Open the list of companies created/updated by the last import.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Updated companies"), + "res_model": "res.partner", + "view_mode": "list,form", + "domain": [("id", "in", self.result_partner_ids.ids)], + } + + def _reopen(self): + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } diff --git a/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard_views.xml b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard_views.xml new file mode 100644 index 0000000..19f75a7 --- /dev/null +++ b/l10n_fr_einvoicing_directory_import/wizards/fr_directory_csv_wizard_views.xml @@ -0,0 +1,104 @@ + + + fr.directory.csv.wizard.form + fr.directory.csv.wizard + +
+ + +
+ One SIREN per line, without header — to deposit on the + official directory. Files are capped at 5000 lines / 1 MB; + a ZIP of several CSV is produced when needed. +
+ + + +
+
+ + + + + + +
+ Upload the CSV returned by the directory (Chorus Pro format), + then import it to create/update the directory lines. +
+ + + + +
+
+ + + +
+
+
+
+ +
+
+ + + France eInvoicing — Directory (CSV import/export) + fr.directory.csv.wizard + form + new + + + +
diff --git a/l10n_fr_einvoicing_import/__manifest__.py b/l10n_fr_einvoicing_import/__manifest__.py index 871d11f..34f759f 100644 --- a/l10n_fr_einvoicing_import/__manifest__.py +++ b/l10n_fr_einvoicing_import/__manifest__.py @@ -13,7 +13,7 @@ { "name": "France eInvoicing: Import Vendor Bills", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Import vendor bills/refunds from accredited platform", @@ -24,6 +24,6 @@ "l10n_fr_einvoicing", "account_invoice_import", ], - "data": ["views/account_journal.xml"], + "data": ["views/account_journal.xml", "views/fr_directory_line.xml"], "installable": True, } diff --git a/l10n_fr_einvoicing_import/i18n/fr.po b/l10n_fr_einvoicing_import/i18n/fr.po index 5d5803c..52704b6 100644 --- a/l10n_fr_einvoicing_import/i18n/fr.po +++ b/l10n_fr_einvoicing_import/i18n/fr.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 21:36+0000\n" -"PO-Revision-Date: 2026-07-24 21:36+0000\n" +"POT-Creation-Date: 2026-08-08 22:55+0000\n" +"PO-Revision-Date: 2026-08-08 22:55+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -16,15 +16,25 @@ msgstr "" "Plural-Forms: \n" #. module: l10n_fr_einvoicing_import -#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_account_journal__company_partner_id -msgid "Account Holder" -msgstr "Titulaire de compte" +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__belongs_to_company_id +msgid "Belongs To Company" +msgstr "Rattaché à la société" #. module: l10n_fr_einvoicing_import #: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_account_journal__fr_directory_line_ids msgid "Company Directory Lines" msgstr "Lignes d'annuaire de la société" +#. module: l10n_fr_einvoicing_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.fr_directory_line_form +msgid "Company Settings" +msgstr "Paramètres de la société" + +#. module: l10n_fr_einvoicing_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id +msgid "Force Purchase Journal" +msgstr "Forcer le journal d'achat" + #. module: l10n_fr_einvoicing_import #: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.view_account_journal_form msgid "France eInvoicing" @@ -50,9 +60,23 @@ msgid "Journal" msgstr "" #. module: l10n_fr_einvoicing_import -#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id -msgid "Purchase Journal" -msgstr "Journal d'achat" +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction +msgid "No VAT Deduction" +msgstr "Pas de déduction de TVA" + +#. module: l10n_fr_einvoicing_import +#: model:ir.model.fields,help:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction +msgid "" +"This option is designed for entities that can deduct VAT on some vendor " +"bills but not all. If they decide to create a directory line dedicated to " +"the vendor bills for which they cannot deduct VAT, they should enable this " +"option on this specific directory line." +msgstr "" +"Cette option est conçue pour les entités qui peuvent déduire la TVA sur " +"certaines factures fournisseur mais pas toutes. Si ces entités décident de créer " +"une ligne d'annuaire dédiée aux factures fournisseur pour lesquelles elles " +"ne peuvent pas déduire la TVA, elles activeront cette option " +"sur cette ligne d'annuaire spécifique." #. module: l10n_fr_einvoicing_import #: model:ir.model,name:l10n_fr_einvoicing_import.model_account_invoice_import diff --git a/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot b/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot index 49fb90f..0da3be2 100644 --- a/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot +++ b/l10n_fr_einvoicing_import/i18n/l10n_fr_einvoicing_import.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-18 15:40+0000\n" -"PO-Revision-Date: 2026-06-18 15:40+0000\n" +"POT-Creation-Date: 2026-08-08 22:54+0000\n" +"PO-Revision-Date: 2026-08-08 22:54+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -16,8 +16,8 @@ msgstr "" "Plural-Forms: \n" #. module: l10n_fr_einvoicing_import -#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_account_journal__company_partner_id -msgid "Account Holder" +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__belongs_to_company_id +msgid "Belongs To Company" msgstr "" #. module: l10n_fr_einvoicing_import @@ -25,6 +25,16 @@ msgstr "" msgid "Company Directory Lines" msgstr "" +#. module: l10n_fr_einvoicing_import +#: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.fr_directory_line_form +msgid "Company Settings" +msgstr "" + +#. module: l10n_fr_einvoicing_import +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id +msgid "Force Purchase Journal" +msgstr "" + #. module: l10n_fr_einvoicing_import #: model_terms:ir.ui.view,arch_db:l10n_fr_einvoicing_import.view_account_journal_form msgid "France eInvoicing" @@ -48,8 +58,17 @@ msgid "Journal" msgstr "" #. module: l10n_fr_einvoicing_import -#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__purchase_journal_id -msgid "Purchase Journal" +#: model:ir.model.fields,field_description:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction +msgid "No VAT Deduction" +msgstr "" + +#. module: l10n_fr_einvoicing_import +#: model:ir.model.fields,help:l10n_fr_einvoicing_import.field_fr_directory_line__no_vat_deduction +msgid "" +"This option is designed for entities that can deduct VAT on some vendor " +"bills but not all. If they decide to create a directory line dedicated to " +"the vendor bills for which they cannot deduct VAT, they should enable this " +"option on this specific directory line." msgstr "" #. module: l10n_fr_einvoicing_import diff --git a/l10n_fr_einvoicing_import/models/account_journal.py b/l10n_fr_einvoicing_import/models/account_journal.py index 98677c6..3d72809 100644 --- a/l10n_fr_einvoicing_import/models/account_journal.py +++ b/l10n_fr_einvoicing_import/models/account_journal.py @@ -8,7 +8,6 @@ class AccountJournal(models.Model): _inherit = "account.journal" - company_partner_id = fields.Many2one(related="company_id.partner_id") fr_directory_line_ids = fields.One2many( "fr.directory.line", "purchase_journal_id", diff --git a/l10n_fr_einvoicing_import/models/fr_directory_line.py b/l10n_fr_einvoicing_import/models/fr_directory_line.py index ad94d92..dabdd3e 100644 --- a/l10n_fr_einvoicing_import/models/fr_directory_line.py +++ b/l10n_fr_einvoicing_import/models/fr_directory_line.py @@ -2,12 +2,47 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import fields, models +from odoo import api, fields, models class FrDirectoryLine(models.Model): _inherit = "fr.directory.line" purchase_journal_id = fields.Many2one( - "account.journal", string="Purchase Journal", copy=False, readonly=True + "account.journal", + string="Force Purchase Journal", + copy=False, + domain="[('company_id', '=', belongs_to_company_id), " + "('type', '=', 'purchase')]", ) + # Field "no_vat_deduction" has been designed for + # "Association avec secteur lucratif": + # to speedy-up the processing of their vendor bills, they can create 2 dir lines: + # - one dedicated to the "secteur lucratif" where they can deduct VAT, + # - another one dedicated to the "secteur non lucratif" on which they would enable + # the option 'no_vat_deduction'. + no_vat_deduction = fields.Boolean( + string="No VAT Deduction", + copy=False, + help="This option is designed for entities that can deduct VAT on some " + "vendor bills but not all. If they decide to create a directory line " + "dedicated to the vendor bills for which they cannot deduct VAT, " + "they should enable this option on this specific directory line.", + ) + # in the module l10n_fr_einvoicing, there is a field 'company_id' + # which is a related of partner_id.company_id, used by the ir.rule + # Here, we need a field which has a value only if the directory line + # is attached to the partner of a company. The field name is not very good, + # but I couldn't find a better one, sorry! + belongs_to_company_id = fields.Many2one( + "res.company", compute="_compute_belongs_to_company_id", store=True + ) + + @api.depends("partner_id") + def _compute_belongs_to_company_id(self): + company_sr = self.env["res.company"].search_read( + [("partner_id", "!=", False)], ["partner_id"] + ) + partner_id2company_id = {c["partner_id"][0]: c["id"] for c in company_sr} + for line in self: + line.belongs_to_company_id = partner_id2company_id.get(line.partner_id.id) diff --git a/l10n_fr_einvoicing_import/views/account_journal.xml b/l10n_fr_einvoicing_import/views/account_journal.xml index c539f46..41325d2 100644 --- a/l10n_fr_einvoicing_import/views/account_journal.xml +++ b/l10n_fr_einvoicing_import/views/account_journal.xml @@ -9,14 +9,14 @@ account.journal - + + - diff --git a/l10n_fr_einvoicing_import/views/fr_directory_line.xml b/l10n_fr_einvoicing_import/views/fr_directory_line.xml new file mode 100644 index 0000000..5e21ff7 --- /dev/null +++ b/l10n_fr_einvoicing_import/views/fr_directory_line.xml @@ -0,0 +1,25 @@ + + + + + fr.directory.line + + + + + + + + + + + + diff --git a/l10n_fr_einvoicing_import/wizards/account_invoice_import.py b/l10n_fr_einvoicing_import/wizards/account_invoice_import.py index 96f4628..c58f2fc 100644 --- a/l10n_fr_einvoicing_import/wizards/account_invoice_import.py +++ b/l10n_fr_einvoicing_import/wizards/account_invoice_import.py @@ -14,11 +14,7 @@ class AccountInvoiceImport(models.TransientModel): @api.model def _prepare_create_invoice_vals(self, parsed_inv, import_config): - vals = super()._prepare_create_invoice_vals(parsed_inv, import_config) - if parsed_inv.get("partner", {}).get("einvoice_address"): - vals["fr_directory_line_identifier"] = parsed_inv["partner"][ - "einvoice_address" - ] + company_fr_dir_line = False if parsed_inv.get("company", {}).get("einvoice_address"): company_fr_dir_line_ident = parsed_inv["company"]["einvoice_address"] company = import_config["company"] @@ -29,18 +25,27 @@ def _prepare_create_invoice_vals(self, parsed_inv, import_config): ], limit=1, ) - if company_fr_dir_line: - vals["company_fr_directory_line_id"] = company_fr_dir_line.id - if company_fr_dir_line.state != "active": - logger.warning( - f"Company directory line state is {company_fr_dir_line.state} " - "(should be active)" - ) - if company_fr_dir_line.purchase_journal_id: - logger.info( - "Import import forced to journal %s because the destination " - "einvoice address is configured on it.", - company_fr_dir_line.purchase_journal_id.display_name, - ) - vals["journal_id"] = company_fr_dir_line.purchase_journal_id.id + if company_fr_dir_line and company_fr_dir_line.no_vat_deduction: + self._pre_process_parsed_inv_taxes( + parsed_inv, company, force_no_vat_deduction=True + ) + vals = super()._prepare_create_invoice_vals(parsed_inv, import_config) + if parsed_inv.get("partner", {}).get("einvoice_address"): + vals["fr_directory_line_identifier"] = parsed_inv["partner"][ + "einvoice_address" + ] + if company_fr_dir_line: + vals["company_fr_directory_line_id"] = company_fr_dir_line.id + if company_fr_dir_line.state != "active": + logger.warning( + f"Company directory line state is {company_fr_dir_line.state} " + "(should be active)" + ) + if company_fr_dir_line.purchase_journal_id: + logger.info( + "Import import forced to journal %s because the destination " + "einvoice address is configured on it.", + company_fr_dir_line.purchase_journal_id.display_name, + ) + vals["journal_id"] = company_fr_dir_line.purchase_journal_id.id return vals diff --git a/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py b/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py index b4cf196..9df43c2 100644 --- a/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py +++ b/l10n_fr_einvoicing_payment_batch_oca/__manifest__.py @@ -4,7 +4,7 @@ { "name": "France eInvoicing: Account Payment Batch OCA", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Option to auto-send payment sent event", diff --git a/l10n_fr_einvoicing_purchase/__manifest__.py b/l10n_fr_einvoicing_purchase/__manifest__.py index dc360ce..ecb7610 100644 --- a/l10n_fr_einvoicing_purchase/__manifest__.py +++ b/l10n_fr_einvoicing_purchase/__manifest__.py @@ -4,7 +4,7 @@ { "name": "France eInvoicing Purchase", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "category": "Inventory/Purchase", "license": "AGPL-3", "summary": "Display directory line on purchase order report", diff --git a/l10n_fr_einvoicing_sale/__manifest__.py b/l10n_fr_einvoicing_sale/__manifest__.py index f1a9c7e..59c96f7 100644 --- a/l10n_fr_einvoicing_sale/__manifest__.py +++ b/l10n_fr_einvoicing_sale/__manifest__.py @@ -4,7 +4,7 @@ { "name": "France eInvoicing Sale", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "category": "Sales", "license": "AGPL-3", "summary": "eInvoicing for France in Sales", diff --git a/l10n_fr_einvoicing_sale/models/res_company.py b/l10n_fr_einvoicing_sale/models/res_company.py index 4ef6732..8fed1a8 100644 --- a/l10n_fr_einvoicing_sale/models/res_company.py +++ b/l10n_fr_einvoicing_sale/models/res_company.py @@ -22,11 +22,8 @@ class ResCompany(models.Model): default=30, ) - _sql_constraints = [ - ( - "fr_ctc_directory_sync_on_sale_order_confirm_days_positive", - "CHECK(fr_ctc_directory_sync_on_sale_order_confirm_days >= 0)", - "The number of days for directory sync on sale order confirmation " - "must be positive.", - ) - ] + _fr_ctc_directory_sync_on_sale_order_confirm_days_positive = models.Constraint( + "CHECK(fr_ctc_directory_sync_on_sale_order_confirm_days >= 0)", + "The number of days for directory sync on sale order confirmation " + "must be positive.", + ) diff --git a/l10n_fr_einvoicing_sale/models/sale_order.py b/l10n_fr_einvoicing_sale/models/sale_order.py index a5a55e9..a451795 100644 --- a/l10n_fr_einvoicing_sale/models/sale_order.py +++ b/l10n_fr_einvoicing_sale/models/sale_order.py @@ -25,6 +25,12 @@ class SaleOrder(models.Model): related="partner_invoice_id.commercial_partner_id.fr_directory_entity_type", string="Invoicing Partner Directory Entity Type", ) + # sale_commercial_partner dropped commercial_partner_invoice_id in 19.0, but + # the directory line domain still needs the invoicing partner's entity. + fr_directory_partner_invoice_id = fields.Many2one( + related="partner_invoice_id.commercial_partner_id", + string="Invoicing Partner Entity", + ) fr_directory_line_id = fields.Many2one( "fr.directory.line", compute="_compute_fr_directory_line_id", @@ -34,7 +40,7 @@ class SaleOrder(models.Model): tracking=True, string="Directory Line", ondelete="restrict", - domain="[('partner_id', '=', commercial_partner_invoice_id), " + domain="[('partner_id', '=', fr_directory_partner_invoice_id), " "('state', '=', 'active')]", ) @@ -131,9 +137,9 @@ def _get_invoice_grouping_keys(self): return group_keys def _action_confirm(self): - self.ensure_one() for order in self: - order._fr_ctc_confirm_checks() + if order.company_id._fr_ctc_is_vat_registered(raise_if_misconfigured=True): + order._fr_ctc_confirm_checks() return super()._action_confirm() def _fr_ctc_confirm_checks(self): @@ -141,15 +147,7 @@ def _fr_ctc_confirm_checks(self): cinvpartner = self.partner_invoice_id.commercial_partner_id company = self.company_id dir_sync_done = False # just to avoid double message in chatter - if ( - ( - not cinvpartner.fr_directory_entity_type - or cinvpartner.fr_directory_entity_type == "private_inactive" - ) - and cinvpartner.is_company - and cinvpartner.is_france_country - and cinvpartner._get_siren() - ): + if cinvpartner._fr_directory_should_sync_upon_confirmation(): try: cinvpartner._fr_directory_sync_logs(company, self.name) self._compute_fr_directory_line_id() diff --git a/l10n_fr_einvoicing_sale/views/sale_order.xml b/l10n_fr_einvoicing_sale/views/sale_order.xml index 074105a..f669454 100644 --- a/l10n_fr_einvoicing_sale/views/sale_order.xml +++ b/l10n_fr_einvoicing_sale/views/sale_order.xml @@ -7,15 +7,18 @@ sale.order - + + + diff --git a/requirements.txt b/requirements.txt index 021838a..d093625 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ # generated from manifests external_dependencies -factur-x>=6.5 -pyfrctc>=0.14 +factur-x>=6.7 +pyfrctc>=0.15