diff --git a/.ruff.toml b/.ruff.toml index 5e63128..20c7d66 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,5 +1,9 @@ -target-version = "py310" +# 16.0 runs on Python 3.9: keeping py310 here makes pyupgrade (UP038) rewrite +# isinstance(x, (int, float)) into isinstance(x, int | float), which raises +# TypeError at runtime on 3.9. Do not raise this without checking the Python +# version of the target image. +target-version = "py39" fix = true [lint] diff --git a/MIGRATION-16.0.md b/MIGRATION-16.0.md new file mode 100644 index 0000000..f41d211 --- /dev/null +++ b/MIGRATION-16.0.md @@ -0,0 +1,227 @@ +# Backport EN16931 → Odoo 16.0 + +Working branch: `MIG-16.0-account_invoice_en16931`, rebased on `upstream/16.0`. + +**Status: the whole stack is ported and installable on 16.0.** All ten modules below are back to +`installable: True` and install on a 16.0 community database. + +## Scope + +| Module | Status | +|---|---| +| `account_invoice_en16931` | Ported. Tax layer rewritten on the 16.0 tax engine. | +| `l10n_fr_account_invoice_en16931` | Ported, on OCA `l10n_fr_siret` 16.0. | +| `l10n_fr_einvoicing` | Ported (Python, views, `env._`). | +| `l10n_fr_einvoicing_import` | Ported. | +| `l10n_fr_einvoicing_purchase` | Ported. | +| `l10n_fr_einvoicing_sale` | Ported. | +| `l10n_fr_einvoicing_dashboard_banner` | Ported. | +| `l10n_fr_einvoicing_batch_payment` | Ported. Renamed from `l10n_fr_einvoicing_payment_batch_oca`: `account_payment_batch_oca` is 18.0-only, the overridden method lives on `account.payment.order` (OCA `account_payment_order`). | +| `l10n_fr_einvoicing_directory_import` | Ported (Sudokeys module, not upstream). Identical to the 18.0 branch, plus the tests. | +| `account_invoice_en16931_py3o` | Ported and `installable: True`. Its glue already fit 16.0; `report_py3o` exists in OCA `reporting-engine` 16.0 (`16.0.1.0.6`) with the same `py3o.report` API. | + +## Why this was not a mechanical backport + +The 18.0 code is built on the **generic tax engine introduced in Odoo 18** (`base_line` dicts + +`tax_details`). None of the methods it relies on exist on 16.0, which only has `compute_all()`: + +- `_prepare_product_base_line_for_taxes_computation`, `_prepare_base_line_for_taxes_computation` +- `_add_tax_details_in_base_line`, `_add_tax_details_in_base_lines` +- `_round_base_lines_tax_details`, `_aggregate_base_lines_tax_details`, + `_aggregate_base_lines_aggregated_values`, `_prepare_tax_line_for_taxes_computation` + +This affects `_check_en16931`, `_prepare_bg25_single_line`, `_prepare_bg20_single_line` and +`_prepare_bg23` (VAT breakdown: `BT-116`/`BT-117`/`BT-118`/`BT-119`). Rounding must match 18.0 +exactly, or the EN16931 schematron fails. + +Two more 18.0-only idioms had to be undone everywhere: `self.env._("…", key=value)`, which only +returns the string on 16.0 and silently drops its arguments (rewritten as `_("…") % (…)`), and the +view syntax (`` → `o_setting_box`, `invisible="…"` → `attrs=`, `` → ``). + +## Dependencies: plain OCA 16.0 is enough + +Two prerequisites were missing from OCA 16.0 when this port started, and were carried on Alusage +forks. **Both have since landed upstream — the forks are no longer needed:** + +1. **VATEX codes** on `account_tax_unece`: `data/unece_tax_vatex.xml`, the `unece_vatex_id` field + and `_compute_unece_vatex_id()` are on `OCA/community-data-files` `16.0`. +2. **`l10n_fr_siret` helpers**: `is_france_country`, `_get_siren()`, `_get_siret()` and `_get_nic()` + are on `OCA/l10n-france` `16.0`, added on 27/07 by + [`9e662b1a`](https://github.com/OCA/l10n-france/commit/9e662b1a). + +⚠️ That same commit also extended `_check_siret` to the `vat` field: a French VAT number must now +end with the nine digits of the SIREN. Any partner data carrying an inconsistent pair — including +demo data — is rejected on load. This is what made the demo of `l10n_fr_einvoicing` abort the +database on instances running a post-27/07 `l10n_fr_siret`; fixed on this branch, and the same pair +is still on `18.0`. + +Activating `account_invoice_en16931_py3o` adds one more repository to provision: `report_py3o` from +OCA `reporting-engine` 16.0, whose `external_dependencies` are `py3o.template` and `py3o.formats` +(picked up by jarvis `update_requirements`) plus the `libreoffice` deb. Odoo does not check the `deb` +key at install time, so the module installs without it — but no py3o report can actually be +rendered, hence no end-to-end test of the glue. + +## 🔴 A French database is not EN16931-ready out of the box + +`account_invoice_en16931._post()` calls `_en16931_checks()`, which refuses to post **any** customer +invoice while the company's tax configuration is incomplete. On a fresh French database that takes +two separate steps, and neither happens on its own. + +**Step 1 — install `l10n_fr_account_tax_unece`** (OCA `l10n-france`, `post_init_hook` +`set_unece_on_taxes`). Without it, every post fails with *"Tax 'TVA 20% (Goods)' has no UNECE Tax +Type"*. The module is not a dependency of anything in this stack, and it looks self-installing — its +manifest says `"auto_installable": True` — but **that key does not exist in Odoo**. The real one is +`auto_install`, and unknown manifest keys are ignored silently, so it never auto-installs. Upstream +OCA typo, identical on 16.0, 17.0 and 18.0, so not a backport artefact. + +**Step 2 — set a VAT exemption reason on the exempt taxes.** With step 1 done, the check moves on to +*"VAT tax 'TVA 0% EXO (Goods)' has UNECE Tax Category '[E] Exempt from tax' so it should have a VAT +Exemption Reason"* — on `TVA 0% EXO` for both goods and services. `l10n_fr_account_tax_unece` sets +`unece_type_id` and `unece_categ_id` only; it never sets `unece_vatex_id` (zero occurrences in its +data file), and `account_tax_unece._compute_unece_vatex_id()` auto-maps categories K and G +(intra-EU, export) but not E. That is deliberate: a national exemption reason is a legal choice +(which CGI article), so no module can guess it — it has to be configured. + +Both steps were verified on a demo database: with the stack alone, the demo invoices and seven +`account_payment_partner` tests fail on step 1; installing the module moves them to step 2 rather +than fixing them — the failure count does not budge. What the database looks like after step 1, which +shows the auto-mapping covering K and G but not E: + +| company | sale tax | type | categ | vatex | +|---|---|---|---|---| +| Burger Queen | TVA 0% EU M | VAT | K | `VATEX-EU-IC` | +| Burger Queen | TVA 0% EXPORT | VAT | G | `VATEX-EU-G` | +| Burger Queen | **TVA 0% EXO** (goods, services) | VAT | **E** | **missing** | +| Burger Queen | TVA 20% / 10% | VAT | S | n/a | + +Invoice-wise: `Burger Queen → 4 draft`, `Tricatel → 4 posted`. The draft ones cannot be rescued by +configuring the taxes afterwards — they are posted during install — so on a fresh demo database +expect four invoices left in draft on the French company. Note that `account`'s own demo does post +fine, because the dependency graph installs `account` well before `account_invoice_en16931`, while +`_post()` is not yet overridden. + +### Running the test suite in the jarvis dev container + +Pass `--http-port` on a free port (e.g. `--http-port=8899`). The container's entrypoint runs a +**resident Odoo** on 8069; sharing that port makes every core `HttpCase` hit the resident server +instead of the test one — a parasitised run showed 84 failures + 46 errors, with `web`, `payment`, +`portal`, `bus`, `web_editor` and nine `TestXMLRPC` failing, all of which vanish once the port is +isolated. Worse, on an empty database the resident server may start initialising it concurrently with +the run, which breaks the registry (`duplicate key … pg_type_typname_nsp_index`) and stops the +container. + +## Install and test run on 16.0 + +Run on a jarvis 16.0 worktree (`fr-einvoicing-erp16-16`, branch `dev`), on a database created with +demo data: + +``` +odoo-dev -d odoo-dev -i account_invoice_en16931,l10n_fr_account_invoice_en16931,l10n_fr_einvoicing,\ +l10n_fr_einvoicing_import,l10n_fr_einvoicing_purchase,l10n_fr_einvoicing_sale,\ +l10n_fr_einvoicing_dashboard_banner,l10n_fr_einvoicing_batch_payment \ + --test-enable --test-tags '/account_invoice_en16931,/l10n_fr_account_invoice_en16931,…' \ + --stop-after-init +``` + +The eight modules install (80 modules loaded, none in error) and the suite passes: +`0 failed, 0 error(s) of 15 tests`. The 15 tests are +`l10n_fr_einvoicing/tests/test_partner_check_siren_siret_vat.py`, which exercises +`_fr_directory_check_siren_siret_vat()` — exactly the method whose `self.env._()` calls were +rewritten, so the port of those messages is covered. + +The only warning was the demo data, which needed its own port (see the `[FIX] … port the demo data +to 16.0` commit): `l10n_fr` declares the French demo company under `base.partner_demo_company_fr` on +18.0 but under `l10n_fr.partner_demo_company_fr` on 16.0, and `try_loading()` loses its +`template_code` argument on 16.0. + +### Full-cascade run, after the upstream backports + +Same worktree, database recreated with demo data, all **ten** modules installed and `--test-enable` +left unrestricted, so the whole dependency cascade is tested too (83 modules loaded, none in error): + +``` +odoo -d odoo-dev -i account_invoice_en16931,account_invoice_en16931_py3o,\ +l10n_fr_account_invoice_en16931,l10n_fr_einvoicing,l10n_fr_einvoicing_batch_payment,\ +l10n_fr_einvoicing_dashboard_banner,l10n_fr_einvoicing_directory_import,\ +l10n_fr_einvoicing_import,l10n_fr_einvoicing_purchase,l10n_fr_einvoicing_sale \ + --test-enable --stop-after-init +``` + +**Our ten modules: 49 tests, 0 failures, 0 errors** (34 in `l10n_fr_einvoicing_directory_import`, +15 in `l10n_fr_einvoicing`) — confirmed twice, with and without `l10n_fr_account_tax_unece`, the +second time with the port isolated (`33 failed, 14 error(s) of 1646`). Testing the cascade also +surfaces 47 failures in modules we do not own: `mail` 11, `account_invoice_import` 8, `base` 7, +`account_payment_partner` 7, `sms` 5, `google_gmail` 3, `sale` 2, and one each in `report_py3o`, +`base_vat` and `account_dashboard_banner`. None is caused by this backport, and three are worth +knowing about: + +- `account_invoice_import` (8) — from the `Alusage/edi` fork. Reproduced **on a clean database with + that module alone**, no reform stack installed: same `2 failures, 6 errors of 11 tests`. The cause + is a `payment_term` line inserted without `account_id` + (`account_move_line_check_accountable_required_fields`). Pre-existing, and worth checking against + [OCA/edi#1379](https://github.com/OCA/edi/pull/1379). +- `account_payment_partner` (7) — these do go through our `_post`, but the check that blocks them + (`_en16931_checks()`, in `account_invoice_en16931._post`) is unchanged from before the backport: + posting any customer invoice is refused while the company's taxes have no UNECE tax type, which is + the module's intended behaviour. It does mean **installing `account_invoice_en16931` breaks any + third-party test that posts a customer invoice on a company without UNECE-coded taxes** — an + upstream design point, not a 16.0 one. `4e97b69` narrows exactly this kind of check to + `fr_einvoicing_required` in `l10n_fr_einvoicing`; `account_invoice_en16931` still checks + unconditionally. +- `account_dashboard_banner` (1) — `isinstance(x, (int | float))` needs Python 3.10; this image runs + 3.9. A bug in the OCA 16.0 module, unrelated to the reform stack. + +The rest are core modules failing on this from-source image (`base`, `mail`, `sms`, `google_gmail`, +`sale`, `base_vat` — RTC, VIES SOAP, sanitizer), plus `report_py3o`'s `test_py3o_report_availability` +(no LibreOffice in the image, so `lo_bin_path` is empty). + +## Rebase on `upstream/16.0` + +Akretion opened its own `16.0` branch and carried seven commits onto it (the send/receive +configuration, `invoice_attachment_ids`, `business_process_type` (BT-23), the Chorus old-syntax +option and its schematron exemption, and two fixes). Four of them had been backported on this +branch beforehand; those backports were dropped on the rebase, upstream's own versions being +authoritative. + +Those commits are written against 18.0 APIs, so one commit re-ports them to 16.0: + +| What upstream added | What 16.0 required | +|---|---| +| Twelve `self.env._("…", key=value)` calls | Rewritten as `_("…") % {…}`: `env._` does not exist before 17.0 and silently drops its arguments. | +| BT-23 moved to `l10n_fr_account_invoice_en16931`, deciding on `product_id.type == "consu"` | On 16.0 the `stock` module adds `('product', 'Storable Product')` to `product.type`, so goods are `'product'` and every physical-goods invoice would be reported as a mixed process (M1/M2) instead of B1/B2. Uses the `_EN16931_GOODS_TYPES` tuple carried by `account_invoice_en16931`. | +| The shared `e-Invoicing` notebook page and the new settings, in 17+ view syntax | Converted to `attrs=` and to `o_setting_box`. | + +Upstream's `[FIX] don't store fr_einvoicing_required` supersedes the fix that was carried here for +[#36](https://github.com/akretion/fr-einvoicing/issues/36) (MemoryError when the issuer's entity +type is set on a database with a real invoicing history): dropping `store=True` removes the mass +recompute at the root, so the local fix was dropped on the rebase. + +## What is left to port + +Nothing. Every upstream module has a 16.0 counterpart, all ten are `installable: True`, no 18.0-only +idiom is left in the ported code (`self.env._(`, ``, dynamic `invisible=`/`readonly=` are all +at zero), and the whole stack installs and passes its tests on a demo database. + +Two follow-ups, both outside the 16.0 stack itself: + +1. **`account_invoice_en16931_py3o` has no end-to-end run.** It installs, but exercising the glue + means rendering a py3o report, which needs LibreOffice in the image — absent here, which is also + why `report_py3o`'s own `test_py3o_report_availability` fails (`lo_bin_path` empty). Nothing + points at the port; it is untested past install. +2. **The 16.0 tests of `l10n_fr_einvoicing_directory_import` are forward-ported to 18.0** on the + local branch `18.0-directory-import-tests` (off `origin/18.0-add-directory-import`, where that + module lives — it is absent from `upstream/18.0`). Not pushed, not run against an 18.0 database. + +Nothing to take from `origin/18.0-add-directory-import` (the module is byte-identical here, minus the +version and the wizard view idiom) nor from `origin/18.0-tmp_hack_chorus` (a temporary Chorus hack). +`origin/18.0` itself is ten commits behind `upstream/18.0`. + +## Full analysis + +The method-by-method matrix, the tax verdict, effort estimate, risks and the ordered migration plan +live in the spec (Obsidian vault): + +`projets/alusage/fr-einvoicing-erp16/specs/2026-07-16-backport-account_invoice_en16931-16.0.md` + +Odoo tasks: (backport), + (reform stack activation). diff --git a/README.md b/README.md index 5de1866..f883ca6 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/) | 16.0.1.0.0 (unported) | alexis-via | Base module to generate electronic invoices +[account_invoice_en16931_py3o](account_invoice_en16931_py3o/) | 16.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/) | 16.0.1.0.0 (unported) | alexis-via | Localization of Invoice EN16931 for France +[l10n_fr_einvoicing](l10n_fr_einvoicing/) | 16.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/) | 16.0.1.0.0 (unported) | alexis-via | Add widgets for eInvoicing flows in Accounting Dashboard Banner +[l10n_fr_einvoicing_import](l10n_fr_einvoicing_import/) | 16.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/) | 16.0.1.0.0 (unported) | alexis-via | Option to auto-send payment sent event +[l10n_fr_einvoicing_purchase](l10n_fr_einvoicing_purchase/) | 16.0.1.0.0 (unported) | alexis-via | Display directory line on purchase order report +[l10n_fr_einvoicing_sale](l10n_fr_einvoicing_sale/) | 16.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..f9a5936 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": "16.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Base module to generate electronic invoices", @@ -14,12 +14,22 @@ "depends": [ "account_tax_unece", "uom_unece", - "account_payment_unece", "base_vat", "intrastat_base", ], + # Optional: install account_payment_unece (OCA bank-payment stack) if you + # want the payment means block (BT-81/84/86) filled in the generated XML. + # It is not a hard dependency here: _en16931_payment_mean() reads the payment + # mode defensively and simply omits that block when the stack is absent. "excludes": ["account_einvoice_generate"], - "external_dependencies": {"python": ["factur-x>=6.5"]}, + # fonttools is an optional dependency of odoo.tools.pdf.convert_to_pdfa(): + # without it, the glyph width arrays produced by wkhtmltopdf are left as is + # and the PDF/A-3 output fails veraPDF rule 6.2.11.5. Declaring it here makes + # the Factur-X PDF actually PDF/A compliant. 16.0-specific. + # Pinned < 4.34: convert_to_pdfa() reads getGlyphSet()._hmtx, an internal + # fontTools API removed around 4.34 (KO on 4.38+), so a newer fonttools + # raises AttributeError instead of fixing the glyph widths. + "external_dependencies": {"python": ["factur-x>=6.7", "fonttools<4.34"]}, "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..cb3d1bc 100644 --- a/account_invoice_en16931/models/account_move.py +++ b/account_invoice_en16931/models/account_move.py @@ -13,13 +13,9 @@ from pypdf import PdfWriter from pypdf.generic import NameObject -from odoo import api, fields, models +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__) @@ -46,6 +42,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 +60,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 +97,91 @@ 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( + _( + "You cannot add an e-invoice attachment with " + "filename '%s' because this filename is reserved." + ) + % attach.name + ) + if attach.name in filenames: + raise ValidationError( + _( + "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( + _( + "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( @@ -108,109 +190,79 @@ def _check_invoice_type_code(self): for move in self: if move.is_sale_document() and not move.invoice_type_code: raise ValidationError( - self.env._( + _( "Field 'Invoice Type Code' is required on customer " - "invoices/refunds, but it is not set on '%s'.", - move.display_name, + "invoices/refunds, but it is not set on '%s'." ) + % move.display_name ) if ( move.move_type in ("in_invoice", "out_invoice") and move.invoice_type_code in REFUND_TYPE_CODES ): raise ValidationError( - self.env._( + _( "Invoice '%(move)s' has Invoice Type Code " - "'%(type_code)s' which is for refunds.", - move=move.display_name, - type_code=type_code2label.get(move.invoice_type_code), + "'%(type_code)s' which is for refunds." ) + % { + "move": move.display_name, + "type_code": type_code2label.get(move.invoice_type_code), + } ) elif ( move.move_type in ("out_refund", "in_refund") and move.invoice_type_code in INVOICE_TYPE_CODES ): raise ValidationError( - self.env._( + _( "Refund '%(move)s' has Invoice Type Code " - "'%(type_code)s' which is for invoices.", - move=move.display_name, - type_code=type_code2label.get(move.invoice_type_code), + "'%(type_code)s' which is for invoices." ) + % { + "move": move.display_name, + "type_code": type_code2label.get(move.invoice_type_code), + } ) 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._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( + _( + "Total Untaxed Amount (%(amount_untaxed)s) is negative. " + "This is not supported by the EN16931 standard." ) - 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 - ), + % { + "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( + _( + "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): @@ -218,19 +270,19 @@ def _en16931_checks_upon_invoice_generation(self): self.company_id._en16931_checks() if self.move_type not in ("out_invoice", "out_refund"): raise UserError( - self.env._( + _( "EN16931 generation is only for customer invoices and refunds. " - "It is not the case of '%s'.", - self.display_name, + "It is not the case of '%s'." ) + % self.display_name ) if self.state not in ("draft", "posted"): raise UserError( - self.env._( + _( "EN16931 generation is only for draft and posted invoices. " - "It is not the case of '%s'.", - self.display_name, + "It is not the case of '%s'." ) + % self.display_name ) def _prepare_bt1(self, speedy): @@ -238,7 +290,7 @@ def _prepare_bt1(self, speedy): if self.state == "posted": inv_number = self.name elif self.state == "draft": - inv_number = self.env._("DRAFT-FOR_TEST_ONLY") + inv_number = _("DRAFT-FOR_TEST_ONLY") else: raise return inv_number @@ -273,9 +325,14 @@ def _prepare_bt8(self, speedy): # use VAT tax of first invoice line # not a good solution... but how could we do better # with the broken native datamodel ? + # [:1] on the VAT taxes as well: a line carrying several VAT taxes + # is invalid for EN16931, but reading tax_exigibility on a + # multi-record set raises "Expected singleton" right here, long + # before the per-line check that states the problem in readable + # terms ("should have exactly one VAT tax and not 2"). vat_tax_first_line = self.invoice_line_ids.filtered( lambda x: x.display_type == "product" - )[:1].tax_ids.filtered(lambda x: x.unece_type_code == "VAT") + )[:1].tax_ids.filtered(lambda x: x.unece_type_code == "VAT")[:1] if ( vat_tax_first_line and vat_tax_first_line.tax_exigibility == "on_payment" @@ -305,41 +362,21 @@ def _prepare_bt20(self, speedy): # it is required) return res + # Product types that count as goods when a country-specific module derives + # BT-23. On 16.0, the 'stock' module adds ('product', 'Storable Product') to + # the selection of product.type, whereas 18.0 only has consu/service/combo + # and carries storability in is_storable. So a storable product is 'product' + # here, and testing == "consu" alone would silently classify goods as a + # mixed process (BT-23 = M1/M2 instead of B1/B2). + _EN16931_GOODS_TYPES = ("consu", "product") + 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() @@ -400,7 +437,86 @@ def _prepare_bg1(self, speedy): ) return res - def _prepare_bg23(self, base_lines, speedy): + def _en16931_direction_sign(self): + """Sign to make the accounting amounts positive. + + EN16931 expects positive amounts, a refund being a separate document + carrying its own type code (BT-3 = 381). On a customer invoice the + product and tax lines are credited, so their balance is negative; on a + refund they are debited. 18.0 has move.direction_sign for this, 16.0 + does not. + """ + self.ensure_one() + return -1 if self.move_type in ("out_invoice", "out_receipt") else 1 + + @api.model + def _en16931_tax_grouping_key(self, tax): + return ( + tax.unece_type_code, + tax.unece_categ_code, + int(round(tax.amount * 1000)), + tax.unece_vatex_code, + tax.unece_vatex_id.name, + ) + + def _en16931_vat_breakdown(self): + """VAT breakdown per UNECE grouping key (BG-23), 16.0 flavour. + + Upstream feeds the 18.0 tax engine (_round_base_lines_tax_details with + the real tax_lines, then _aggregate_base_lines_*). None of it exists on + 16.0, but the native tax details SQL query does most of the job: it + allocates the *actual* tax line amounts over their base lines, in both + the invoice and the company currency. Reusing it means the breakdown is + reconciled with what is really booked, so sum(BT-117) matches the + invoice tax total, which the EN16931 schematron checks (BR-CO-*). + + The query has one blind spot: a 0% tax books no tax line, so it never + shows up there. Those groups (exemptions: categories E/K/G/Z...) still + need a BG-23 entry with their base and a null amount, or the schematron + rejects the invoice. We add them from the invoice lines, for the VAT + taxes the query did not already cover. + + Returns {grouping_key: {"base_currency", "tax_currency", "tax_company"}} + with positive amounts. + """ + self.ensure_one() + aml_obj = self.env["account.move.line"] + query, params = aml_obj._get_query_tax_details_from_domain( + [("move_id", "=", self.id)] + ) + self.env.cr.execute(query, params) + rows = self.env.cr.dictfetchall() + sign = self._en16931_direction_sign() + res = {} + seen_tax_ids = set() + for row in rows: + seen_tax_ids.add(row["tax_id"]) + tax = self.env["account.tax"].browse(row["tax_id"]) + key = self._en16931_tax_grouping_key(tax) + vals = res.setdefault( + key, {"base_currency": 0.0, "tax_currency": 0.0, "tax_company": 0.0} + ) + vals["base_currency"] += sign * (row["base_amount_currency"] or 0.0) + vals["tax_currency"] += sign * (row["tax_amount_currency"] or 0.0) + vals["tax_company"] += sign * (row["tax_amount"] or 0.0) + # 0% VAT taxes: no booked tax line, so absent from the query above. + # price_subtotal is already a positive amount in the invoice currency, + # on invoices and refunds alike, so no sign to apply here. + for line in self.invoice_line_ids.filtered( + lambda x: x.display_type == "product" + ): + for tax in line.tax_ids: + if tax.id in seen_tax_ids or tax.unece_type_code != "VAT": + continue + key = self._en16931_tax_grouping_key(tax) + vals = res.setdefault( + key, + {"base_currency": 0.0, "tax_currency": 0.0, "tax_company": 0.0}, + ) + vals["base_currency"] += line.price_subtotal + return res + + def _prepare_bg23(self, speedy): self.ensure_one() bt110 = bt111 = 0.0 bg23 = [] @@ -420,51 +536,31 @@ def _prepare_bg23(self, base_lines, speedy): } ) return bg23, bt110, bt111 - tax_obj = self.env["account.tax"] - tax_amls = self.line_ids.filtered(lambda x: x.tax_repartition_line_id) - tax_lines = [self._prepare_tax_line_for_taxes_computation(x) for x in tax_amls] - tax_obj._round_base_lines_tax_details( - base_lines, self.company_id, tax_lines=tax_lines - ) - - # from pprint import pprint - # print('BG23 === base_lines================') - # pprint(base_lines) - def grouping_function(base_line, tax_data): - tax = tax_data["tax"] - grouping_key = { - "unece_type_code": tax.unece_type_code, - "unece_categ_code": tax.unece_categ_code, - "rate_int": int(round(tax.amount * 1000)), - "vatex_code": tax.unece_vatex_code, - "vatex_label": tax.unece_vatex_id.name, - } - return grouping_key - - base_lines_aggregated_values = tax_obj._aggregate_base_lines_tax_details( - base_lines, grouping_function - ) - values_per_grouping_key = tax_obj._aggregate_base_lines_aggregated_values( - base_lines_aggregated_values - ) - for tax_dict, tax_vals in values_per_grouping_key.items(): - if tax_dict["unece_type_code"] == "VAT": - bt110 += tax_vals.get("target_tax_amount_currency", 0) - bt111 += tax_vals.get("target_tax_amount", 0) + for key, tax_vals in self._en16931_vat_breakdown().items(): + ( + unece_type_code, + unece_categ_code, + rate_int, + vatex_code, + vatex_label, + ) = key + if unece_type_code == "VAT": + bt110 += tax_vals["tax_currency"] + bt111 += tax_vals["tax_company"] bg23.append( { "BT-116": self.currency_id._en16931_format( - tax_vals.get("target_base_amount_currency", 0) + tax_vals["base_currency"] ), "BT-116-1": self.currency_id.name, "BT-117": self.currency_id._en16931_format( - tax_vals.get("target_tax_amount_currency", 0) + tax_vals["tax_currency"] ), "BT-117-1": self.currency_id.name, - "BT-118": tax_dict["unece_categ_code"], - "BT-119": "%.2f" % (tax_dict["rate_int"] / 1000), # rate - "BT-120": tax_dict["vatex_label"], - "BT-121": tax_dict["vatex_code"], + "BT-118": unece_categ_code, + "BT-119": "%.2f" % (rate_int / 1000), # rate + "BT-120": vatex_label, + "BT-121": vatex_code, } ) return bg23, bt110, bt111 @@ -493,36 +589,58 @@ def _prepare_bg24(self, speedy, pdf_invoice_bin): { "BT-122": self.state == "posted" and self.name - or self.env._("Draft Invoice"), + or _("Draft Invoice"), "BT-123": "LISIBLE", "BT-125": base64.encodebytes(pdf_invoice_bin), "BT-125-1": "application/pdf", "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 _en16931_payment_mean(self): + """(unece_code, bank_account) of the payment mean, 16.0 flavour. + + Upstream reads move.preferred_payment_method_line_id, an + account.payment.method.line added to account.move in 18.0 (absent from + 16.0 and 17.0). The 16.0 counterpart is the OCA account.payment.mode, + put on the move by account_payment_partner; both modules are optional + here, exactly like account_payment_base_oca or mandate_id below. Without + them there is simply no payment mean to declare, and the BT-81 block is + left out. + """ + self.ensure_one() + payment_mode = hasattr(self, "payment_mode_id") and self.payment_mode_id or None + if not payment_mode: + return False, None + # unece_code is carried by account.payment.method (account_payment_unece), + # the same model as on 18.0. + unece_code = payment_mode.payment_method_id.unece_code or False + bank_account = None + if payment_mode.bank_account_link == "fixed": + bank_account = payment_mode.fixed_journal_id.bank_account_id or None + return unece_code, bank_account + def _prepare_en16931_payment_data(self, speedy): self.ensure_one() vals = {} - payment_method_line = self.preferred_payment_method_line_id - payment_unece_code = ( - payment_method_line - and payment_method_line.payment_method_id.unece_code - or False - ) + payment_unece_code, bank_account = self._en16931_payment_mean() # in the schematron, they want to back account even on refunds, # so we don't filter the IF below on "out_invoice" if payment_unece_code in CREDIT_TRF_CODES: - if hasattr(payment_method_line, "bank_account_link"): - # if account_payment_base_oca is installed - bank_account = ( - payment_method_line.bank_account_link == "fixed" - and payment_method_line.journal_id.bank_account_id - or None - ) - else: - bank_account = payment_method_line.journal_id.bank_account_id if bank_account: vals["BT-81"] = payment_unece_code vals["BT-84"] = bank_account.sanitized_acc_number @@ -550,7 +668,6 @@ def _prepare_en16931_invoice_lines(self, speedy): self.ensure_one() bg25 = [] bg20 = [] - base_lines = [] totals = { "BT-106": 0.0, "BT-107": 0.0, @@ -564,17 +681,13 @@ def _prepare_en16931_invoice_lines(self, speedy): ) if price_compare >= 0: lnumber += 1 - lvals, base_line = line._prepare_bg25_single_line( - lnumber, totals, speedy - ) - bg25.append(lvals) + bg25.append(line._prepare_bg25_single_line(lnumber, totals, speedy)) else: - allowance_vals_list, base_line = line._prepare_bg20_single_line( - totals, speedy - ) - bg20 += allowance_vals_list - base_lines.append(base_line) - return bg25, bg20, totals, base_lines + bg20 += line._prepare_bg20_single_line(totals, speedy) + # Upstream also collects the 18.0 base_lines here, to hand them over to + # _prepare_bg23(). On 16.0 the VAT breakdown is rebuilt from the booked + # tax lines instead, so there is nothing to carry over. + return bg25, bg20, totals def _prepare_en16931_speedy(self): self.ensure_one() @@ -605,7 +718,7 @@ def _prepare_en16931_speedy(self): "vatex_label": no_vat_taxes_vatex_id.name, }, "state2label": dict(self._fields["state"]._description_selection(self.env)), - "invoice_line_missing_label": self.env._("Missing invoice line label."), + "invoice_line_missing_label": _("Missing invoice line label."), "company_currency": company_currency, "company_currency_id": company_currency.id, "eu_country_ids": self.env.ref("base.europe").country_ids.ids, @@ -617,7 +730,7 @@ def _prepare_en16931_speedy(self): def _prepare_en16931_filename(self, invoice_format): self.ensure_one() if self.state == "draft": - filename = self.env._("draft_invoice") + filename = _("draft_invoice") else: filename = self.name.replace("/", "_") if invoice_format: @@ -661,7 +774,7 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): # SELLER vals["BT-34"], vals["BT-34-1"] = self._prepare_bt34_with_scheme(speedy) if not self.partner_id: - raise UserError(self.env._("Customer is not selected yet.")) + raise UserError(_("Customer is not selected yet.")) buyer_partner_data = self.partner_id._en16931_partner_data() seller_partner_data = self.company_id.partner_id._en16931_partner_data() if self.user_id: @@ -711,7 +824,7 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): vals["BT-80"] = ship_partner_data["country_code"] vals["BT-72"] = self._prepare_bt72(speedy) vals.update(self._prepare_en16931_payment_data(speedy)) - bg25, bg20, totals, base_lines = self._prepare_en16931_invoice_lines(speedy) + bg25, bg20, totals = self._prepare_en16931_invoice_lines(speedy) for allowance_total_field in ("BT-107", "BT-108"): allowance_total = totals[allowance_total_field] if not self.currency_id.is_zero(allowance_total): @@ -720,7 +833,7 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): ) vals["BT-106"] = self.currency_id._en16931_format(totals["BT-106"]) bt109 = totals["BT-106"] - totals["BT-107"] + totals["BT-108"] - bg23, bt110, bt111 = self._prepare_bg23(base_lines, speedy) + bg23, bt110, bt111 = self._prepare_bg23(speedy) vals["BT-109"] = self.currency_id._en16931_format(bt109) vals["BT-110"] = self.currency_id._en16931_format(bt110) vals["BT-110-1"] = self.currency_id.name @@ -740,7 +853,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 +863,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 +874,54 @@ 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(): + 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( + _( + "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: - xml_bytes = generate_xml( + ubl_xml_bytes = generate_xml( data_dict, - flavor=flavor, - level=level, + flavor="ubl-2.1", + level="extended-ctc-fr", check_xsd=True, check_schematron=check_schematron, saxon_server_url=saxon_server_url, @@ -780,30 +932,29 @@ def generate_en16931_xml(self, flavor2level, pdf_invoice_bin=False): 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), + _( + "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() - inv_type = ( - self.move_type == "out_refund" - and self.env._("Refund") - or self.env._("Invoice") - ) + inv_type = self.move_type == "out_refund" and _("Refund") or _("Invoice") if self.invoice_date: invoice_date = format_date( self.env, self.invoice_date, lang_code=self.partner_id.lang ) else: - invoice_date = self.env._("(no date)") + invoice_date = _("(no date)") if self.state == "posted": invoice_number = self.name else: @@ -816,69 +967,55 @@ def _prepare_facturx_pdf_metadata(self): } pdf_metadata = { "author": format_vals["company_name"], - "keywords": ", ".join([inv_type, self.env._("Factur-X")]), - "title": self.env._( + "keywords": ", ".join([inv_type, _("Factur-X")]), + "title": _( "{company_name}: {invoice_type} {invoice_number} dated {invoice_date}" ).format(**format_vals), - "subject": self.env._( + "subject": _( "Factur-X {invoice_type} {invoice_number} dated {invoice_date} " "issued by {company_name}" ).format(**format_vals), } 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 +1025,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,14 +1041,59 @@ 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, but 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 ~1000 checks on those (ISO 19005-3 clauses 6.2.4.3 and + 6.2.11.5). Odoo 16.0 ships OdooPdfFileWriter.convert_to_pdfa(), used by + account_edi_ubl_cii for exactly this; 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. + + 16.0-specific: convert_to_pdfa() only exists on 16.0 in this shape. + """ + # Imported here: OdooPdfFileWriter is 16.0-only, 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_report(self): + """Report action rendering the human-readable invoice. + + Passed as a recordset rather than as the "account.report_invoice_with_payments" + string: that string is the report_name of the standard action, and a + customer module repointing the action to its own QWeb template makes it + unresolvable — _get_report() then falls back to env.ref(), which returns + the ir.ui.view of the same name and raises. + """ + self.ensure_one() + return self.env.ref("account.account_invoices") 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"] .with_context(regular_pdf_invoice=True) - ._render("account.report_invoice_with_payments", [self.id]) + ._render(self._get_pdf_invoice_report(), [self.id]) ) return pdf_invoice_bin @@ -927,21 +1110,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 +1167,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..c8755f9 100644 --- a/account_invoice_en16931/models/account_move_line.py +++ b/account_invoice_en16931/models/account_move_line.py @@ -7,7 +7,7 @@ from stdnum import ean -from odoo import models +from odoo import _, models from odoo.exceptions import UserError from odoo.tools import ( float_compare, @@ -23,9 +23,56 @@ 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( + _( + "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( + _( + "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( + _( + "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"] @@ -35,80 +82,110 @@ def _check_en16931(self, speedy): # this module if len(vat_tax) != 1: raise UserError( - self.env._( + _( "On invoice '%(inv)s', invoice line '%(inv_line)s' should " - "have exactly one VAT tax and not %(count)s.", - inv=self.move_id.display_name, - inv_line=self.display_name, - count=len(vat_tax), + "have exactly one VAT tax and not %(count)s." ) + % { + "inv": self.move_id.display_name, + "inv_line": self.display_name, + "count": len(vat_tax), + } ) 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"): + 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 vat_dict["vatex_code"] = vat_tax.unece_vatex_code vat_dict["vatex_label"] = vat_tax.unece_vatex_id.name - base_line = self.move_id._prepare_product_base_line_for_taxes_computation(self) - self.env["account.tax"]._add_tax_details_in_base_lines( - [base_line], self.company_id - ) - # print('ILine base_line ===================') - # from pprint import pprint - # pprint(base_line) non_vat_taxes = [] - if base_line.get("tax_details", {}).get("taxes_data"): - for tax_data in base_line["tax_details"]["taxes_data"]: - non_vat_tax = tax_data["tax"] - if non_vat_tax.unece_type_code != "VAT": - tax_label = ( - not is_html_empty(non_vat_tax.description) - and html2plaintext(non_vat_tax.description) - or non_vat_tax.name - ) - tax_rate = None - if non_vat_tax.amount_type == "percent": - tax_rate = non_vat_tax.amount - non_vat_taxes.append( - { - "tax_amount": tax_data["raw_tax_amount_currency"], - "base_amount": tax_data["raw_base_amount_currency"], - "tax_rate": tax_rate, - "tax_label": tax_label, - "tax_unece_type_code": non_vat_tax.unece_type_code, - } - ) + for tax_data in self._en16931_compute_taxes(): + non_vat_tax = tax_data["tax"] + if non_vat_tax.unece_type_code != "VAT": + tax_label = ( + not is_html_empty(non_vat_tax.description) + and html2plaintext(non_vat_tax.description) + or non_vat_tax.name + ) + tax_rate = None + if non_vat_tax.amount_type == "percent": + tax_rate = non_vat_tax.amount + non_vat_taxes.append( + { + "tax_amount": tax_data["tax_amount"], + "base_amount": tax_data["base_amount"], + "tax_rate": tax_rate, + "tax_label": tax_label, + "tax_unece_type_code": non_vat_tax.unece_type_code, + } + ) if self.product_uom_id and not self.product_uom_id.unece_code: raise UserError( - self.env._( - "UNECE code is not configured on unit of measure '%s'.", - self.product_uom_id.display_name, - ) + _("UNECE code is not configured on unit of measure '%s'.") + % self.product_uom_id.display_name ) - return vat_dict, non_vat_taxes, base_line + return vat_dict, non_vat_taxes + + # 16.0 backport of the 18.0 generic tax engine + # --------------------------------------------------------------------- + # Upstream builds `base_line` dicts and asks account.tax to fill their + # `tax_details` (_prepare_base_line_for_taxes_computation, + # _add_tax_details_in_base_line(s), ...). None of that exists on 16.0, whose + # only tax entry point is compute_all(). The two helpers below expose just + # what this module consumes, so the callers stay close to upstream. + + def _en16931_compute_taxes(self): + """Per-line tax details, 16.0 flavour. + + Returns a list of {"tax": account.tax, "tax_amount": float, + "base_amount": float}, in the invoice currency, replacing the + `tax_details["taxes_data"]` of the 18.0 base_line. + """ + self.ensure_one() + res = self.tax_ids.compute_all( + self.price_unit * (1 - (self.discount or 0.0) / 100.0), + currency=self.currency_id, + quantity=self.quantity, + product=self.product_id, + partner=self.move_id.partner_id, + is_refund=self.move_id.move_type in ("out_refund", "in_refund"), + ) + tax_obj = self.env["account.tax"] + return [ + { + "tax": tax_obj.browse(tax_vals["id"]), + "tax_amount": tax_vals["amount"], + "base_amount": tax_vals["base"], + } + for tax_vals in res["taxes"] + ] + + def _en16931_gross_price_unit(self): + """Unit price, taxes excluded (BT-148). + + The price_unit may be tax-included, so it cannot be used as is. Upstream + runs the tax engine on a synthetic base_line with quantity=1 and reads + `raw_total_excluded_currency`; compute_all()'s `total_excluded` is the + 16.0 equivalent. Discounts are excluded on purpose: BT-148 is the gross + price, the discount is carried by BT-147. + """ + self.ensure_one() + return self.tax_ids.compute_all( + self.price_unit, + currency=self.currency_id, + quantity=1, + )["total_excluded"] def _prepare_bg25_single_line(self, line_number, totals, speedy): self.ensure_one() - vat_dict, non_vat_taxes, base_line = self._check_en16931(speedy) + vat_dict, non_vat_taxes = self._check_en16931(speedy) # convert price that may be tax-include to tax-exclude price - gross_price_base_line = self.env[ - "account.tax" - ]._prepare_base_line_for_taxes_computation( - None, - currency_id=self.currency_id, - tax_ids=self.tax_ids, - price_unit=self.price_unit, - quantity=1, - ) - self.tax_ids._add_tax_details_in_base_line( - gross_price_base_line, self.company_id - ) gross_price = float_round( - gross_price_base_line["tax_details"]["raw_total_excluded_currency"], + self._en16931_gross_price_unit(), precision_digits=speedy["price_prec"], ) if float_is_zero(self.quantity, precision_digits=speedy["qty_prec"]): @@ -136,7 +213,7 @@ def _prepare_bg25_single_line(self, line_number, totals, speedy): ) line_total = self.price_subtotal + sum([x["tax_amount"] for x in non_vat_taxes]) vat_rate = ( - isinstance(vat_dict.get("vat_rate"), int | float) + isinstance(vat_dict.get("vat_rate"), (int, float)) and speedy["tax_rate_fmt"] % vat_dict["vat_rate"] or None ) @@ -195,16 +272,16 @@ def _prepare_bg25_single_line(self, line_number, totals, speedy): ): vals["BT-134"] = self.start_date vals["BT-135"] = self.end_date - return vals, base_line + return vals def _prepare_bg20_single_line(self, totals, speedy): """Invoice line with price unit < 0""" self.ensure_one() res = [] - vat_dict, non_vat_taxes, base_line = self._check_en16931(speedy) + vat_dict, non_vat_taxes = self._check_en16931(speedy) bt92 = self.price_subtotal * -1 vat_rate = ( - isinstance(vat_dict.get("vat_rate"), int | float) + isinstance(vat_dict.get("vat_rate"), (int, float)) and speedy["tax_rate_fmt"] % vat_dict["vat_rate"] or None ) @@ -221,11 +298,10 @@ def _prepare_bg20_single_line(self, totals, speedy): for non_vat_tax in non_vat_taxes: bt92 = non_vat_tax["tax_amount"] * -1 non_vat_tax_vals = dict(vals) - label = self.env._( - "%(tax_label)s on %(inv_line)s", - tax_label=non_vat_tax["tax_label"], - inv_line=vals["BT-97"], - ) + label = _("%(tax_label)s on %(inv_line)s") % { + "tax_label": non_vat_tax["tax_label"], + "inv_line": vals["BT-97"], + } non_vat_tax_vals.update( { "BT-92": self.currency_id._en16931_format(bt92), @@ -234,4 +310,4 @@ def _prepare_bg20_single_line(self, totals, speedy): ) res.append(non_vat_tax_vals) totals["BT-107"] += bt92 - return res, base_line + return res diff --git a/account_invoice_en16931/models/account_tax.py b/account_invoice_en16931/models/account_tax.py index bbd661c..a368e77 100644 --- a/account_invoice_en16931/models/account_tax.py +++ b/account_invoice_en16931/models/account_tax.py @@ -2,7 +2,7 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import models +from odoo import _, models from odoo.tools import float_compare from odoo.tools.misc import formatLang @@ -15,98 +15,108 @@ def _en16931_check_sale_tax(self): errors = [] amount_fc = float_compare(self.amount, 0, precision_digits=4) if not self.unece_type_id: - errors.append( - self.env._("Tax '%s' has no UNECE Tax Type.", self.display_name) - ) + errors.append(_("Tax '%s' has no UNECE Tax Type.") % self.display_name) elif self.unece_type_code == "VAT": if self.amount_type != "percent": errors.append( - self.env._( + _( "VAT tax '%(tax)s' has Tax Computation set to " "'%(amount_type)s', but all VAT taxes should have " - "Tax Computation set to 'Percentage'.", - tax=self.display_name, - amount_type=dict( + "Tax Computation set to 'Percentage'." + ) + % { + "tax": self.display_name, + "amount_type": dict( self._fields["amount_type"]._description_selection(self.env) ).get(self.amount_type), - ) + } ) if self.amount > 30: # the maxium known VAT rate in the world is 27% errors.append( - self.env._( + _( "VAT tax '%(tax)s' has a rate of %(rate)s %%, which is above " "the known maximum VAT rate in the world. There is certainly " - "a mistake in the rate.", - tax=self.display_name, - rate=formatLang(self.env, self.amount, digits=4), + "a mistake in the rate." ) + % { + "tax": self.display_name, + "rate": formatLang(self.env, self.amount, digits=4), + } ) if not self.unece_categ_id: errors.append( - self.env._( - "VAT tax '%s' has no UNECE Tax Category.", self.display_name - ) + _("VAT tax '%s' has no UNECE Tax Category.") % self.display_name ) elif self.unece_categ_code == "S": if amount_fc <= 0: errors.append( - self.env._( + _( "VAT tax '%(tax)s' has UNECE Tax Category '%(categ)s' " - "so it's rate (%(rate)s) should be strictly positive.", - tax=self.display_name, - categ=self.unece_categ_id.display_name, - rate=formatLang(self.env, self.amount, digits=4), + "so it's rate (%(rate)s) should be strictly positive." ) + % { + "tax": self.display_name, + "categ": self.unece_categ_id.display_name, + "rate": formatLang(self.env, self.amount, digits=4), + } ) if self.unece_vatex_id: errors.append( - self.env._( + _( "VAT tax '%(tax)s' has UNECE Tax Category '%(categ)s' " - "so it should not have a VAT Exemption Reason.", - tax=self.display_name, - categ=self.unece_categ_id.display_name, + "so it should not have a VAT Exemption Reason." ) + % { + "tax": self.display_name, + "categ": self.unece_categ_id.display_name, + } ) elif self.unece_categ_code not in ("S", "Z"): if amount_fc: errors.append( - self.env._( + _( "VAT tax '%(tax)s' has UNECE Tax Category '%(categ)s' " - "so it's rate (%(rate)s) should be null.", - tax=self.display_name, - categ=self.unece_categ_id.display_name, - rate=formatLang(self.env, self.amount, digits=4), + "so it's rate (%(rate)s) should be null." ) + % { + "tax": self.display_name, + "categ": self.unece_categ_id.display_name, + "rate": formatLang(self.env, self.amount, digits=4), + } ) if not self.unece_vatex_id: errors.append( - self.env._( + _( "VAT tax '%(tax)s' has UNECE Tax Category '%(categ)s' " - "so it should have a VAT Exemption Reason.", - tax=self.display_name, - categ=self.unece_categ_id.display_name, + "so it should have a VAT Exemption Reason." ) + % { + "tax": self.display_name, + "categ": self.unece_categ_id.display_name, + } ) else: if amount_fc < 0: errors.append( - self.env._( + _( "Tax '%(tax)s' has a negative rate (%(rate)s). " "This is not supported by the community implementation of " - "EN16931 for the moment.", - tax=self.display_name, - rate=formatLang(self.env, self.amount, digits=4), + "EN16931 for the moment." ) + % { + "tax": self.display_name, + "rate": formatLang(self.env, self.amount, digits=4), + } ) if not self.include_base_amount: errors.append( - self.env._( + _( "Tax '%(tax)s' is not a VAT tax and is " "not configured with the option 'Affect Base of " "Subsequent Taxes'. This is not supported by the community " - "implementation of EN16931 for the moment.", - tax=self.display_name, + "implementation of EN16931 for the moment." ) + % {"tax": self.display_name} ) return errors diff --git a/account_invoice_en16931/models/ir_actions_report.py b/account_invoice_en16931/models/ir_actions_report.py index 92d6ddb..e25dc3f 100644 --- a/account_invoice_en16931/models/ir_actions_report.py +++ b/account_invoice_en16931/models/ir_actions_report.py @@ -2,12 +2,37 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import models +from odoo import api, models class IrActionsReport(models.Model): _inherit = "ir.actions.report" + @api.model + def _get_en16931_invoice_reports(self): + """Report actions rendering the human-readable customer invoice. + + Resolved through the XML IDs of the actions, never through their + report_name: report_name is a plain field, and repointing the standard + invoice action to a custom QWeb template is a very common + customisation. When that happens, the standard report_name literals + used by account (_is_invoice_report) no longer match anything, and + env.ref() on them returns the ir.ui.view of the same name. + """ + reports = self.browse() + for xmlid in ( + "account.account_invoices", + "account.account_invoices_without_payment", + ): + reports |= self.env.ref(xmlid, raise_if_not_found=False) or self.browse() + return reports + + @api.model + def _is_en16931_invoice_report(self, report_ref): + return self._is_invoice_report(report_ref) or self._get_report( + report_ref + ) in self._get_en16931_invoice_reports() + def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None): # It works, but: # - when you click on the "Print" button or use the "Print" menu, @@ -22,12 +47,14 @@ def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None): collected_streams and res_ids and len(res_ids) == 1 - and self._is_invoice_report(report_ref) + and self._is_en16931_invoice_report(report_ref) 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..a13ee64 100644 --- a/account_invoice_en16931/models/res_company.py +++ b/account_invoice_en16931/models/res_company.py @@ -2,7 +2,7 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import fields, models +from odoo import _, fields, models from odoo.exceptions import UserError @@ -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" ) @@ -31,12 +39,17 @@ class ResCompany(models.Model): # TODO add field to choose between Factur-X and UBL def _compute_no_vat_taxes(self): - rg_res = self.env["account.tax"]._read_group( + # On 16.0, read_group() returns dicts and the count is exposed as + # _count: the 18.0 signature (groupby=, aggregates=) and its + # recordset results do not exist yet. + rg_res = self.env["account.tax"].read_group( [("company_id", "in", self.ids), ("unece_type_code", "=", "VAT")], - groupby=["company_id"], - aggregates=["__count"], + ["company_id"], + ["company_id"], ) - mapped_data = {company.id: vat_tax_count for (company, vat_tax_count) in rg_res} + mapped_data = { + x["company_id"][0]: x["company_id_count"] for x in rg_res if x["company_id"] + } for company in self: company.no_vat_taxes = not bool(mapped_data.get(company.id, 0)) @@ -54,36 +67,26 @@ def _en16931_checks(self): # révision de la norme EN16931 limitent les prix unitaires à 4 décimales" if price_prec > 4: errors.append( - self.env._( - "Price decimal precision is %s. For EN16931, " - "the maximum value is 4.", - price_prec, - ) + _("Price decimal precision is %s. For EN16931, the maximum value is 4.") + % price_prec ) qty_prec = dpo.precision_get("Product Unit of Measure") if qty_prec > 4: errors.append( - self.env._( + _( "Product Unit of Measure decimal precision is %s. For EN16931, " - "the maximum value is 4.", - 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, + "the maximum value is 4." ) + % qty_prec ) if errors: raise UserError( - self.env._( + _( "The following errors have been detected in company %(company)s " - "that block EN16931 e-invoicing:\n%(err_msg)s", - company=self.display_name, - err_msg="\n".join([f"- {error}" for error in errors]), + "that block EN16931 e-invoicing:\n%(err_msg)s" ) + % { + "company": self.display_name, + "err_msg": "\n".join([f"- {error}" for error in errors]), + } ) diff --git a/account_invoice_en16931/models/res_partner.py b/account_invoice_en16931/models/res_partner.py index 6c11190..9ea4740 100644 --- a/account_invoice_en16931/models/res_partner.py +++ b/account_invoice_en16931/models/res_partner.py @@ -2,7 +2,7 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import models +from odoo import _, models from odoo.exceptions import UserError @@ -18,11 +18,11 @@ def _en16931_partner_data(self, country_required=True): country = self.country_id or self.commercial_partner_id.country_id if country_required and not country: raise UserError( - self.env._( + _( "Country is not set on partner '%s'. " - "Country is required in EN16931.", - self.display_name, + "Country is required in EN16931." ) + % self.display_name ) vals = { "name": self.commercial_partner_id.name, diff --git a/account_invoice_en16931/views/account_move.xml b/account_invoice_en16931/views/account_move.xml index 7f64091..981c0f4 100644 --- a/account_invoice_en16931/views/account_move.xml +++ b/account_invoice_en16931/views/account_move.xml @@ -9,9 +9,36 @@ account.move - - - + + + + + + + + + + + + diff --git a/account_invoice_en16931/wizards/account_invoice_en16931_generate.py b/account_invoice_en16931/wizards/account_invoice_en16931_generate.py index 21d6c62..bb15e98 100644 --- a/account_invoice_en16931/wizards/account_invoice_en16931_generate.py +++ b/account_invoice_en16931/wizards/account_invoice_en16931_generate.py @@ -7,7 +7,7 @@ import zipfile from io import BytesIO -from odoo import Command, api, fields, models +from odoo import Command, _, api, fields, models from odoo.exceptions import UserError @@ -74,9 +74,7 @@ def default_get(self, fields_list): move_ids.append(move.id) if not move_ids: raise UserError( - self.env._( - "No customer invoice/refund in draft or posted state selected." - ) + _("No customer invoice/refund in draft or posted state selected.") ) res["move_ids"] = [Command.set(move_ids)] return res @@ -84,16 +82,14 @@ def default_get(self, fields_list): def generate_button(self): self.ensure_one() if not self.move_ids: - raise UserError( - self.env._("You must select at least one customer invoice/refund.") - ) + raise UserError(_("You must select at least one customer invoice/refund.")) if len(self.move_ids) == 1: move = self.move_ids file_b64 = move._get_en16931_invoice_bin(self.invoice_format, b64=True) filename = move._prepare_en16931_filename(self.invoice_format) else: if not self.archive_format: - raise UserError(self.env._("You must select an archive format.")) + raise UserError(_("You must select an archive format.")) if self.archive_format == "zip": zip_buffer = BytesIO() with zipfile.ZipFile(zip_buffer, "w") as zip_file: @@ -128,7 +124,7 @@ def generate_button(self): } ) action = { - "name": self.env._("Invoice(s)"), + "name": _("Invoice(s)"), "type": "ir.actions.act_url", "url": f"web/content/?model={self._name}&id={self.id}&" f"filename_field=filename&field=file_data&download=true&" diff --git a/account_invoice_en16931/wizards/account_invoice_en16931_generate_view.xml b/account_invoice_en16931/wizards/account_invoice_en16931_generate_view.xml index 1f52b92..c83c5fe 100644 --- a/account_invoice_en16931/wizards/account_invoice_en16931_generate_view.xml +++ b/account_invoice_en16931/wizards/account_invoice_en16931_generate_view.xml @@ -15,8 +15,7 @@
diff --git a/account_invoice_en16931/wizards/res_config_settings.py b/account_invoice_en16931/wizards/res_config_settings.py index ba73169..c9e8a59 100644 --- a/account_invoice_en16931/wizards/res_config_settings.py +++ b/account_invoice_en16931/wizards/res_config_settings.py @@ -2,7 +2,7 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import fields, models +from odoo import _, fields, models class ResConfigSettings(models.TransientModel): @@ -11,6 +11,7 @@ class ResConfigSettings(models.TransientModel): en16931_default_pdf_invoice = fields.Selection( related="company_id.en16931_default_pdf_invoice", readonly=False ) + en16931_issuer = fields.Boolean(related="company_id.en16931_issuer", readonly=False) no_vat_taxes = fields.Boolean(related="company_id.no_vat_taxes") no_vat_taxes_vatex_id = fields.Many2one( related="company_id.no_vat_taxes_vatex_id", readonly=False @@ -18,3 +19,25 @@ class ResConfigSettings(models.TransientModel): saxon_server_url = fields.Char( string="Specific Saxon Server URL", config_parameter="en16931.saxon_server_url" ) + saxon_validation_blocking = fields.Boolean( + string="Raise Error if Saxon Validation Fails", + config_parameter="en16931.saxon_validation_blocking", + ) + + def button_en16931_checks(self): + """Run the EN16931 configuration checks of the current company on demand. + + _en16931_checks() raises a UserError listing what is wrong, so reaching + the end means the company is properly configured. + """ + self.ensure_one() + self.company_id._en16931_checks() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "type": "success", + "message": _("The EN16931 configuration of company %s is valid.") + % self.company_id.display_name, + }, + } diff --git a/account_invoice_en16931/wizards/res_config_settings_view.xml b/account_invoice_en16931/wizards/res_config_settings_view.xml index 3484888..f5096de 100644 --- a/account_invoice_en16931/wizards/res_config_settings_view.xml +++ b/account_invoice_en16931/wizards/res_config_settings_view.xml @@ -5,31 +5,120 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> + res.config.settings - - - - - - +

eInvoicing

+
+
- - - +
+ Default PDF Invoice Generation +
+ Format used when a PDF invoice is generated. +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ Specific Saxon Server URL +
+ The default value for the Saxon Server URL is + http://localhost:5000/transform If you need to use + another URL, you can enter it below. +
+
+ +
+
+
+
+
+ +
+
+
+
+
- - - - - +
+
+ Company VAT Exemption Reason +
+ The current company doesn't have any active VAT tax. + Either it is a mistake and you should update the + configuration of the taxes. Or it is normal (because + the company is not a VAT-registrered company for + example) and you must set the VAT exemption reason + below. +
+
+ + +
+
+
+
+ diff --git a/account_invoice_en16931_py3o/__manifest__.py b/account_invoice_en16931_py3o/__manifest__.py index 1e953d4..5c80192 100644 --- a/account_invoice_en16931_py3o/__manifest__.py +++ b/account_invoice_en16931_py3o/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Invoice EN16931 Py3o", - "version": "18.0.1.0.0", + "version": "16.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Glue module to generate EN16931 invoices with Py3o", diff --git a/account_invoice_en16931_py3o/models/py3o_report.py b/account_invoice_en16931_py3o/models/py3o_report.py index bfb9721..d72d409 100644 --- a/account_invoice_en16931_py3o/models/py3o_report.py +++ b/account_invoice_en16931_py3o/models/py3o_report.py @@ -11,15 +11,18 @@ class Py3oReport(models.TransientModel): def _postprocess_report(self, model_instance, result_path): report = self.ir_actions_report_id if ( - self.env["ir.actions.report"]._is_invoice_report(report.report_name) + self.env["ir.actions.report"]._is_en16931_invoice_report(report) and model_instance and len(model_instance) == 1 and report.report_type == "py3o" and report.py3o_filetype == "pdf" and result_path + and not self.env.context.get("regular_pdf_invoice") ): move = model_instance - variant = move._get_pdf_invoice_variant() - if variant: - move._regular_pdf_invoice_to_en16931_pdf_invoice(result_path, variant) + invoice_format = move._get_pdf_invoice_format() + if invoice_format: + move._regular_pdf_invoice_to_en16931_pdf_invoice( + result_path, invoice_format + ) return super()._postprocess_report(model_instance, result_path) diff --git a/l10n_fr_account_invoice_en16931/__init__.py b/l10n_fr_account_invoice_en16931/__init__.py index 0650744..aee8895 100644 --- a/l10n_fr_account_invoice_en16931/__init__.py +++ b/l10n_fr_account_invoice_en16931/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizards diff --git a/l10n_fr_account_invoice_en16931/__manifest__.py b/l10n_fr_account_invoice_en16931/__manifest__.py index 8303bf6..299abc3 100644 --- a/l10n_fr_account_invoice_en16931/__manifest__.py +++ b/l10n_fr_account_invoice_en16931/__manifest__.py @@ -4,7 +4,7 @@ { "name": "France-specific Invoice EN16931", - "version": "18.0.1.0.0", + "version": "16.0.1.0.0", "category": "Accounting", "license": "AGPL-3", "summary": "Localization of Invoice EN16931 for France", diff --git a/l10n_fr_account_invoice_en16931/i18n/fr.po b/l10n_fr_account_invoice_en16931/i18n/fr.po index 23e3e63..02b25b2 100644 --- a/l10n_fr_account_invoice_en16931/i18n/fr.po +++ b/l10n_fr_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:15+0000\n" -"PO-Revision-Date: 2026-07-24 21:15+0000\n" +"POT-Creation-Date: 2026-08-07 20:46+0000\n" +"PO-Revision-Date: 2026-08-07 20:46+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -15,22 +15,169 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b1 +msgid "B1. Dépôt d'une facture de bien" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b2 +msgid "B2. Dépôt d'une facture de bien déjà payée" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b4 +msgid "B4. Dépôt d'une facture définitive (après acompte) de bien" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b7 +msgid "" +"B7. Dépôt d'une facture de bien ayant fait l'objet d'un e-reporting (TVA " +"déjà collectée)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b8 +msgid "B8. Dépôt d'une facture multi-vendeurs de biens" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b9 +msgid "B9. Dépôt d'une facture bidirectionnelle de biens" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_move__business_process_type +msgid "Business Process Type" +msgstr "Type de processus métier" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields,help:l10n_fr_account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,help:l10n_fr_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: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_invoice_en16931_generate__invoice_format__facturx_old_chorus +msgid "Factur-X compatible with Chorus Pro portal" +msgstr "Factur-X compatible avec le portail Chorus Pro" + #. module: l10n_fr_account_invoice_en16931 #: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_bank_statement_line__fr_einvoicing_internal #: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_move__fr_einvoicing_internal msgid "Internal Invoice/Refund" msgstr "Facture/avoir interne" +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model,name:l10n_fr_account_invoice_en16931.model_account_invoice_en16931_generate +msgid "Invoice EN16931 Generate" +msgstr "Générer la facture EN16931" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_invoice_en16931_generate__invoice_format +msgid "Invoice Format" +msgstr "Format de facture" + #. module: l10n_fr_account_invoice_en16931 #: model:ir.model,name:l10n_fr_account_invoice_en16931.model_account_move msgid "Journal Entry" msgstr "Pièce comptable" +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m1 +msgid "" +"M1. Dépôt d'une facture double (livraison de biens et services qui ne sont " +"pas accessoires l'une de l'autre)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m2 +msgid "M2. Dépôt d'une facture double déjà payée" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m4 +msgid "M4. Dépôt d'une facture définitive (après acompte) double" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m8 +msgid "" +"M8. Dépôt d'une facture multi-vendeurs double, contenant des factures " +"unitaires qui ne sont pas toutes Sx ou Bx" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m9 +msgid "M9. Dépôt d'une facture bidirectionnelle double" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s1 +msgid "S1. Dépôt d'une facture de prestation de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s2 +msgid "S2. Dépôt d'une facture de prestation de service déjà payée" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s3 +msgid "" +"S3. Dépôt d'une demande de paiement de sous-traitance avec paiement direct " +"(B2G)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s4 +msgid "S4. Dépôt d'une facture définitive (après acompte) de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s5 +msgid "S5. Dépôt par un sous-traitant d'une facture de prestation de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s6 +msgid "S6. Dépôt par un cotraitant d'une facture de prestation de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s7 +msgid "" +"S7. Dépôt d'une facture de prestation de service ayant fait l'objet d'un " +"e-reporting (TVA déjà collectée)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s8 +msgid "S8. Dépôt d'une facture multi-vendeurs de services" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s9 +msgid "S9. Dépôt d'une facture bidirectionnelle de services" +msgstr "" + #. module: l10n_fr_account_invoice_en16931 #: model_terms:ir.ui.view,arch_db:l10n_fr_account_invoice_en16931.view_move_form msgid "" "This is an internal refund/invoice. It will not be sent to " "the customer by the accredited platform." msgstr "" -"Ceci est un avoir/facture interne qui ne sera pas " -"envoyé au client par la plateforme agréée." +"Ceci est un avoir/facture interne qui ne sera pas envoyé au" +" client par la plateforme agréée." + +#. module: l10n_fr_account_invoice_en16931 +#. odoo-python +#: code:addons/l10n_fr_account_invoice_en16931/models/account_move.py:0 +msgid "" +"When Business Process Type is B4, S4 or M4, Invoice Type Code cannot be 386," +" 500 or 503 (rule G1.60)." +msgstr "" +"Quand le type de processus métier est B4, S4 ou M4, le code de type de facture " +"ne peut pas être 386, 500 ou 503 (règle G1.60)." diff --git a/l10n_fr_account_invoice_en16931/i18n/l10n_fr_account_invoice_en16931.pot b/l10n_fr_account_invoice_en16931/i18n/l10n_fr_account_invoice_en16931.pot index 08ef19c..9d89038 100644 --- a/l10n_fr_account_invoice_en16931/i18n/l10n_fr_account_invoice_en16931.pot +++ b/l10n_fr_account_invoice_en16931/i18n/l10n_fr_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:36+0000\n" -"PO-Revision-Date: 2026-07-17 06:36+0000\n" +"POT-Creation-Date: 2026-08-07 20:45+0000\n" +"PO-Revision-Date: 2026-08-07 20:45+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -15,20 +15,165 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b1 +msgid "B1. Dépôt d'une facture de bien" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b2 +msgid "B2. Dépôt d'une facture de bien déjà payée" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b4 +msgid "B4. Dépôt d'une facture définitive (après acompte) de bien" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b7 +msgid "" +"B7. Dépôt d'une facture de bien ayant fait l'objet d'un e-reporting (TVA " +"déjà collectée)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b8 +msgid "B8. Dépôt d'une facture multi-vendeurs de biens" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_b9 +msgid "B9. Dépôt d'une facture bidirectionnelle de biens" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_move__business_process_type +msgid "Business Process Type" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields,help:l10n_fr_account_invoice_en16931.field_account_bank_statement_line__business_process_type +#: model:ir.model.fields,help:l10n_fr_account_invoice_en16931.field_account_move__business_process_type +msgid "Business Term BT-23 in EN16931" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_invoice_en16931_generate__invoice_format__facturx_old_chorus +msgid "Factur-X compatible with Chorus Pro portal" +msgstr "" + #. module: l10n_fr_account_invoice_en16931 #: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_bank_statement_line__fr_einvoicing_internal #: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_move__fr_einvoicing_internal msgid "Internal Invoice/Refund" msgstr "" +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model,name:l10n_fr_account_invoice_en16931.model_account_invoice_en16931_generate +msgid "Invoice EN16931 Generate" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields,field_description:l10n_fr_account_invoice_en16931.field_account_invoice_en16931_generate__invoice_format +msgid "Invoice Format" +msgstr "" + #. module: l10n_fr_account_invoice_en16931 #: model:ir.model,name:l10n_fr_account_invoice_en16931.model_account_move msgid "Journal Entry" msgstr "" +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m1 +msgid "" +"M1. Dépôt d'une facture double (livraison de biens et services qui ne sont " +"pas accessoires l'une de l'autre)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m2 +msgid "M2. Dépôt d'une facture double déjà payée" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m4 +msgid "M4. Dépôt d'une facture définitive (après acompte) double" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m8 +msgid "" +"M8. Dépôt d'une facture multi-vendeurs double, contenant des factures " +"unitaires qui ne sont pas toutes Sx ou Bx" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_m9 +msgid "M9. Dépôt d'une facture bidirectionnelle double" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s1 +msgid "S1. Dépôt d'une facture de prestation de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s2 +msgid "S2. Dépôt d'une facture de prestation de service déjà payée" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s3 +msgid "" +"S3. Dépôt d'une demande de paiement de sous-traitance avec paiement direct " +"(B2G)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s4 +msgid "S4. Dépôt d'une facture définitive (après acompte) de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s5 +msgid "S5. Dépôt par un sous-traitant d'une facture de prestation de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s6 +msgid "S6. Dépôt par un cotraitant d'une facture de prestation de service" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s7 +msgid "" +"S7. Dépôt d'une facture de prestation de service ayant fait l'objet d'un " +"e-reporting (TVA déjà collectée)" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s8 +msgid "S8. Dépôt d'une facture multi-vendeurs de services" +msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#: model:ir.model.fields.selection,name:l10n_fr_account_invoice_en16931.selection__account_move__business_process_type__fr_s9 +msgid "S9. Dépôt d'une facture bidirectionnelle de services" +msgstr "" + #. module: l10n_fr_account_invoice_en16931 #: model_terms:ir.ui.view,arch_db:l10n_fr_account_invoice_en16931.view_move_form msgid "" "This is an internal refund/invoice. It will not be sent to " "the customer by the accredited platform." msgstr "" + +#. module: l10n_fr_account_invoice_en16931 +#. odoo-python +#: code:addons/l10n_fr_account_invoice_en16931/models/account_move.py:0 +msgid "" +"When Business Process Type is B4, S4 or M4, Invoice Type Code cannot be 386," +" 500 or 503 (rule G1.60)." +msgstr "" diff --git a/l10n_fr_account_invoice_en16931/models/account_move.py b/l10n_fr_account_invoice_en16931/models/account_move.py index 1f1da80..1753e3c 100644 --- a/l10n_fr_account_invoice_en16931/models/account_move.py +++ b/l10n_fr_account_invoice_en16931/models/account_move.py @@ -3,9 +3,14 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 +from io import BytesIO + from unidecode import unidecode -from odoo import fields, models +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_compare class AccountMove(models.Model): @@ -14,9 +19,187 @@ class AccountMove(models.Model): fr_einvoicing_internal = fields.Boolean( string="Internal Invoice/Refund", copy=False, tracking=True ) + # We have a specific strategy for the field 'business_process_type' + # to correctly handle the fact that the value is different for an invoice already + # paid or an unpaid invoice. We don't want a computed field that depends + # on 'payment_state', because we don't want to switch from B1 to B2 (for example) + # once the invoice is paid. And we want the user to be able to set a value + # manually, for example S3/S5/S6. So the strategy is the following: + # 1. the user can set the value manually when the invoice is draft, + # 2. if no value has been set, odoo auto-computes a value, + # 3. if no value has been set and the invoice is posted, odoo will write + # the auto-computed value on the first generation of an EN16931 invoice. + # That way, after confirmation of a customer invoice that is already paid, + # if the user first reconciles with the payment and then sends the invoice, + # the value will be B2 (for example) and not B1. + # For an unpaid customer invoice, when the user confirms and then sends + # the invoice, odoo will auto-set the value to B1, and this value + # will not change when the payment is later received and reconciled. + business_process_type = fields.Selection( + selection_add=[ + ("fr_B1", "B1. Dépôt d'une facture de biens"), + ("fr_S1", "S1. Dépôt d'une facture de prestation de service"), + ( + "fr_M1", + "M1. Dépôt d'une facture mixte (livraison de biens et services " + "qui ne sont pas accessoires l'une de l'autre)", + ), + ("fr_B2", "B2. Dépôt d'une facture de biens déjà payée"), + ("fr_S2", "S2. Dépôt d'une facture de prestation de service déjà payée"), + ("fr_M2", "M2. Dépôt d'une facture mixte déjà payée"), + ( + "fr_S3", + "S3. Dépôt d'une demande de paiement de sous-traitance avec " + "paiement direct (B2G)", + ), + ("fr_B4", "B4. Dépôt d'une facture définitive (après acompte) de biens"), + ("fr_S4", "S4. Dépôt d'une facture définitive (après acompte) de services"), + ("fr_M4", "M4. Dépôt d'une facture définitive (après acompte) mixte"), + ( + "fr_S5", + "S5. Dépôt par un sous-traitant d'une facture de prestation de service", + ), + ( + "fr_S6", + "S6. Dépôt par un cotraitant d'une facture de prestation de service", + ), + ( + "fr_B7", + "B7. Dépôt d'une facture de biens ayant fait l'objet d'un e-reporting " + "(TVA déjà collectée)", + ), + ( + "fr_S7", + "S7. Dépôt d'une facture de prestation de service ayant fait l'objet " + "d'un e-reporting (TVA déjà collectée)", + ), + ("fr_B8", "B8. Dépôt d'une facture multi-vendeurs de biens"), + ("fr_S8", "S8. Dépôt d'une facture multi-vendeurs de services"), + ( + "fr_M8", + "M8. Dépôt d'une facture multi-vendeurs mixte, contenant des " + "factures unitaires qui ne sont pas toutes Sx ou Bx", + ), + ("fr_B9", "B9. Dépôt d'une facture bidirectionnelle de biens"), + ("fr_S9", "S9. Dépôt d'une facture bidirectionnelle de services"), + ("fr_M9", "M9. Dépôt d'une facture bidirectionnelle mixte"), + ], + ondelete={ + "fr_B1": "set null", + "fr_S1": "set null", + "fr_M1": "set null", + "fr_B2": "set null", + "fr_S2": "set null", + "fr_M2": "set null", + "fr_S3": "set null", + "fr_B4": "set null", + "fr_S4": "set null", + "fr_M4": "set null", + "fr_S5": "set null", + "fr_S6": "set null", + "fr_B7": "set null", + "fr_S7": "set null", + "fr_B8": "set null", + "fr_S8": "set null", + "fr_M8": "set null", + "fr_B9": "set null", + "fr_S9": "set null", + "fr_M9": "set null", + }, + ) + + @api.constrains("business_process_type", "invoice_type_code") + def _check_business_process_type(self): + for move in self: + if move.is_sale_document(): + # G1.60 + if move.business_process_type in ( + "fr_B4", + "fr_S4", + "fr_M4", + ) and move.invoice_type_code in ("386", "500", "503"): + raise ValidationError( + _( + "When Business Process Type is B4, S4 or M4, " + "Invoice Type Code cannot be 386, 500 or 503 " + "(rule G1.60)." + ) + ) + # BR-FR-CPRO-23 and BR-FR-CPRO-24 are checked in the + # module l10n_fr_einvoicing + + def _prepare_bt23(self, speedy): + self.ensure_one() + bt23 = super()._prepare_bt23(speedy) + if bt23: + return bt23 + if not self.company_id.is_france_country: + return None + # OCA module intrastat_base + has_is_accessory_cost = hasattr( + self.env["product.template"], "is_accessory_cost" + ) + # 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" + ) + line_types = [] + has_deduct_down_payment = False + for line in self.invoice_line_ids: + if line.display_type == "product": + # If an invoice line has no product, we consider it is a service + ptype = line.product_id and line.product_id.type or "service" + is_accessory_cost = ( + has_is_accessory_cost and line.product_id.is_accessory_cost or False + ) + if ( + has_is_downpayment + and line.is_downpayment + and float_compare(line.quantity, 0, precision_digits=qty_prec) < 0 + ): + has_deduct_down_payment = True + continue + line_types.append((ptype, is_accessory_cost)) + service_only = all( + [ptype == "service" for (ptype, is_accessory_cost) in line_types] + ) + at_least_one_product = any( + [ + ptype in self._EN16931_GOODS_TYPES + for (ptype, is_accessory_cost) in line_types + ] + ) + all_products_or_accessory_costs = all( + [ + ptype in self._EN16931_GOODS_TYPES or is_accessory_cost + for (ptype, is_accessory_cost) in line_types + ] + ) + paid = self.payment_state == "paid" + if service_only: + if has_deduct_down_payment: + business_process_type = "fr_S4" + else: + business_process_type = paid and "fr_S2" or "fr_S1" + elif at_least_one_product and all_products_or_accessory_costs: + if has_deduct_down_payment: + business_process_type = "fr_B4" + else: + business_process_type = paid and "fr_B2" or "fr_B1" + else: + if has_deduct_down_payment: + business_process_type = "fr_M4" + else: + business_process_type = paid and "fr_M2" or "fr_M1" + if self.state == "posted": + self.sudo().write({"business_process_type": business_process_type}) + return business_process_type[3:] def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): vals = super()._prepare_en16931_dict(speedy, pdf_invoice_bin=pdf_invoice_bin) + vals["BT-23"] = self._prepare_bt23(speedy) chorus = ( hasattr(self, "fr_directory_partner_entity_type") and self.fr_directory_partner_entity_type == "public" @@ -36,6 +219,18 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): seller_siret = self.company_id.partner_id._get_siret() if seller_siret: vals["BT-29"]["0009"] = seller_siret + if self.env.context.get("chorus_old_xml_syntax"): + vals.update( + { + "BT-30": seller_siret, + "BT-30-1": "0009", + } + ) + if self.payment_state == "paid": + vals["BT-23"] = "A2" + else: + vals["BT-23"] = "A1" + # BUYER buyer_siren = self.partner_id._get_siren() if buyer_siren: @@ -49,11 +244,20 @@ def _prepare_en16931_dict(self, speedy, pdf_invoice_bin=False): buyer_siret = self.commercial_partner_id._get_siret() if buyer_siret: vals["BT-46"]["0009"] = buyer_siret + if self.env.context.get("chorus_old_xml_syntax"): + vals.update( + { + "BT-47": buyer_siret, + "BT-47-1": "0009", + } + ) if ( self.fr_directory_line_id.type == "routing_code" and self.fr_directory_line_id.routing_code ): vals["BT-46"]["0240"] = self.fr_directory_line_id.routing_code + if self.env.context.get("chorus_old_xml_syntax"): + vals["BT-10"] = self.fr_directory_line_id.routing_code vals["BT-56-0"] = ( self.fr_directory_line_id.routing_code_name ) # UBL ? @@ -104,3 +308,20 @@ def _prepare_bg1(self, speedy): if self.fr_einvoicing_internal: res.append({"BT-21": "BAR", "BT-22": "ARCHIVEONLY"}) return res + + def _get_en16931_invoice_bin(self, invoice_format, b64=False): + self.ensure_one() + if invoice_format == "facturx_old_chorus": + pdf_invoice_bin = self._get_pdf_invoice_bin() + with BytesIO(pdf_invoice_bin) as pdf_bytesio: + self.with_context( + chorus_old_xml_syntax=True + )._regular_pdf_invoice_to_en16931_pdf_invoice( + pdf_bytesio, invoice_format + ) + pdf_bytesio.seek(0) + invoice_bin = pdf_bytesio.read() + if b64: + invoice_bin = base64.encodebytes(invoice_bin) + return invoice_bin + return super()._get_en16931_invoice_bin(invoice_format, b64=b64) diff --git a/l10n_fr_account_invoice_en16931/views/account_move.xml b/l10n_fr_account_invoice_en16931/views/account_move.xml index 86c72d1..8363f0e 100644 --- a/l10n_fr_account_invoice_en16931/views/account_move.xml +++ b/l10n_fr_account_invoice_en16931/views/account_move.xml @@ -9,14 +9,20 @@ account.move - - + + + + + 0
- - + - - - - - -
diff --git a/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py b/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py index ceb7842..a0327bc 100644 --- a/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py +++ b/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py @@ -4,7 +4,7 @@ from markupsafe import Markup -from odoo import Command, api, fields, models +from odoo import Command, _, api, fields, models from odoo.exceptions import UserError @@ -104,14 +104,12 @@ def run(self): if self.detail_required: if not self.detail_ids: raise UserError( - self.env._( - "For this status, you must create at least one line of details." - ) + _("For this status, you must create at least one line of details.") ) if self.confirm_required and not self.confirm: if status == "refused": raise UserError( - self.env._( + _( "You must confirm the refusal of the vendor bill/refund, " "or click on cancel. Reminder: the refusal is definitive. " "On the contrary, if you want to block the vendor bill " @@ -120,21 +118,18 @@ def run(self): ) else: raise UserError( - self.env._( - "You must confirm the creation of the event or click on cancel." - ) + _("You must confirm the creation of the event or click on cancel.") ) event = ( self.env["fr.einvoicing.event"].sudo().create(self._prepare_event_vals()) ) self.move_id.message_post( body=Markup( - self.env._( + _( "Event %(event)s created.", - event=event.display_name, - event_id=event.id, + "data-oe-id=%(event_id)s>%(event)s created." ) + % {"event": event.display_name, "event_id": event.id} ) ) if status == "refused": diff --git a/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual_view.xml b/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual_view.xml index fa36911..1eb993d 100644 --- a/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual_view.xml +++ b/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual_view.xml @@ -14,16 +14,12 @@ @@ -34,48 +30,47 @@ - + - +
- +