From e4ffe090811e35e96d76d19d2636fd97967d3206 Mon Sep 17 00:00:00 2001 From: CarmenMiranda Date: Fri, 16 Jan 2026 20:46:06 +0000 Subject: [PATCH 01/23] [FIX] website_event: Ease attendee form inputs extensibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the addition of identification questions like "name", "email", and "phone" in the commit [1] as event questions instead of having them static, we could add custom data, such as fields for the address, with static inputs in the form. After that addition, it's no longer possible because the registration gives us the following error when trying to convert data that isn't a M2o ID or an Integer value: invalid literal for int() with base 10 By adding the check for the field's type, we can still add custom fields with static fields in the template, as an alternative, given that there's no question type for other fields. The fix is needed to allow custom modules overrides. [1]: https://github.com/odoo/odoo/commit/6b8daa880c closes odoo/odoo#244378 Signed-off-by: Noé Antoine (nan) --- addons/website_event/controllers/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py index 97a54b38531d09..81d6ab9d36a8a4 100644 --- a/addons/website_event/controllers/main.py +++ b/addons/website_event/controllers/main.py @@ -299,7 +299,10 @@ def _process_attendees_form(self, event, form_details): registration_index, field_name = key_values if field_name not in registration_fields: continue - registrations.setdefault(registration_index, dict())[field_name] = int(value) or False + # Only cast when needed, as it might crash here for custom inputs in overrides + if isinstance(registration_fields[field_name], (fields.Many2one, fields.Integer)): + value = int(value) or False + registrations.setdefault(registration_index, dict())[field_name] = value continue if len(key_values) != 3: From f7c7a0e4d8e32224cf6c75ddfbf6ab0e2ba6bcc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Rahir=20=28rar=29?= Date: Tue, 17 Feb 2026 08:04:23 +0100 Subject: [PATCH 02/23] [FIX] spreadsheet: cannot undo a list duplication How to reproduce: - insert an odoo list in a spreadsheet - duplicate the list from the sidepanel - undo with Ctrl+z -> crash The command "DUPLICATE_ODOO_LIST" was not supported in the inverseCommand registry. Task-5943688 closes odoo/odoo#248969 Signed-off-by: Pierre Rousseau (pro) --- addons/spreadsheet/static/src/list/index.js | 3 ++- addons/spreadsheet/static/tests/lists/list_plugin.test.js | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/addons/spreadsheet/static/src/list/index.js b/addons/spreadsheet/static/src/list/index.js index 6effddfd307077..0169e3e2afe3aa 100644 --- a/addons/spreadsheet/static/src/list/index.js +++ b/addons/spreadsheet/static/src/list/index.js @@ -56,6 +56,7 @@ inverseCommandRegistry .add("UPDATE_ODOO_LIST", identity) .add("RE_INSERT_ODOO_LIST", identity) .add("RENAME_ODOO_LIST", identity) - .add("REMOVE_ODOO_LIST", identity); + .add("REMOVE_ODOO_LIST", identity) + .add("DUPLICATE_ODOO_LIST", identity); export { ListCorePlugin, ListUIPlugin }; diff --git a/addons/spreadsheet/static/tests/lists/list_plugin.test.js b/addons/spreadsheet/static/tests/lists/list_plugin.test.js index c889db851bdec6..2b26044174ceb8 100644 --- a/addons/spreadsheet/static/tests/lists/list_plugin.test.js +++ b/addons/spreadsheet/static/tests/lists/list_plugin.test.js @@ -1043,6 +1043,10 @@ test("Can duplicate a list", async () => { const listIds = model.getters.getListIds(); expect(model.getters.getListIds().length).toBe(2); + undo(model); + expect(model.getters.getListIds().length).toBe(1); + redo(model); + const expectedDuplicatedDefinition = { ...model.getters.getListDefinition(listId), id: "2", From 68b67c9c4350d3c2c09f0d0b16e45068ee054740 Mon Sep 17 00:00:00 2001 From: defl Date: Wed, 3 Dec 2025 09:31:49 +0100 Subject: [PATCH 03/23] [FIX] mass_mailing: prevent wrappers application on favorites templates **Steps to reproduce:** - Go to Email Marketing app - Create a new mailing - Click on empty mail body and add only a Heading block - Set a subject, save it and click `Add to Templates` (favorites) - Create another mailing which use the first one as its template - Repeat the operation multiple times - Error will be raised at some point due to the depth of the template html **Issue:** Unnecessarily nested DIVs are created when using favorites to create new mailing.mailing records, if those favorites are themselves based on other favorites etc., which later can lead to a recursion error when rendering the template. **Fix:** Check if the template comes from the favorites to avoid reapplying the wrappers on it. This seems to be solved in 19.0 with the refactoring (https://github.com/odoo/odoo/commit/354b8f60dbabcfac690d90bf657592e1347e4f86#diff-14a6d2cb740522696fcc7c950d0767ad23de989d621cca3bbbe24020d3f9c609) opw-5275187 closes odoo/odoo#238489 Signed-off-by: Julien Banken (jbn) --- .../static/src/js/mass_mailing_html_field.js | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/addons/mass_mailing/static/src/js/mass_mailing_html_field.js b/addons/mass_mailing/static/src/js/mass_mailing_html_field.js index 1d09fced2e9a27..c0aff4c0cdd3f2 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_html_field.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_html_field.js @@ -357,6 +357,7 @@ export class MassMailingHtmlField extends HtmlField { template: values.body_arch, userId: values.user_id[0], userName: values.user_id[1], + is_favorite: true, }; }); @@ -674,41 +675,56 @@ export class MassMailingHtmlField extends HtmlField { const old_layout = this.wysiwyg.$editable.find('.o_layout')[0]; - let $newWrapper; - let $newWrapperContent; - if (themeParams.nowrap) { - $newWrapper = $('
', { - class: 'oe_structure' - }); - $newWrapperContent = $newWrapper; + const templateDoc = new DOMParser().parseFromString(themeParams.template, "text/html"); + const templateEl = templateDoc.body.firstElementChild; + const expectedClasses = [ + 'o_layout', + 'oe_unremovable', + 'oe_unmovable', + themeParams.className, + ].filter(Boolean); + const hasAllClasses = templateEl && expectedClasses.every(cls => templateEl.classList.contains(cls)); + if (themeParams?.is_favorite && hasAllClasses) { + // Avoid applying multiple time the wrapper elements + old_layout && old_layout.remove(); + this.wysiwyg.odooEditor.resetContent(templateEl.outerHTML); } else { - // This wrapper structure is the only way to have a responsive - // and centered fixed-width content column on all mail clients - $newWrapper = $('
', { - class: 'container o_mail_wrapper o_mail_regular oe_unremovable', - }); - $newWrapperContent = $('
', { - class: 'col o_mail_no_options o_mail_wrapper_td bg-white oe_structure o_editable' - }); - $newWrapper.append($('
').append($newWrapperContent)); + let $newWrapper; + let $newWrapperContent; + if (themeParams.nowrap) { + $newWrapper = $('
', { + class: 'oe_structure' + }); + $newWrapperContent = $newWrapper; + } else { + // This wrapper structure is the only way to have a responsive + // and centered fixed-width content column on all mail clients + $newWrapper = $('
', { + class: 'container o_mail_wrapper o_mail_regular oe_unremovable', + }); + $newWrapperContent = $('
', { + class: 'col o_mail_no_options o_mail_wrapper_td bg-white oe_structure o_editable' + }); + $newWrapper.append($('
').append($newWrapperContent)); + } + const $newLayout = $('
', { + class: 'o_layout oe_unremovable oe_unmovable bg-200 ' + themeParams.className, + style: themeParams.layoutStyles, + 'data-name': 'Mailing', + }).append($newWrapper); + + $newWrapperContent.append(themeParams.template); + + this._switchImages(themeParams, $newWrapperContent); + old_layout && old_layout.remove(); + this.wysiwyg.odooEditor.resetContent($newLayout[0].outerHTML); + + $newWrapperContent.find('*').addBack() + .contents() + .filter(function () { + return this.nodeType === 3 && this.textContent.match(/\S/); + }).parent().addClass('o_default_snippet_text'); } - const $newLayout = $('
', { - class: 'o_layout oe_unremovable oe_unmovable bg-200 ' + themeParams.className, - style: themeParams.layoutStyles, - 'data-name': 'Mailing', - }).append($newWrapper); - - const $contents = themeParams.template; - $newWrapperContent.append($contents); - this._switchImages(themeParams, $newWrapperContent); - old_layout && old_layout.remove(); - this.wysiwyg.odooEditor.resetContent($newLayout[0].outerHTML); - - $newWrapperContent.find('*').addBack() - .contents() - .filter(function () { - return this.nodeType === 3 && this.textContent.match(/\S/); - }).parent().addClass('o_default_snippet_text'); if (themeParams.name === 'basic') { this.wysiwyg.$editable[0].focus(); From a05b731bf07be86199230c06ce51f7e0ee8c041c Mon Sep 17 00:00:00 2001 From: Xavier ALT Date: Thu, 15 Jan 2026 15:57:48 +0100 Subject: [PATCH 04/23] [FIX] website_forum: fix wrong props passed to WebsiteForumTagsWrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following rewrite in odoo/odoo@33206fd1941ae, this commit update passed props (`disabled` -> `isReadOnly`) to avoid a crash when creating a new forum post while being in debug mode: `OwlError: Invalid props for component 'WebsiteForumTagsWrapper': unknown key 'disabled'` closes odoo/odoo#244063 Signed-off-by: Warnon Aurélien (awa) --- addons/website_forum/static/src/js/website_forum.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index 8eefe734a58207..0d3b825fc7732f 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -135,7 +135,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ await attachComponent(this, selectMenuWrapperEl, WebsiteForumTagsWrapper, { defaulValue: defaulValue, - disabled: isReadOnly, + isReadOnly: isReadOnly, }); } From d1681937a49239d808f517eb315fe857a69f5e7d Mon Sep 17 00:00:00 2001 From: orma-odoo Date: Wed, 18 Feb 2026 07:48:53 +0000 Subject: [PATCH 05/23] [FIX] hw_drivers: fix SSL verification for IoT handlers In PR #233423, we rightfully removed `cert_reqs='CERT_NONE'` to enforce secure certificate validation during IoT handler downloads. However, this exposed a blind spot in Python's `urllib3` library on Windows. Because `urllib3` defaults to the host's underlying certificate list (which is limited on Windows) instead of the installed `certifi` package, Virtual IoT boxes get the following error during handler downloads: `certificate verify failed: unable to get local issuer certificate` This commit restores the broken flow while maintaining security by explicitly passing `certifi.where()` to the `urllib3.PoolManager` via the `ca_certs` parameter. opw-5902549 closes odoo/odoo#249271 X-original-commit: 521ee2b66ea7949a542c48dc0c1af23608e16ef7 Signed-off-by: Yaroslav Soroko (yaso) Signed-off-by: Maxime Orban (orma) --- addons/hw_drivers/tools/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/hw_drivers/tools/helpers.py b/addons/hw_drivers/tools/helpers.py index 6b2472508f063e..ae2c82a7759a43 100644 --- a/addons/hw_drivers/tools/helpers.py +++ b/addons/hw_drivers/tools/helpers.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import certifi import configparser import contextlib import datetime @@ -503,7 +504,7 @@ def download_iot_handlers(auto=True): server = get_odoo_server_url() if server: urllib3.disable_warnings() - pm = urllib3.PoolManager() + pm = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where()) server = server + '/iot/get_handlers' try: resp = pm.request('POST', server, fields={'mac': get_mac_address(), 'auto': auto}, timeout=8) From 0acc6481ff03204f9c6d47505dc38fde89342ae9 Mon Sep 17 00:00:00 2001 From: agbr-odoo Date: Fri, 26 Dec 2025 06:43:23 +0000 Subject: [PATCH 06/23] [FIX] sale_project: unlink SO when no SO item is linked to a task Currently, a task remains linked to its original sales order even when it has no sales order item. This prevents users to not bill a task and temporarily detach it from a sales order until it can be linked to a new one. Steps to produce: * Install Sales, Project * Products > Virtual Home Staging > Create On Order > Project and Task * Create and confirm quotation with that product. * Tasks > Empty Sale Order Item Field Observed Behavior: * Sale Order is still linked to the task despite sale order line has been unlinked from that task. Root cause: * Compute method [1] only detaches the sale order if the customer has been changed. Solution: * Only detach the sale order when there are no sale order items and the record is not a field service task. * Field service tasks should always keep the sale order linked so materials can still be added to the existing sale order, even when the task is non-billable (i.e., no sale order line is linked). This logic is handled by the compute override at [2], which reassigns the sale order when needed. [1]: https://github.com/odoo/odoo/blob/3f4e45ecaca46a98c904536658728a1f1571bdbd/addons/sale_project/models/project.py#L916-L935 [2]: https://github.com/odoo/enterprise/blob/6658581828dcdc43ffc5823814a05cb936cd0500/industry_fsm_sale/models/project_task.py#L178-L194 opw-5215989 closes odoo/odoo#249107 X-original-commit: 0ebe2eaee2539c4f5087a74a7d3d7bb75126c923 Related: odoo/enterprise#107716 Signed-off-by: Maxime de Neuville (mane) --- addons/sale_project/models/project_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale_project/models/project_task.py b/addons/sale_project/models/project_task.py index c255c0be3a9d83..16b25236df2ad3 100644 --- a/addons/sale_project/models/project_task.py +++ b/addons/sale_project/models/project_task.py @@ -58,7 +58,7 @@ def _group_expand_sales_order(self, sales_orders, domain): @api.depends('sale_line_id', 'project_id', 'allow_billable') def _compute_sale_order_id(self): for task in self: - if not task.allow_billable: + if not (task.allow_billable and task.sale_line_id): task.sale_order_id = False continue sale_order = ( From 20e54e37670ffa569f47663a7e4b8d7de3a4c33c Mon Sep 17 00:00:00 2001 From: Mathieu Walravens Date: Thu, 22 Jan 2026 08:38:31 +0100 Subject: [PATCH 07/23] [FIX] survey: use answer token in cookies This commit prevents duplicate URLs for the same survey when fetched by bots/crawlers, improving SEO for public surveys. opw-5408587 Co-Authored-By: Denis Ledoux --- addons/survey/controllers/main.py | 13 ++++++++++--- addons/survey/tests/test_survey_controller.py | 10 +++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 3adba1836f8b4c..4fd486924155dd 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -243,7 +243,9 @@ def survey_start(self, survey_token, answer_token=None, email=False, **post): else: return request.render("survey.survey_403_page", {'survey': survey_sudo}) - return request.redirect('/survey/%s/%s' % (survey_sudo.access_token, answer_sudo.access_token)) + response = request.redirect('/survey/%s' % survey_sudo.access_token) + response.set_cookie('survey_%s' % survey_sudo.access_token, answer_sudo.access_token, max_age=60 * 60 * 24) + return response def _prepare_survey_data(self, survey_sudo, answer_sudo, **post): """ This method prepares all the data needed for template rendering, in function of the survey user input state. @@ -387,8 +389,13 @@ def _prepare_question_html(self, survey_sudo, answer_sudo, **post): 'background_image_url': background_image_url } - @http.route('/survey//', type='http', auth='public', website=True) - def survey_display_page(self, survey_token, answer_token, **post): + @http.route([ + '/survey/', + '/survey//', + ], type='http', auth='public', website=True) + def survey_display_page(self, survey_token, answer_token=None, **post): + if not answer_token: + answer_token = request.httprequest.cookies.get('survey_%s' % survey_token) access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: return self._redirect_with_error(access_data, access_data['validity_code']) diff --git a/addons/survey/tests/test_survey_controller.py b/addons/survey/tests/test_survey_controller.py index e640cfe44261e7..0c59cc6ae91dd3 100644 --- a/addons/survey/tests/test_survey_controller.py +++ b/addons/survey/tests/test_survey_controller.py @@ -79,9 +79,17 @@ def test_submit_route_scoring_after_page(self): if layout == 'page_per_section': page0, _ = self.env['survey.question'].create(pages) + cookie_key = f'survey_{survey.access_token}' + # clear the cookie to start a new survey + self.opener.cookies.pop(cookie_key, None) + response = self._access_start(survey) - user_input = self.env['survey.user_input'].search([('access_token', '=', response.url.split('/')[-1])]) + self.assertTrue(response.history, "Survey start should redirect") + cookie_token = response.history[0].cookies.get(cookie_key) + user_input = self.env['survey.user_input'].search([('access_token', '=', cookie_token)]) answer_token = user_input.access_token + self.assertTrue(cookie_token) + self.assertTrue(user_input) r = self._access_page(survey, answer_token) self.assertResponse(r, 200) From 766a370a177eed48f2babcf4f8a63b0d564a7fd8 Mon Sep 17 00:00:00 2001 From: Antoine Dupuis Date: Tue, 17 Jun 2025 16:08:03 +0200 Subject: [PATCH 08/23] [ADD] pos_edi_ubl: Helpers to generate UBL in the PoS We introduce a new model `pos.edi.xml.ubl_21` which can be inherited to build UBL 2.1 Invoice and CreditNote documents from a PoS order. This generates the UBL document using sensible default values taken from the PoS order but can also be inherited. This calls methods defined in the `account.edi.xml.ubl_21` model and so can be composed with models that inherit from `account.edi.xml.ubl_21`. task-4038651 Part-of: odoo/odoo#243844 Signed-off-by: Josse Colpaert (jco) --- .../models/account_edi_xml_ubl_20.py | 1 + addons/pos_edi_ubl/__init__.py | 1 + addons/pos_edi_ubl/__manifest__.py | 14 ++ addons/pos_edi_ubl/models/__init__.py | 1 + addons/pos_edi_ubl/models/pos_edi_ubl_21.py | 203 ++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 addons/pos_edi_ubl/__init__.py create mode 100644 addons/pos_edi_ubl/__manifest__.py create mode 100644 addons/pos_edi_ubl/models/__init__.py create mode 100644 addons/pos_edi_ubl/models/pos_edi_ubl_21.py diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py index 6ba93fd6c459b9..e4c39b6deb7946 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py @@ -1121,6 +1121,7 @@ def _add_invoice_config_vals(self, vals): 'currency_id': invoice.currency_id, 'company_currency_id': invoice.company_id.currency_id, 'company': invoice.company_id, + 'journal': invoice.journal_id, 'use_company_currency': False, # If true, use the company currency for the amounts instead of the invoice currency 'fixed_taxes_as_allowance_charges': True, # If true, include fixed taxes as AllowanceCharges on lines instead of as taxes diff --git a/addons/pos_edi_ubl/__init__.py b/addons/pos_edi_ubl/__init__.py new file mode 100644 index 00000000000000..0650744f6bc69b --- /dev/null +++ b/addons/pos_edi_ubl/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/pos_edi_ubl/__manifest__.py b/addons/pos_edi_ubl/__manifest__.py new file mode 100644 index 00000000000000..f0eac36e337104 --- /dev/null +++ b/addons/pos_edi_ubl/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': 'Point of Sale UBL', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'sequence': 6, + 'summary': 'UBL in the Point of Sale ', + 'description': """ + +This module provides helpers for exporting POS orders in various UBL 2.1 formats. + +""", + 'depends': ['point_of_sale', 'account_edi_ubl_cii'], + 'license': 'LGPL-3', +} diff --git a/addons/pos_edi_ubl/models/__init__.py b/addons/pos_edi_ubl/models/__init__.py new file mode 100644 index 00000000000000..460892381cae75 --- /dev/null +++ b/addons/pos_edi_ubl/models/__init__.py @@ -0,0 +1 @@ +from . import pos_edi_ubl_21 diff --git a/addons/pos_edi_ubl/models/pos_edi_ubl_21.py b/addons/pos_edi_ubl/models/pos_edi_ubl_21.py new file mode 100644 index 00000000000000..b4e3649353a0e0 --- /dev/null +++ b/addons/pos_edi_ubl/models/pos_edi_ubl_21.py @@ -0,0 +1,203 @@ +from lxml import etree + +from odoo import models +from odoo.addons.account.tools import dict_to_xml + + +class PosEdiXmlUBL21(models.AbstractModel): + _name = 'pos.edi.xml.ubl_21' + _description = 'PoS Order UBL 2.1 builder' + _inherit = ['account.edi.xml.ubl_21'] + + def _export_pos_order(self, pos_order): + vals = {'pos_order': pos_order.with_context(lang=pos_order.partner_id.lang)} + document_node = self._get_pos_order_node(vals) + + errors = {constraint for constraint in self._export_pos_order_constraints(pos_order, vals).values() if constraint} + + template = self._get_document_template(vals) + nsmap = self._get_document_nsmap(vals) + + xml_tree = dict_to_xml(document_node, nsmap=nsmap, template=template) + return etree.tostring(xml_tree, xml_declaration=True, encoding='UTF-8'), set(errors) + + def _get_pos_order_node(self, vals): + self._add_pos_order_config_vals(vals) + self._add_pos_order_base_lines_vals(vals) + self._add_pos_order_currency_vals(vals) + self._add_pos_order_tax_grouping_function_vals(vals) + self._add_pos_order_monetary_totals_vals(vals) + + document_node = {} + self._add_pos_order_header_nodes(document_node, vals) + self._add_pos_order_accounting_supplier_party_nodes(document_node, vals) + self._add_pos_order_accounting_customer_party_nodes(document_node, vals) + self._add_pos_order_payment_means_nodes(document_node, vals) + + self._add_pos_order_allowance_charge_nodes(document_node, vals) + self._add_pos_order_tax_total_nodes(document_node, vals) + self._add_pos_order_monetary_total_nodes(document_node, vals) + self._add_pos_order_line_nodes(document_node, vals) + return document_node + + def _add_pos_order_config_vals(self, vals): + pos_order = vals['pos_order'] + supplier = pos_order.company_id.partner_id.commercial_partner_id + customer = pos_order.partner_id + + vals.update({ + 'document_type': 'invoice' if pos_order.amount_total >= 0 else 'credit_note', + + 'company': pos_order.company_id, + 'journal': pos_order.config_id.invoice_journal_id, + 'name': pos_order.name, + + 'supplier': supplier, + 'customer': customer, + + 'currency_id': pos_order.currency_id, + 'company_currency_id': pos_order.company_id.currency_id, + + 'use_company_currency': False, # If true, use the company currency for the amounts instead of the invoice currency + 'fixed_taxes_as_allowance_charges': True, # If true, include fixed taxes as AllowanceCharges on lines instead of as taxes + }) + + def _add_pos_order_base_lines_vals(self, vals): + pos_order = vals['pos_order'] + + base_lines = pos_order._prepare_tax_base_line_values() + AccountTax = self.env['account.tax'] + AccountTax._add_tax_details_in_base_lines(base_lines, pos_order.company_id) + AccountTax._round_base_lines_tax_details(base_lines, pos_order.company_id) + + vals['base_lines'] = base_lines + + def _add_pos_order_currency_vals(self, vals): + self._add_document_currency_vals(vals) + + def _add_pos_order_tax_grouping_function_vals(self, vals): + self._add_document_tax_grouping_function_vals(vals) + + def _add_pos_order_monetary_totals_vals(self, vals): + self._add_document_monetary_total_vals(vals) + + def _add_pos_order_header_nodes(self, document_node, vals): + pos_order = vals['pos_order'] + document_node.update({ + 'cbc:UBLVersionID': {'_text': '2.0'}, + 'cbc:ID': {'_text': vals['name']}, + 'cbc:IssueDate': {'_text': pos_order.date_order}, + 'cbc:InvoiceTypeCode': {'_text': 380} if vals['document_type'] == 'invoice' else None, + 'cbc:Note': {'_text': pos_order.general_note}, + 'cbc:DocumentCurrencyCode': {'_text': pos_order.currency_id.name}, + 'cac:OrderReference': { + 'cbc:ID': {'_text': vals['name']}, + } + }) + + def _add_pos_order_accounting_supplier_party_nodes(self, document_node, vals): + document_node['cac:AccountingSupplierParty'] = { + 'cac:Party': self._get_party_node({**vals, 'partner': vals['supplier'], 'role': 'supplier'}), + } + + def _add_pos_order_accounting_customer_party_nodes(self, document_node, vals): + document_node['cac:AccountingCustomerParty'] = { + 'cac:Party': self._get_party_node({**vals, 'partner': vals['customer'], 'role': 'customer'}), + } + + def _add_pos_order_payment_means_nodes(self, document_node, vals): + pass + + def _add_pos_order_allowance_charge_nodes(self, document_node, vals): + self._add_document_allowance_charge_nodes(document_node, vals) + + def _add_pos_order_tax_total_nodes(self, document_node, vals): + self._add_document_tax_total_nodes(document_node, vals) + + def _add_pos_order_monetary_total_nodes(self, document_node, vals): + self._add_document_monetary_total_nodes(document_node, vals) + monetary_total_tag = self._get_tags_for_document_type(vals)['monetary_total'] + pos_order = vals['pos_order'] + total_included = vals[f'tax_exclusive_amount{vals["currency_suffix"]}'] + document_node[monetary_total_tag].update({ + 'cbc:PrepaidAmount': { + '_text': self.format_float(total_included - pos_order.amount_paid, vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:PayableAmount': { + '_text': self.format_float(pos_order.amount_paid, vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + }) + + def _add_pos_order_line_nodes(self, document_node, vals): + line_idx = 1 + + line_tag = self._get_tags_for_document_type(vals)['document_line'] + document_node[line_tag] = line_nodes = [] + for base_line in vals['base_lines']: + # Only use product lines to generate the UBL InvoiceLines. + # Other lines should be represented as AllowanceCharges. + if not self._is_document_allowance_charge(base_line): + line_vals = { + **vals, + 'line_idx': line_idx, + 'base_line': base_line, + } + line_node = self._get_pos_order_line_node(line_vals) + line_nodes.append(line_node) + line_idx += 1 + + def _get_pos_order_line_node(self, vals): + self._add_pos_order_line_vals(vals) + + line_node = {} + self._add_pos_order_line_id_nodes(line_node, vals) + self._add_pos_order_line_note_nodes(line_node, vals) + self._add_pos_order_line_amount_nodes(line_node, vals) + self._add_pos_order_line_period_nodes(line_node, vals) + self._add_pos_order_line_allowance_charge_nodes(line_node, vals) + self._add_pos_order_line_tax_total_nodes(line_node, vals) + self._add_pos_order_line_item_nodes(line_node, vals) + self._add_pos_order_line_tax_category_nodes(line_node, vals) + self._add_pos_order_line_price_nodes(line_node, vals) + return line_node + + def _add_pos_order_line_vals(self, vals): + self._add_document_line_vals(vals) + + def _add_pos_order_line_id_nodes(self, line_node, vals): + self._add_document_line_id_nodes(line_node, vals) + + def _add_pos_order_line_note_nodes(self, line_node, vals): + self._add_document_line_note_nodes(line_node, vals) + + def _add_pos_order_line_amount_nodes(self, line_node, vals): + self._add_document_line_amount_nodes(line_node, vals) + + def _add_pos_order_line_period_nodes(self, line_node, vals): + pass + + def _add_pos_order_line_allowance_charge_nodes(self, line_node, vals): + self._add_document_line_allowance_charge_nodes(line_node, vals) + + def _add_pos_order_line_tax_total_nodes(self, line_node, vals): + self._add_document_line_tax_total_nodes(line_node, vals) + + def _add_pos_order_line_item_nodes(self, line_node, vals): + self._add_document_line_item_nodes(line_node, vals) + + line = vals['base_line']['record'] + if line_name := line.name and line.name.replace('\n', ' '): + line_node['cac:Item']['cbc:Description']['_text'] = line_name + if not line_node['cac:Item']['cbc:Name']['_text']: + line_node['cac:Item']['cbc:Name']['_text'] = line_name + + def _add_pos_order_line_tax_category_nodes(self, line_node, vals): + self._add_document_line_tax_category_nodes(line_node, vals) + + def _add_pos_order_line_price_nodes(self, line_node, vals): + self._add_document_line_price_nodes(line_node, vals) + + def _export_pos_order_constraints(self, pos_order, vals): + return {} From 57aa2e166cba72b40248df0d8b6c00c132ed8276 Mon Sep 17 00:00:00 2001 From: Hesham Saleh Date: Tue, 28 Oct 2025 17:10:15 +0100 Subject: [PATCH 09/23] [ADD] l10n_jo_edi_pos: Added JoFotara PoS support This commit adds support to JoFotara for pos.order model. task-4213323 closes odoo/odoo#243844 Signed-off-by: Josse Colpaert (jco) --- addons/l10n_jo_edi/models/account_move.py | 37 +- addons/l10n_jo_edi/models/res_company.py | 42 ++ .../tests/test_jo_edi_precision.py | 6 +- addons/l10n_jo_edi_pos/__init__.py | 1 + addons/l10n_jo_edi_pos/__manifest__.py | 26 ++ addons/l10n_jo_edi_pos/demo/demo_company.xml | 9 + addons/l10n_jo_edi_pos/models/__init__.py | 6 + addons/l10n_jo_edi_pos/models/account_move.py | 11 + .../models/pos_edi_ubl_21_jo.py | 385 ++++++++++++++++++ addons/l10n_jo_edi_pos/models/pos_order.py | 202 +++++++++ .../models/pos_payment_method.py | 18 + addons/l10n_jo_edi_pos/models/res_company.py | 15 + .../models/res_config_settings.py | 8 + .../overrides/components/order_receipt.xml | 22 + .../src/overrides/models/invoice_button.js | 22 + .../static/src/overrides/models/pos_order.js | 18 + .../src/overrides/models/receipt_screen.js | 28 ++ .../src/overrides/screens/ticket_screen.js | 34 ++ .../src/overrides/screens/ticket_screen.xml | 12 + .../tests/tours/l10n_jo_edi_pos_tour.js | 23 ++ addons/l10n_jo_edi_pos/tests/__init__.py | 3 + .../tests/jo_edi_pos_common.py | 47 +++ .../tests/test_jo_edi_pos_precision.py | 141 +++++++ .../tests/test_jo_edi_pos_types.py | 352 ++++++++++++++++ .../tests/test_l10n_jo_edi_pos_tour.py | 23 ++ .../l10n_jo_edi_pos/views/pos_order_views.xml | 56 +++ .../views/pos_payment_method_views.xml | 13 + .../views/res_config_settings_views.xml | 25 ++ 28 files changed, 1548 insertions(+), 37 deletions(-) create mode 100644 addons/l10n_jo_edi_pos/__init__.py create mode 100644 addons/l10n_jo_edi_pos/__manifest__.py create mode 100644 addons/l10n_jo_edi_pos/demo/demo_company.xml create mode 100644 addons/l10n_jo_edi_pos/models/__init__.py create mode 100644 addons/l10n_jo_edi_pos/models/account_move.py create mode 100644 addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py create mode 100644 addons/l10n_jo_edi_pos/models/pos_order.py create mode 100644 addons/l10n_jo_edi_pos/models/pos_payment_method.py create mode 100644 addons/l10n_jo_edi_pos/models/res_company.py create mode 100644 addons/l10n_jo_edi_pos/models/res_config_settings.py create mode 100644 addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml create mode 100644 addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js create mode 100644 addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js create mode 100644 addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js create mode 100644 addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js create mode 100644 addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml create mode 100644 addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js create mode 100644 addons/l10n_jo_edi_pos/tests/__init__.py create mode 100644 addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py create mode 100644 addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py create mode 100644 addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py create mode 100644 addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py create mode 100644 addons/l10n_jo_edi_pos/views/pos_order_views.xml create mode 100644 addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml create mode 100644 addons/l10n_jo_edi_pos/views/res_config_settings_views.xml diff --git a/addons/l10n_jo_edi/models/account_move.py b/addons/l10n_jo_edi/models/account_move.py index ba06c736c04ed7..b206b06106fbfc 100644 --- a/addons/l10n_jo_edi/models/account_move.py +++ b/addons/l10n_jo_edi/models/account_move.py @@ -1,13 +1,10 @@ import base64 -import requests import uuid from werkzeug.urls import url_encode from odoo import _, api, fields, models from odoo.exceptions import ValidationError -JOFOTARA_URL = "https://backend.jofotara.gov.jo/core/invoices/" - class AccountMove(models.Model): _inherit = 'account.move' @@ -154,27 +151,10 @@ def _get_name_invoice_report(self): return super()._get_name_invoice_report() def _l10n_jo_build_jofotara_headers(self): - self.ensure_one() - return { - 'Client-Id': self.sudo().company_id.l10n_jo_edi_client_identifier, - 'Secret-Key': self.sudo().company_id.l10n_jo_edi_secret_key, - } + return self.company_id._l10n_jo_build_jofotara_headers() def _send_l10n_jo_edi_request(self, params, headers): - try: - response = requests.post(JOFOTARA_URL, json=params, headers=headers, timeout=50) - except requests.exceptions.Timeout: - return {'error': _("Request timeout! Please try again.")} - except requests.exceptions.RequestException as e: - return {'error': _("Invalid request: %s", e)} - - if not response.ok: - content = response.content.decode() - if response.status_code == 403: - content = _("Access forbidden. Please verify your JoFotara credentials.") - return {'error': _("Request failed: %s", content)} - dict_response = response.json() - return dict_response + return self.company_id._send_l10n_jo_edi_request(params, headers) def _submit_to_jofotara(self): self.ensure_one() @@ -200,18 +180,7 @@ def _l10n_jo_edi_get_xml_attachment_name(self): return f"{self.name.replace('/', '_')}_edi.xml" def _l10n_jo_validate_config(self): - error_msgs = [] - if not self.sudo().company_id.l10n_jo_edi_client_identifier: - error_msgs.append(_("Client ID is missing.")) - if not self.sudo().company_id.l10n_jo_edi_secret_key: - error_msgs.append(_("Secret key is missing.")) - if not self.company_id.l10n_jo_edi_taxpayer_type: - error_msgs.append(_("Taxpayer type is missing.")) - if not self.company_id.l10n_jo_edi_sequence_income_source: - error_msgs.append(_("Activity number (Sequence of income source) is missing.")) - - if error_msgs: - return _("%s \nTo set: Configuration > Settings > Electronic Invoicing (Jordan)", "\n".join(error_msgs)) + return self.company_id._l10n_jo_validate_config() def _l10n_jo_validate_fields(self): def has_non_digit_vat(partner, partner_type, error_msgs): diff --git a/addons/l10n_jo_edi/models/res_company.py b/addons/l10n_jo_edi/models/res_company.py index 9f0ab7ce448bc9..dfc2eecb562f2d 100644 --- a/addons/l10n_jo_edi/models/res_company.py +++ b/addons/l10n_jo_edi/models/res_company.py @@ -1,5 +1,9 @@ +import requests + from odoo import fields, models +JOFOTARA_URL = "https://backend.jofotara.gov.jo/core/invoices/" + class ResCompany(models.Model): _inherit = 'res.company' @@ -12,3 +16,41 @@ class ResCompany(models.Model): ('sales', "Registered in the sales tax"), ('special', "Registered in the special sales tax"), ], default='sales') + + def _l10n_jo_validate_config(self): + self.ensure_one() + error_msgs = [] + if not self.sudo().l10n_jo_edi_client_identifier: + error_msgs.append(self.env._("Client ID is missing.")) + if not self.sudo().l10n_jo_edi_secret_key: + error_msgs.append(self.env._("Secret key is missing.")) + if not self.l10n_jo_edi_taxpayer_type: + error_msgs.append(self.env._("Taxpayer type is missing.")) + if not self.l10n_jo_edi_sequence_income_source: + error_msgs.append(self.env._("Activity number (Sequence of income source) is missing.")) + + if error_msgs: + return self.env._("%s \nTo set: Configuration > Settings > Electronic Invoicing (Jordan)", "\n".join(error_msgs)) + + def _l10n_jo_build_jofotara_headers(self): + self.ensure_one() + return { + 'Client-Id': self.sudo().l10n_jo_edi_client_identifier, + 'Secret-Key': self.sudo().l10n_jo_edi_secret_key, + } + + def _send_l10n_jo_edi_request(self, params, headers): + try: + response = requests.post(JOFOTARA_URL, json=params, headers=headers, timeout=50) + except requests.exceptions.Timeout: + return {'error': self.env._("Request timeout! Please try again.")} + except requests.exceptions.RequestException as e: + return {'error': self.env._("Invalid request: %s", e)} + + if not response.ok: + content = response.content.decode() + if response.status_code == 403: + content = self.env._("Access forbidden. Please verify your JoFotara credentials.") + return {'error': self.env._("Request failed: %s", content)} + dict_response = response.json() + return dict_response diff --git a/addons/l10n_jo_edi/tests/test_jo_edi_precision.py b/addons/l10n_jo_edi/tests/test_jo_edi_precision.py index 09da7629c25b04..8eb2acc08d3c22 100644 --- a/addons/l10n_jo_edi/tests/test_jo_edi_precision.py +++ b/addons/l10n_jo_edi/tests/test_jo_edi_precision.py @@ -47,7 +47,7 @@ def _extract_vals_from_subtotals(self, subtotals, defaults): def _sum_max_dp(self, iterable): return self.env['account.edi.xml.ubl_21.jo']._sum_max_dp(iterable) - def _validate_jo_edi_numbers(self, xml_string, invoice): + def _validate_jo_edi_numbers(self, xml_string, amount_total): """ TLDR: This method checks that units sum up to total values. =================================================================================================== @@ -80,7 +80,7 @@ def _validate_jo_edi_numbers(self, xml_string, invoice): tax_exclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxExclusiveAmount')) tax_inclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxInclusiveAmount')) - self.assertEqual(float_compare(tax_inclusive_amount, invoice.amount_total, 2), 0, f'{tax_inclusive_amount} != {invoice.amount_total}') + self.assertEqual(float_compare(tax_inclusive_amount, amount_total, 2), 0, f'{tax_inclusive_amount} != {amount_total}') monetary_values_discount = float(root.findtext('./{*}LegalMonetaryTotal/{*}AllowanceTotalAmount')) payable_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount')) @@ -162,7 +162,7 @@ def _validate_invoice_vals_jo_edi_numbers(self, invoice_vals): with self.subTest(sub_test_name=invoice_vals['name']): invoice = self._l10n_jo_create_invoice(invoice_vals) generated_file = self.env['account.edi.xml.ubl_21.jo']._export_invoice(invoice)[0] - errors = self._validate_jo_edi_numbers(generated_file, invoice) + errors = self._validate_jo_edi_numbers(generated_file, invoice.amount_total) self.assertFalse(errors, errors) def test_jo_sales_invoice_precision(self): diff --git a/addons/l10n_jo_edi_pos/__init__.py b/addons/l10n_jo_edi_pos/__init__.py new file mode 100644 index 00000000000000..0650744f6bc69b --- /dev/null +++ b/addons/l10n_jo_edi_pos/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/l10n_jo_edi_pos/__manifest__.py b/addons/l10n_jo_edi_pos/__manifest__.py new file mode 100644 index 00000000000000..6b27fb5375b195 --- /dev/null +++ b/addons/l10n_jo_edi_pos/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Jordan Accounting EDI for POS", + "countries": ["jo"], + "version": "1.0", + "description": """ +Jordan Accounting EDI for POS +============================= +Provides electronic invoicing for Jordan in the POS. +""", + "category": "Accounting/Localizations/EDI", + "license": "OEEL-1", + "depends": ["l10n_jo_edi_extended", "pos_edi_ubl"], + "demo": ["demo/demo_company.xml"], + "data": [ + "views/pos_order_views.xml", + "views/pos_payment_method_views.xml", + "views/res_config_settings_views.xml", + ], + "auto_install": True, + "assets": { + "point_of_sale._assets_pos": ["l10n_jo_edi_pos/static/src/**/*"], + "web.assets_tests": [ + "l10n_jo_edi_pos/static/tests/tours/**/*", + ], + }, +} diff --git a/addons/l10n_jo_edi_pos/demo/demo_company.xml b/addons/l10n_jo_edi_pos/demo/demo_company.xml new file mode 100644 index 00000000000000..cb751296779d40 --- /dev/null +++ b/addons/l10n_jo_edi_pos/demo/demo_company.xml @@ -0,0 +1,9 @@ + + + + + True + True + + + diff --git a/addons/l10n_jo_edi_pos/models/__init__.py b/addons/l10n_jo_edi_pos/models/__init__.py new file mode 100644 index 00000000000000..35adf85ebf76da --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/__init__.py @@ -0,0 +1,6 @@ +from . import account_move +from . import pos_edi_ubl_21_jo +from . import pos_order +from . import pos_payment_method +from . import res_company +from . import res_config_settings diff --git a/addons/l10n_jo_edi_pos/models/account_move.py b/addons/l10n_jo_edi_pos/models/account_move.py new file mode 100644 index 00000000000000..01bbd9ab39abd9 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/account_move.py @@ -0,0 +1,11 @@ +from odoo import models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + def _compute_l10n_jo_edi_is_needed(self): + super()._compute_l10n_jo_edi_is_needed() + # moves linked to pos.orders should be synchronized through the pos.order + for move in self.filtered(lambda m: m.l10n_jo_edi_is_needed and m.pos_order_ids): + move.l10n_jo_edi_is_needed = False diff --git a/addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py b/addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py new file mode 100644 index 00000000000000..671c5c76a046a2 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py @@ -0,0 +1,385 @@ +import functools +from types import SimpleNamespace + +from odoo import models +from odoo.tools import float_round, float_is_zero +from odoo.addons.l10n_jo_edi.models.account_edi_xml_ubl_21_jo import JO_MAX_DP + + +class HashableNamespace(SimpleNamespace): + """ We need to use a hashable namespace to be able to use it as a key in a dictionary. + This is because the `SimpleNamespace` class is not hashable by default. + """ + + def __hash__(self): + return hash(self.name) + + +class PosEdiXmlUBL21Jo(models.AbstractModel): + _name = 'pos.edi.xml.ubl_21.jo' + _inherit = ['account.edi.xml.ubl_21.jo', 'pos.edi.xml.ubl_21'] + _description = 'UBL 2.1 (JoFotara) for PoS Orders' + + def _get_tax_category_code(self, customer, supplier, tax): + if tax: + if tax._l10n_jo_is_exempt_tax(): + return "Z" + if tax.amount: + return "S" + return "O" + + def _get_pos_order_node(self, vals): + document_node = super()._get_pos_order_node(vals) + self._add_pos_order_seller_supplier_party_nodes(document_node, vals) + return document_node + + def _add_pos_order_config_vals(self, vals): + super()._add_pos_order_config_vals(vals) + vals.update({ + 'document_type': 'invoice', + 'fixed_taxes_as_allowance_charges': False, + 'is_refund': vals['document_type'] == 'credit_note', + 'is_sales': vals['pos_order'].company_id.l10n_jo_edi_taxpayer_type == 'sales', + 'is_income': vals['pos_order'].company_id.l10n_jo_edi_taxpayer_type == 'income', + }) + # We cannot use a new `res.currency` record to round to 9 decimals, because the + # `res.currency.rounding` field definition prevents rounding to more than 6 decimal places. + vals['currency_id'] = HashableNamespace( + name='JOD', + round=functools.partial(float_round, precision_digits=JO_MAX_DP), + is_zero=functools.partial(float_is_zero, precision_digits=JO_MAX_DP), + rounding=10**-JO_MAX_DP, + decimal_places=JO_MAX_DP, + ) + + def _add_pos_order_currency_vals(self, vals): + super()._add_pos_order_currency_vals(vals) + vals['currency_name'] = 'JO' + + def _add_pos_order_base_lines_vals(self, vals): + # OVERRIDE account_edi_xml_ubl_20.py + currency_9_dp = vals['currency_id'] + pos_order = vals['pos_order'] + + # Compute values for order lines. In Jordan, because the web-service has absolutely no tolerance, + # what we do is: use round per line with 9 decimals (yes!) + base_lines = pos_order._prepare_tax_base_line_values() + + AccountTax = self.env['account.tax'] + for base_line in base_lines: + base_line['currency_id'] = currency_9_dp + AccountTax._add_tax_details_in_base_line(base_line, base_line['record'].company_id, 'round_per_line') + + # Round to 9 decimals + AccountTax._round_base_lines_tax_details(base_lines, company=pos_order.company_id) + + # raw_total_* values need to calculated with `round_globally` because these values should not be rounded per line + # However, taxes need to be calculated with `round_per_line` so that _round_base_lines_tax_details does not + # end up generating lines taxes using unrounded base amounts + new_base_lines = [base_line.copy() for base_line in base_lines] + for base_line, new_base_line in zip(base_lines, new_base_lines): + AccountTax._add_tax_details_in_base_line(new_base_line, new_base_line['record'].company_id, 'round_globally') + for key in [ + 'raw_total_excluded_currency', + 'raw_total_excluded', + 'raw_total_included_currency', + 'raw_total_included', + ]: + base_line['tax_details'][key] = new_base_line['tax_details'][key] + + vals['base_lines'] = base_lines + self._add_pos_order_discount_vals(vals) + + def _add_document_line_gross_subtotal_and_discount_vals(self, vals): + """ In JO, because of the precision requirements, we first compute an exact + gross unit price, rounded to 9 decimals, and then use it to compute the discount amounts. + """ + base_line = vals['base_line'] + currency_9_dp = vals['currency_id'] + + # OVERRIDE account_edi_xml_ubl_20.py + discount_factor = 1 - (base_line['discount'] / 100.0) + if discount_factor != 0.0: + gross_subtotal_currency = base_line['tax_details']['raw_total_excluded_currency'] / discount_factor + else: + gross_subtotal_currency = base_line['currency_id'].round(base_line['price_unit'] * base_line['quantity']) + + # Start by getting a gross unit price rounded to 9 decimals + vals['gross_price_unit_currency'] = currency_9_dp.round(gross_subtotal_currency / base_line['quantity']) if base_line['quantity'] else 0.0 + + # Then compute the gross subtotal from the rounded unit price + vals['gross_subtotal_currency'] = currency_9_dp.round(vals['gross_price_unit_currency'] * base_line['quantity']) + + # Then compute the discount from the gross subtotal + vals['discount_amount_currency'] = vals['gross_subtotal_currency'] - base_line['tax_details']['total_excluded_currency'] + + def _add_pos_order_discount_vals(self, vals): + discount_amount_currency = 0 + for base_line in vals['base_lines']: + new_vals = {**vals, 'base_line': base_line} + self._add_document_line_gross_subtotal_and_discount_vals(new_vals) + discount_amount_currency += abs(new_vals['discount_amount_currency']) + vals['discount_amount_currency'] = discount_amount_currency + + def _get_payment_method_code(self, order): + return order._get_order_scope_code() + order._get_order_payment_method_code() + order._get_order_tax_payer_type_code() + + def _add_pos_order_header_nodes(self, document_node, vals): + pos_order = vals['pos_order'] + document_node.update({ + 'cbc:ProfileID': {'_text': 'reporting:1.0'}, + 'cbc:ID': {'_text': (pos_order.name or '').replace('/', '_')}, + 'cbc:UUID': {'_text': pos_order.l10n_jo_edi_pos_uuid}, + 'cbc:IssueDate': {'_text': '2020-01-01' if pos_order.company_id.l10n_jo_edi_pos_testing_mode else pos_order.date_order.date()}, + 'cbc:InvoiceTypeCode': {'_text': 381 if vals['is_refund'] else 388, 'name': self._get_payment_method_code(pos_order)}, + 'cbc:Note': {'_text': pos_order.general_note}, + 'cbc:DocumentCurrencyCode': {'_text': pos_order.currency_id.name}, + 'cbc:TaxCurrencyCode': {'_text': pos_order.currency_id.name}, + 'cac:BillingReference': { + 'cac:InvoiceDocumentReference': { + 'cbc:ID': {'_text': (pos_order.refunded_order_id.name or '').replace('/', '_')}, + 'cbc:UUID': {'_text': pos_order.refunded_order_id.l10n_jo_edi_pos_uuid}, + 'cbc:DocumentDescription': { + '_text': self.format_float(abs(pos_order.refunded_order_id.amount_total), vals['currency_dp']), + }, + }, + } if vals['is_refund'] else None, + 'cac:AdditionalDocumentReference': { + 'cbc:ID': {'_text': 'ICV'}, + 'cbc:UUID': {'_text': pos_order.id}, + }, + }) + + def _add_pos_order_accounting_customer_party_nodes(self, document_node, vals): + super()._add_pos_order_accounting_customer_party_nodes(document_node, vals) + if not vals['is_refund']: + document_node['cac:AccountingCustomerParty'].update({ + 'cac:AccountingContact': { + 'cbc:Telephone': {'_text': self._sanitize_phone(vals['customer'].phone or vals['customer'].mobile)} + }, + }) + + def _add_pos_order_seller_supplier_party_nodes(self, document_node, vals): + document_node['cac:SellerSupplierParty'] = { + 'cac:Party': { + 'cac:PartyIdentification': { + 'cbc:ID': {'_text': vals['pos_order'].company_id.l10n_jo_edi_sequence_income_source}, + }, + }, + } + + def _add_pos_order_payment_means_nodes(self, document_node, vals): + if vals['is_refund']: + document_node['cac:PaymentMeans'] = { + 'cbc:PaymentMeansCode': {'listID': 'UN/ECE 4461', '_text': 10}, + 'cbc:InstructionNote': {'_text': vals['pos_order'].l10n_jo_edi_pos_return_reason}, + } + + def _get_party_node(self, vals): + partner = vals['partner'] + commercial_partner = partner.commercial_partner_id + is_customer = vals['role'] == 'customer' + return { + 'cac:PartyIdentification': { + 'cbc:ID': {'_text': commercial_partner.vat, 'schemeID': 'TN' if partner.country_code == 'JO' else 'PN'}, + } if not vals['is_refund'] and is_customer else None, + 'cac:PostalAddress': self._get_address_node(vals), + 'cac:PartyTaxScheme': { + 'cbc:CompanyID': {'_text': commercial_partner.vat} if not vals['is_refund'] or not is_customer else None, + 'cac:TaxScheme': { + 'cbc:ID': {'_text': 'VAT'} + }, + }, + 'cac:PartyLegalEntity': { + 'cbc:RegistrationName': {'_text': commercial_partner.name}, + } if not vals['is_refund'] or not is_customer else None, + } + + def _get_address_node(self, vals): + partner = vals['partner'] + country = partner['country_id'] + state = partner['state_id'] + + return { + 'cbc:PostalZone': {'_text': partner.zip} if not vals['is_refund'] else None, + 'cbc:CountrySubentityCode': {'_text': state.code} if not vals['is_refund'] else None, + 'cac:Country': { + 'cbc:IdentificationCode': {'_text': country.code}, + }, + } + + def _add_pos_order_allowance_charge_nodes(self, document_node, vals): + currency_suffix = vals['currency_suffix'] + + document_node['cac:AllowanceCharge'] = { + 'cbc:ChargeIndicator': {'_text': 'false'}, + 'cbc:AllowanceChargeReason': {'_text': 'discount'}, + 'cbc:Amount': { + '_text': self.format_float(vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + } + + def _add_pos_order_tax_total_nodes(self, document_node, vals): + # income docs should have no taxes + if not vals['is_income']: + super()._add_pos_order_tax_total_nodes(document_node, vals) + + def _add_pos_order_monetary_total_nodes(self, document_node, vals): + monetary_total_tag = self._get_tags_for_document_type(vals)['monetary_total'] + currency_suffix = vals['currency_suffix'] + + document_node[monetary_total_tag] = { + 'cbc:TaxExclusiveAmount': { + '_text': self.format_float(vals[f'tax_exclusive_amount{currency_suffix}'] + vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:TaxInclusiveAmount': { + '_text': self.format_float(vals[f'tax_inclusive_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:AllowanceTotalAmount': { + '_text': self.format_float(vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:PrepaidAmount': { + '_text': self.format_float(0, vals['currency_dp']), + 'currencyID': vals['currency_name'], + } if vals['is_refund'] and vals['is_sales'] else None, + 'cbc:PayableAmount': { + '_text': self.format_float(vals[f'tax_inclusive_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + } + + def _get_pos_order_line_node(self, vals): + self._add_pos_order_line_vals(vals) + + line_node = {} + self._add_pos_order_line_id_nodes(line_node, vals) + self._add_pos_order_line_amount_nodes(line_node, vals) + self._add_pos_order_line_item_nodes(line_node, vals) + self._add_pos_order_line_tax_total_nodes(line_node, vals) + self._add_pos_order_line_price_nodes(line_node, vals) + self._add_pos_order_line_allowance_charge_nodes(line_node, vals) + return line_node + + def _get_pos_order_line_id(self, vals): + if not vals['is_refund']: + return vals['line_idx'] + + line_id = -1 + refund_line = vals['base_line']['record'] + order_lines = vals['pos_order'].refunded_order_id.lines + for order_line_id, order_line in enumerate(order_lines, 1): + if refund_line.product_id == order_line.product_id \ + and refund_line.price_unit == order_line.price_unit \ + and refund_line.discount == order_line.discount: + line_id = order_line_id + break + if line_id == -1: + line_id = len(order_lines) + vals['line_idx'] + + return line_id + + def _add_pos_order_line_id_nodes(self, line_node, vals): + line_id = self._get_pos_order_line_id(vals) + line_node['cbc:ID'] = {'_text': line_id} + + def _add_pos_order_line_item_nodes(self, line_node, vals): + product = vals['base_line']['product_id'] + line_node['cac:Item'] = { + 'cbc:Name': {'_text': product.name}, + } + + def _add_pos_order_line_tax_total_nodes(self, line_node, vals): + # income docs should have no taxes + if not vals['is_income']: + super()._add_pos_order_line_tax_total_nodes(line_node, vals) + + def _sum_tax_details(self, vals, key, include_fixed=False): + currency_suffix = vals['currency_suffix'] + + return sum( + tax_details[f'{key}{currency_suffix}'] + for grouping_key, tax_details in vals['aggregated_tax_details'].items() + if grouping_key and (include_fixed or grouping_key['amount_type'] != 'fixed') + ) + + def _get_tax_total_node(self, vals): + aggregated_tax_details = vals['aggregated_tax_details'] + total_tax_amount = self._sum_tax_details(vals, 'raw_tax_amount') + rounding_amount = self._sum_tax_details(vals, 'raw_total_excluded') + self._sum_tax_details(vals, 'raw_tax_amount', True) + return { + 'cbc:TaxAmount': { + '_text': self.format_float(total_tax_amount, vals['currency_dp']), + 'currencyID': vals['currency_name'] + }, + 'cbc:RoundingAmount': { + '_text': self.format_float(rounding_amount, vals['currency_dp']), + 'currencyID': vals['currency_name'], + } if vals['role'] == 'line' else None, + 'cac:TaxSubtotal': [ + self._get_tax_subtotal_node({ + **vals, + 'tax_details': tax_details, + 'grouping_key': grouping_key, + }) + for grouping_key, tax_details in aggregated_tax_details.items() + if grouping_key + ] if vals['role'] == 'line' or (vals['is_refund'] and vals['is_sales']) else None, + } + + def _get_tax_subtotal_node(self, vals): + tax_details = vals['tax_details'] + currency_suffix = vals['currency_suffix'] + return { + 'cbc:TaxableAmount': { + '_text': self.format_float(tax_details[f'raw_total_excluded{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'] + }, + 'cbc:TaxAmount': { + '_text': self.format_float(tax_details[f'raw_tax_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'] + }, + 'cac:TaxCategory': self._get_tax_category_node(vals) + } + + def _get_tax_category_node(self, vals): + grouping_key = vals['grouping_key'] + return { + 'cbc:ID': {'_text': grouping_key['tax_category_code'], 'schemeAgencyID': 6, 'schemeID': 'UN/ECE 5305'}, + 'cbc:Percent': {'_text': grouping_key['amount']} if grouping_key['amount_type'] == 'percent' else None, + 'cac:TaxScheme': { + 'cbc:ID': { + '_text': 'VAT' if grouping_key['amount_type'] == 'percent' else 'OTH', + 'schemeAgencyID': 6, + 'schemeID': 'UN/ECE 5153', + }, + } + } + + def _add_pos_order_line_price_nodes(self, line_node, vals): + currency_suffix = vals['currency_suffix'] + + line_node['cac:Price'] = { + 'cbc:PriceAmount': { + '_text': self.format_float( + vals[f'gross_price_unit{currency_suffix}'], + vals['currency_dp'], + ), + 'currencyID': vals['currency_name'], + }, + } + + def _add_pos_order_line_allowance_charge_nodes(self, line_node, vals): + currency_suffix = vals['currency_suffix'] + + line_node['cac:Price']['cac:AllowanceCharge'] = { + 'cbc:ChargeIndicator': {'_text': 'false'}, + 'cbc:AllowanceChargeReason': {'_text': 'DISCOUNT'}, + 'cbc:Amount': { + '_text': self.format_float(vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + } diff --git a/addons/l10n_jo_edi_pos/models/pos_order.py b/addons/l10n_jo_edi_pos/models/pos_order.py new file mode 100644 index 00000000000000..8a4ebd1ee7ee90 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/pos_order.py @@ -0,0 +1,202 @@ +import base64 +import uuid +from urllib.parse import urlencode + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.sql import create_column, column_exists + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + l10n_jo_edi_pos_return_reason = fields.Char(string="Return Reason", help="Return Reason reported to JoFotara") + l10n_jo_edi_pos_enabled = fields.Boolean(related='company_id.l10n_jo_edi_pos_enabled') + l10n_jo_edi_pos_uuid = fields.Char(string="Order UUID", copy=False, compute='_compute_l10n_jo_edi_pos_uuid', store=True) + l10n_jo_edi_pos_qr = fields.Char(string="QR", copy=False) + l10n_jo_edi_pos_state = fields.Selection( + selection=[('to_send', 'To Send'), ('sent', 'Sent'), ('demo', 'Sent (Demo)')], + string="JoFotara State", + tracking=True, + copy=False, + ) + l10n_jo_edi_pos_error = fields.Text( + string="JoFotara Error", + copy=False, + readonly=True, + ) + l10n_jo_edi_pos_computed_xml = fields.Binary( + string="Jordan E-Invoice computed XML File", + compute='_compute_l10n_jo_edi_pos_computed_xml', + help="Jordan: technical field computing e-invoice XML data, useful at submission failure scenarios.", + ) + l10n_jo_edi_pos_xml_attachment_id = fields.Many2one( + comodel_name='ir.attachment', + string="Jordan E-Invoice XML", + help="Jordan: e-invoice XML.", + ) + + def _auto_init(self): + if not column_exists(self.env.cr, 'pos_order', 'l10n_jo_edi_pos_uuid'): + create_column(self.env.cr, 'pos_order', 'l10n_jo_edi_pos_uuid', 'char') + return super()._auto_init() + + @api.depends('country_code') + def _compute_l10n_jo_edi_pos_uuid(self): + for order in self: + if order.country_code == 'JO' and not order.l10n_jo_edi_pos_uuid: + order.l10n_jo_edi_pos_uuid = uuid.uuid4() + + def _get_order_scope_code(self): + return '0' + + def _get_order_payment_method_code(self): + return '1' if any(self.payment_ids.payment_method_id.mapped('l10n_jo_edi_pos_is_cash')) else '2' + + def _get_order_tax_payer_type_code(self): + return { + 'income': '1', + 'sales': '2', + 'special': '3', + }.get(self.company_id.l10n_jo_edi_taxpayer_type, '1') + + def _submit_to_jofotara(self): + self.ensure_one() + headers = self.company_id._l10n_jo_build_jofotara_headers() + xml_order = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(self)[0] + params = {'invoice': base64.b64encode(xml_order).decode()} + dict_response = {'EINV_QR': "Demo JoFotara QR"}\ + if self.env.company.l10n_jo_edi_demo_mode\ + else self.company_id._send_l10n_jo_edi_request(params, headers) + if 'error' in dict_response and len(dict_response) == 1: + return dict_response['error'] + self.l10n_jo_edi_pos_qr = str(dict_response.get('EINV_QR', '')) + self.l10n_jo_edi_pos_xml_attachment_id = self.env['ir.attachment'].create( + { + 'res_model': 'pos.order', + 'res_id': self.id, + 'name': self._l10n_jo_edi_pos_get_xml_attachment_name(), + 'raw': xml_order, + } + ) + + def _l10n_jo_edi_pos_get_xml_attachment_name(self): + return f"{self.name.replace('/', '_')}_edi.xml" + + def _l10n_jo_validate_fields(self): + error_msgs = [] + if self.refunded_order_id: + if self.refunded_order_id.l10n_jo_edi_pos_state not in ['sent', 'demo']: + error_msgs.append(self.env._("Refunded order was not sent to JoFotara. Please submit the original order to JoFotara first and try again.")) + if not self.l10n_jo_edi_pos_return_reason: + error_msgs.append(self.env._("Refund order must have a return reason")) + if any(line.price_unit < 0 for line in self.lines) or (not self.refunded_order_id and any(line.qty < 0 for line in self.lines)): + error_msgs.append(self.env._("Downpayments, global discounts, and negative lines are not supported at the moment. To revert this order, please go to Orders > Select the Order > Refund or create a Return from the backend by going to Orders > Select the Order > Return")) + if len(self.payment_ids.payment_method_id.mapped('l10n_jo_edi_pos_is_cash')) > 1: + error_msgs.append(self.env._("Please select the payment methods that are consistent with the value set in 'JoFotara Cash'. If set, the payment method is Cash. If empty, then it is Receivable.")) + + for line in self.lines: + if self.company_id.l10n_jo_edi_taxpayer_type == 'income' and len(line.tax_ids) != 0: + error_msgs.append(self.env._("No taxes are allowed on order lines for taxpayers unregistered in the sales tax")) + elif self.company_id.l10n_jo_edi_taxpayer_type == 'sales' and len(line.tax_ids) != 1: + error_msgs.append(self.env._("One general tax per order line is expected for taxpayers registered in the sales tax")) + elif self.company_id.l10n_jo_edi_taxpayer_type == 'special' and len(line.tax_ids) != 2: + error_msgs.append(self.env._("One special and one general tax per order line are expected for taxpayers registered in the special tax")) + + return "\n".join(error_msgs) + + def _l10n_jo_edi_send(self): + for order in self: + if error_messages := order.company_id._l10n_jo_validate_config() or order._l10n_jo_validate_fields() or order._submit_to_jofotara(): + order.l10n_jo_edi_pos_state = 'to_send' + order.l10n_jo_edi_pos_error = error_messages + # avoid creating an invoice in case of JoFotara sync failure + order.to_invoice = False + return error_messages + else: + order.l10n_jo_edi_pos_state = 'demo' if order.env.company.l10n_jo_edi_demo_mode else 'sent' + order.l10n_jo_edi_pos_error = False + order._link_xml_and_qr_to_invoice(order.account_move) + order.message_post( + body=order.env._("E-invoice (JoFotara) submitted successfully."), + attachment_ids=order.l10n_jo_edi_pos_xml_attachment_id.ids, + ) + + def action_pos_order_paid(self): + # EXTENDS 'point_of_sale' + """ + Once an order is paid, sync it with JoFotara if possible + """ + result = super().action_pos_order_paid() + if self.country_code == 'JO' and self.l10n_jo_edi_pos_enabled: + self._l10n_jo_edi_send() + return result + + def button_l10n_jo_edi_pos(self): + if error_message := self._l10n_jo_edi_send(): + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'danger', + 'message': error_message, + 'next': { + 'type': 'ir.actions.act_window_close', + }, + } + } + + @api.depends('country_code', 'l10n_jo_edi_pos_error') + def _compute_l10n_jo_edi_pos_computed_xml(self): + for order in self: + if order.country_code == 'JO' and not order.l10n_jo_edi_pos_error: + xml_content = order.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + order.l10n_jo_edi_pos_computed_xml = base64.b64encode(xml_content) + else: + order.l10n_jo_edi_pos_computed_xml = False + + def download_l10n_jo_edi_pos_computed_xml(self): + if error_message := self.company_id._l10n_jo_validate_config() or self._l10n_jo_validate_fields(): + raise ValidationError(self.env._("The following errors have to be fixed in order to create an XML:\n") + error_message) + params = urlencode({ + 'model': self._name, + 'id': self.id, + 'field': 'l10n_jo_edi_pos_computed_xml', + 'filename': self._l10n_jo_edi_pos_get_xml_attachment_name(), + 'mimetype': 'application/xml', + 'download': 'true', + }) + return {'type': 'ir.actions.act_url', 'url': '/web/content/?' + params, 'target': 'new'} + + def _prepare_invoice_vals(self): + # EXTENDS 'point_of_sale' + vals = super()._prepare_invoice_vals() + return { + **vals, + 'ref': self.l10n_jo_edi_pos_return_reason, + 'l10n_jo_edi_uuid': self.l10n_jo_edi_pos_uuid, + 'l10n_jo_edi_state': self.l10n_jo_edi_pos_state, + 'l10n_jo_edi_error': self.l10n_jo_edi_pos_error, + 'l10n_jo_edi_invoice_type': 'local', # pos order invoices are always of type local + 'preferred_payment_method_line_id': self.env['account.payment.method.line'].search([], limit=1).id, + } + + def _create_invoice(self, move_vals): + # EXTENDS 'point_of_sale' + invoice = super()._create_invoice(move_vals) + self._link_xml_and_qr_to_invoice(invoice) + return invoice + + def _link_xml_and_qr_to_invoice(self, invoice): + if invoice and self.l10n_jo_edi_pos_xml_attachment_id: + self.env["ir.attachment"].create( + { + "res_model": "account.move", + "res_id": invoice.id, + "res_field": "l10n_jo_edi_xml_attachment_file", + "name": f'{self.name}_edi.xml', + "datas": self.l10n_jo_edi_pos_xml_attachment_id.datas, + } + ) + if invoice and self.l10n_jo_edi_pos_qr: + invoice.l10n_jo_edi_qr = self.l10n_jo_edi_pos_qr diff --git a/addons/l10n_jo_edi_pos/models/pos_payment_method.py b/addons/l10n_jo_edi_pos/models/pos_payment_method.py new file mode 100644 index 00000000000000..d79c303099dc02 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/pos_payment_method.py @@ -0,0 +1,18 @@ +from odoo import api, fields, models + + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + country_code = fields.Char(related='company_id.country_id.code', depends=['company_id.country_id']) + l10n_jo_edi_pos_is_cash = fields.Boolean( + string="JoFotara Cash", + help="If checked, this payment method will reported as a cash payment method to JoFotara.", + compute='_compute_l10n_jo_edi_pos_is_cash', + store=True, readonly=False, + ) + + @api.depends('journal_id.type') + def _compute_l10n_jo_edi_pos_is_cash(self): + for pm in self: + pm.l10n_jo_edi_pos_is_cash = pm.journal_id.type == 'cash' diff --git a/addons/l10n_jo_edi_pos/models/res_company.py b/addons/l10n_jo_edi_pos/models/res_company.py new file mode 100644 index 00000000000000..4b4bbe4f58b97d --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/res_company.py @@ -0,0 +1,15 @@ +from odoo import api, fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_jo_edi_pos_enabled = fields.Boolean() + l10n_jo_edi_pos_testing_mode = fields.Boolean() + + @api.model + def _load_pos_data_fields(self, config_id): + params = super()._load_pos_data_fields(config_id) + if self.env.company.account_fiscal_country_id.code == 'JO': + params += ["l10n_jo_edi_pos_enabled"] + return params diff --git a/addons/l10n_jo_edi_pos/models/res_config_settings.py b/addons/l10n_jo_edi_pos/models/res_config_settings.py new file mode 100644 index 00000000000000..e24d63baf434d8 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/res_config_settings.py @@ -0,0 +1,8 @@ +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_jo_edi_pos_enabled = fields.Boolean(related='company_id.l10n_jo_edi_pos_enabled', readonly=False) + l10n_jo_edi_pos_testing_mode = fields.Boolean(related='company_id.l10n_jo_edi_pos_testing_mode', readonly=False) diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml b/addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml new file mode 100644 index 00000000000000..bc1b2a5c7c2852 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml @@ -0,0 +1,22 @@ + + + + +
RECEIPT WITHOUT FISCAL VALUE
+
+
+ + + + +
--------------------------------
+
+ JoFotara QR code +
+
+ +
+
+
+
+
diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js b/addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js new file mode 100644 index 00000000000000..47d7643cce22d1 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js @@ -0,0 +1,22 @@ +import { InvoiceButton } from "@point_of_sale/app/screens/ticket_screen/invoice_button/invoice_button"; +import { WarningDialog } from "@web/core/errors/error_dialogs"; +import { _t } from "@web/core/l10n/translation"; +import { patch } from "@web/core/utils/patch"; + +patch(InvoiceButton.prototype, { + click() { + if ( + this.pos.config.company_id.l10n_jo_edi_pos_enabled && + !["sent", "demo"].includes(this.props.order.l10n_jo_edi_pos_state) + ) { + this.dialog.add(WarningDialog, { + title: _t("Odoo Warning"), + message: _t( + "Please synchronize this order with JoFotara first by clicking on Details > JoFotara (Jordan)" + ), + }); + return; + } + return super.click(); + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js b/addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js new file mode 100644 index 00000000000000..374cf6897ba9e7 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js @@ -0,0 +1,18 @@ +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { qrCodeSrc } from "@point_of_sale/utils"; +import { patch } from "@web/core/utils/patch"; + +patch(PosOrder.prototype, { + export_for_printing() { + const result = super.export_for_printing(...arguments); + if (this.company.account_fiscal_country_id?.code !== "JO") { + return result; + } + + result.headerData.l10n_jo_edi_pos_error = this.l10n_jo_edi_pos_error; + result.l10n_jo_edi_pos_qr = this.l10n_jo_edi_pos_qr + ? qrCodeSrc(this.l10n_jo_edi_pos_qr) + : false; + return result; + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js b/addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js new file mode 100644 index 00000000000000..79191443af1738 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js @@ -0,0 +1,28 @@ +import { _t } from "@web/core/l10n/translation"; +import { onMounted } from "@odoo/owl"; +import { patch } from "@web/core/utils/patch"; +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen"; + +patch(ReceiptScreen.prototype, { + setup() { + super.setup(); + onMounted(() => { + const error = this.currentOrder.l10n_jo_edi_pos_error; + if (error) { + this.dialog.add( + AlertDialog, + { + title: _t("JoFotara Error"), + body: + _t( + `The receipt is stuck due to an Error.\nTo send it, go to Orders > Select the Order > Details > JoFotara or Backend > Orders > Select the Order > JoFotara.\n\nError message:\n` + ) + error, + }, + {} + ); + } + }); + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js new file mode 100644 index 00000000000000..02182e3204b4e9 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js @@ -0,0 +1,34 @@ +import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen"; +import { makeAwaitable } from "@point_of_sale/app/store/make_awaitable_dialog"; +import { TextInputPopup } from "@point_of_sale/app/utils/input_popups/text_input_popup"; +import { _t } from "@web/core/l10n/translation"; +import { patch } from "@web/core/utils/patch"; + +patch(TicketScreen.prototype, { + get showJoEdiStatus() { + return this.pos.config.company_id.l10n_jo_edi_pos_enabled; + }, + get joEdiStatus() { + switch (this.order.l10n_jo_edi_pos_state) { + case "to_send": + return _t("To Send"); + case "sent": + return _t("Sent"); + case "demo": + return _t("Sent (Demo)"); + default: + return ""; + } + }, + async addAdditionalRefundInfo(order, destinationOrder) { + if (this.pos.config.company_id.country_id.code === "JO") { + const payload = await makeAwaitable(this.dialog, TextInputPopup, { + title: _t("JoFotara Return Reason"), + }); + if (payload) { + destinationOrder.l10n_jo_edi_pos_return_reason = payload; + } + } + return super.addAdditionalRefundInfo(...arguments); + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml new file mode 100644 index 00000000000000..39c262a2483f3a --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml @@ -0,0 +1,12 @@ + + + + + +
JoFotara Status
+
+ +
+ + + diff --git a/addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js b/addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js new file mode 100644 index 00000000000000..7f4568979bdc27 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js @@ -0,0 +1,23 @@ +import * as Chrome from "@point_of_sale/../tests/tours/utils/chrome_util"; +import * as Dialog from "@point_of_sale/../tests/tours/utils/dialog_util"; +import * as PaymentScreen from "@point_of_sale/../tests/tours/utils/payment_screen_util"; +import * as ProductScreen from "@point_of_sale/../tests/tours/utils/product_screen_util"; +import * as ReceiptScreen from "@point_of_sale/../tests/tours/utils/receipt_screen_util"; +import { registry } from "@web/core/registry"; + +registry.category("web_tour.tours").add("L10nJoEdiPosTour", { + steps: () => + [ + Chrome.startPoS(), + Dialog.confirm("Open Register"), + ProductScreen.addOrderline("Desk Pad", "1"), + ProductScreen.clickPayButton(), + PaymentScreen.clickPaymentMethod("Bank"), + PaymentScreen.clickValidate(), + ReceiptScreen.receiptIsThere(), + { + content: "Check for JoFotara QR code", + trigger: "div:contains('JoFotara QR code') + div img.pos-receipt-qrcode", + }, + ].flat(), +}); diff --git a/addons/l10n_jo_edi_pos/tests/__init__.py b/addons/l10n_jo_edi_pos/tests/__init__.py new file mode 100644 index 00000000000000..780798f4e4d79c --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_jo_edi_pos_precision +from . import test_jo_edi_pos_types +from . import test_l10n_jo_edi_pos_tour diff --git a/addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py b/addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py new file mode 100644 index 00000000000000..00c8d8cdb59662 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py @@ -0,0 +1,47 @@ +from odoo import Command +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi.tests.jo_edi_common import JoEdiCommon +from odoo.addons.point_of_sale.tests.common import TestPoSCommon +from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class JoEdiPosCommon(JoEdiCommon, TestPoSCommon, TestPointOfSaleHttpCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company_data['company'].write({ + 'country_id': cls.env.ref('base.jo').id, + }) + + def _l10n_jo_create_order(self, order_vals): + self.main_pos_config.with_user(self.pos_user).open_ui() + order_vals.update({ + 'company_id': self.company.id, + 'session_id': self.main_pos_config.current_session_id.id, + 'partner_id': self.partner_jo.id, + 'date_order': order_vals.get('date_order', '2019-01-01'), + }) + order_vals.setdefault('amount_tax', 0) + order_vals.setdefault('amount_paid', 0) + order_vals.setdefault('amount_total', 0) + order_vals.setdefault('amount_return', 0) + order_vals['lines'] = [Command.create({'price_subtotal': 0, 'price_subtotal_incl': 0, **line}) for line in order_vals['lines']] + order = self.env['pos.order'].create(order_vals) + order._compute_prices() + + if 'currency_id' in order_vals: + order.currency_id = order_vals['currency_id'] + + return order + + def _l10n_jo_create_order_refund(self, order, refund_vals): + order = self._l10n_jo_create_order(order) if isinstance(order, dict) else order + order_refund = order._refund() + if 'lines' in refund_vals: + for order_line, line_write_vals in zip(order_refund.lines, refund_vals['lines']): + order_line.write(line_write_vals) + del refund_vals['lines'] + order_refund.write(refund_vals) + return order_refund diff --git a/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py new file mode 100644 index 00000000000000..bdcb72099e74b9 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py @@ -0,0 +1,141 @@ +from odoo import Command +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi.tests.test_jo_edi_precision import TestJoEdiPrecision +from odoo.addons.l10n_jo_edi_pos.tests.jo_edi_pos_common import JoEdiPosCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestJoEdiPosPrecision(JoEdiPosCommon, TestJoEdiPrecision): + def _validate_order_vals_jo_edi_pos_numbers(self, order_vals): + with self.subTest(sub_test_name=order_vals['name']): + order = self._l10n_jo_create_order(order_vals) + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + errors = self._validate_jo_edi_numbers(generated_file, order.amount_total) + self.assertFalse(errors, errors) + + def test_jo_pos_sales_invoice_precision(self): + eur = self.env.ref('base.EUR') + self.setup_currency_rate(eur, 1.41) + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + + self._validate_order_vals_jo_edi_pos_numbers({ + 'name': 'TestEIN022', + 'currency_id': eur.id, + 'date_order': '2023-11-12', + 'lines': [ + { + 'product_id': self.product_a.id, + 'qty': 3.48, + 'price_unit': 1.56, + 'discount': 2.5, + 'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)], + }, + { + 'product_id': self.product_b.id, + 'qty': 6.02, + 'price_unit': 2.79, + 'discount': 2.5, + 'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)], + }, + ], + }) + + def test_jo_pos_special_invoice_precision(self): + self.company.l10n_jo_edi_taxpayer_type = 'special' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + self._validate_order_vals_jo_edi_pos_numbers({ + 'name': 'TestEIN014', + 'date_order': '2023-11-10', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_10).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_5).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_16 | self.jo_special_tax_5).ids)], + }, + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_16 | self.jo_special_tax_10).ids)], + }, + ], + }) + + def test_jo_pos_credit_notes_price_unit(self): + def get_price_units(xml_string): + root = self.get_xml_tree_from_string(xml_string) + for xml_line in root.findall('./{*}InvoiceLine'): + yield float(xml_line.findtext('{*}Price/{*}PriceAmount')) + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + order = self._l10n_jo_create_order({ + 'name': 'TestEIN014', + 'date_order': '2023-11-10', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 11.11, + 'qty': 9833, + 'discount': 3.12, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 10000.01, + 'qty': 93333, + 'discount': 99.71, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 0.01, + 'qty': 0.11, + 'discount': 2, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + ], + }) + refund = self._l10n_jo_create_order_refund(order, { + 'l10n_jo_edi_pos_return_reason': 'return reason', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 11.11, + 'qty': 3.11, + 'discount': 3.12, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 10000.01, + 'qty': 2.02, + 'discount': 99.71, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 0.01, + 'qty': 0.1, + 'discount': 2, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + ], + }) + invoice_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + refund_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + for invoice_price_unit, refund_price_unit in zip(get_price_units(invoice_file), get_price_units(refund_file)): + self.assertEqual(invoice_price_unit, refund_price_unit) diff --git a/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py new file mode 100644 index 00000000000000..642461d8b405b6 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py @@ -0,0 +1,352 @@ +from odoo import Command +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi_pos.tests.jo_edi_pos_common import JoEdiPosCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestJoEdiPosTypes(JoEdiPosCommon): + def test_jo_pos_income_invoice(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 3, + 'qty': 44, + 'discount': 1, + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_1') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_income_refund(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + order_vals = { + 'name': 'EIN00017', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 18.85, + 'qty': 10, + 'discount': 20, + }, + ], + } + refund_vals = { + 'name': 'EIN998833', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'l10n_jo_edi_pos_return_reason': 'Reversal of: EIN00017, change price', + 'lines': [ + { + 'price_unit': 3, + 'qty': -44, + 'discount': 1, + }, + ], + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + + expected_file = self._read_xml_test_file('type_2') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_sales_invoice(self): + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + + order_vals = { + 'name': 'TestEIN022', + 'date_order': '2023-11-10', + 'general_note': 'Test General for Documentation', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 100, + 'discount': 10, + 'tax_ids': [Command.set(self.jo_general_tax_10.ids)], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_3') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_sales_refund(self): + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + + order_vals = { + 'name': 'TestEIN022', + 'currency_id': self.usd.id, # should not affect values as they are reported in order currency + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 100, + 'discount': 10, + 'tax_ids': [Command.set(self.jo_general_tax_10.ids)], + }, + ], + } + refund_vals = { + 'name': 'TestEIN022R', + 'currency_id': self.usd.id, # should not affect values as they are reported in order currency + 'date_order': '2023-11-10', + 'l10n_jo_edi_pos_return_reason': 'Reversal of: TestEIN022, Test_Return', + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + + expected_file = self._read_xml_test_file('type_4') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_special_invoice(self): + self.company.l10n_jo_edi_taxpayer_type = 'special' + self.company.l10n_jo_edi_sequence_income_source = '16683696' + + order_vals = { + 'name': 'TestEIN013', + 'currency_id': self.usd.id, # should not affect values as they are reported in order currency + 'date_order': '2023-11-10', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_10).ids)], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_5') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_special_refund(self): + self.company.l10n_jo_edi_taxpayer_type = 'special' + self.company.l10n_jo_edi_sequence_income_source = '16683696' + + order_vals = { + 'name': 'TestEIN013', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_10).ids)], + }, + ], + } + refund_vals = { + 'name': 'TestEINReturn013', + 'date_order': '2023-11-10', + 'l10n_jo_edi_pos_return_reason': 'Reversal of: TestEIN013, Test Return', + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + + expected_file = self._read_xml_test_file('type_6') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_no_vat_customer(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + self.partner_jo.vat = False + + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 3, + 'qty': 44, + 'discount': 1, + 'tax_ids': [Command.clear()], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_7') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_no_country_customer(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + self.partner_jo.country_id = False + + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 3, + 'qty': 44, + 'discount': 1, + 'tax_ids': [Command.clear()], + }, + ], + } + + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_8') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_credit_notes_lines_matching(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + order_vals = { + 'name': 'EIN00017', + 'lines': [ + { # id = 1 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 10, + 'discount': 10, + }, + { # id = 2 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 10, + 'discount': 20, + }, + { # id = 3 + 'product_id': self.product_b.id, + 'price_unit': 10, + 'qty': 10, + }, + { # id = 4 + 'product_id': self.product_b.id, + 'price_unit': 20, + 'qty': 10, + }, + ], + } + refund_vals = { + 'name': 'EIN998833', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'l10n_jo_edi_pos_return_reason': 'change price', + 'lines': [ + { # id should be 4 + 'product_id': self.product_b.id, + 'price_unit': 20, + 'qty': -3, + 'discount': 0, + }, + { # id should be 1 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': -10, + 'discount': 10, + }, + { # id should be 2 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': -1, + 'discount': 20, + }, + { # id should be > 4 + 'product_id': self.product_b.id, + 'price_unit': 30, + 'qty': -10, + }, + ], + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + xml_string = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + xml_tree = self.get_xml_tree_from_string(xml_string) + for xml_line, expected_line_id in zip(xml_tree.findall('./{*}InvoiceLine'), [4, 1, 2]): + self.assertEqual(int(xml_line.findtext('{*}ID')), expected_line_id) + + self.assertGreater(int(xml_tree.findall('./{*}InvoiceLine')[-1].findtext('{*}ID')), 4) + + def test_different_payment_methods(self): + def get_xml_order_type(order, amount_cash, amount_bank): + cash_pm = order.config_id.payment_method_ids.filtered(lambda pm: pm.l10n_jo_edi_pos_is_cash)[0] + bank_pm = order.config_id.payment_method_ids.filtered(lambda pm: not pm.l10n_jo_edi_pos_is_cash)[0] + + if amount_cash: + self.make_payment(order, cash_pm, amount_cash) + if amount_bank: + self.make_payment(order, bank_pm, amount_bank) + if order._l10n_jo_validate_fields(): # conflicting payment methods + return False + + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + xml_tree = self.get_xml_tree_from_string(generated_file) + return xml_tree.find(".//{urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2}InvoiceTypeCode").get('name') + + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + for (cash_amount, bank_amount, expected_type) in [ + (100, 0, '011'), + (0, 100, '021'), + (50, 50, False), + ]: + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 100, + 'qty': 1, + 'discount': 0, + 'tax_ids': [Command.clear()], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + order_type = get_xml_order_type(order, cash_amount, bank_amount) + self.assertEqual(order_type, expected_type) diff --git a/addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py b/addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py new file mode 100644 index 00000000000000..00d951347817e5 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py @@ -0,0 +1,23 @@ +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi_pos.tests.jo_edi_pos_common import JoEdiPosCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestL10nJoEdiPosTour(JoEdiPosCommon): + + def test_l10n_jo_edi_pos_tour(self): + self.company.write({ + 'l10n_jo_edi_pos_enabled': True, + 'l10n_jo_edi_pos_testing_mode': True, + 'l10n_jo_edi_demo_mode': True, + 'l10n_jo_edi_sequence_income_source': '12345', + 'l10n_jo_edi_secret_key': 'demo_secret_key', + 'l10n_jo_edi_client_identifier': 'demo_client_identifier', + 'l10n_jo_edi_taxpayer_type': 'income', + }) + + self.main_pos_config.with_user(self.pos_user).open_ui() + self.start_tour( + "/pos/ui?config_id=%d" % self.main_pos_config.id, + "L10nJoEdiPosTour", login="pos_user", + ) diff --git a/addons/l10n_jo_edi_pos/views/pos_order_views.xml b/addons/l10n_jo_edi_pos/views/pos_order_views.xml new file mode 100644 index 00000000000000..4e8f7648b4b39e --- /dev/null +++ b/addons/l10n_jo_edi_pos/views/pos_order_views.xml @@ -0,0 +1,56 @@ + + + + pos.order.form + pos.order + + +
+
+ + + + + + + + + +
+
+ + + pos.order + + + + + + + + + + + pos.order.list.select.inherit + pos.order + + + + + + + + +
diff --git a/addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml b/addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml new file mode 100644 index 00000000000000..7f6c7e86f3a72d --- /dev/null +++ b/addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml @@ -0,0 +1,13 @@ + + + + pos.payment.method.form + pos.payment.method + + + + + + + + diff --git a/addons/l10n_jo_edi_pos/views/res_config_settings_views.xml b/addons/l10n_jo_edi_pos/views/res_config_settings_views.xml new file mode 100644 index 00000000000000..1ca62a6e8f28ed --- /dev/null +++ b/addons/l10n_jo_edi_pos/views/res_config_settings_views.xml @@ -0,0 +1,25 @@ + + + + res.config.settings.view.form + res.config.settings + + + + + +
+ +
+
+
+
+
+
From 7b9e596403a2baada30ef3ff0cd165b5282e9712 Mon Sep 17 00:00:00 2001 From: "Claire (clbr)" Date: Tue, 17 Feb 2026 16:01:29 +0100 Subject: [PATCH 10/23] [FIX] l10n_{hr,ro}_edi: Fix dependency to Peppol BIS3 constraints The CIUS RO and CIUS HR depends on the BIS3 which is fundamentally incorrect. This was probably made out of lazyness to redefine things that are almost the same in both these CIUS and the BIS3. Now, in previous PR [1], we added contraints for the Peppol BIS 3 that are impacting those formats. Indeed, the EndpointID can be empty in the context of CIUS RO and CIUS HR. In particular, it's breaking the sending to physical person at the moment. [1]: https://github.com/odoo/odoo/pull/246961 opw-5943698 closes odoo/odoo#249089 Signed-off-by: Claire Bretton (clbr) --- addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py | 6 +++++- addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py | 6 +++++- .../from_odoo/ciusro_out_invoice_no_prefix_vat.xml | 1 - addons/l10n_ro_edi/tests/test_xml_ubl_ro.py | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py b/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py index d7b104f7bef217..011fb6b588d55d 100644 --- a/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py +++ b/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py @@ -63,7 +63,11 @@ def _get_document_nsmap(self, vals): return nsmap def _export_invoice_constraints_new(self, invoice, vals): - constraints = super()._export_invoice_constraints_new(invoice, vals) + # OVERRIDE 'account.edi.xml.ubl_bis3': don't apply Peppol rules + constraints = self.env['account.edi.xml.ubl_20']._export_invoice_constraints(invoice, vals) + constraints.update( + self._invoice_constraints_cen_en16931_ubl_new(invoice, vals) + ) constraints.update( self._invoice_constraints_eracun_new(invoice, vals) ) diff --git a/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py b/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py index d66716f8dcdf13..57256aa440609d 100644 --- a/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py +++ b/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py @@ -239,6 +239,10 @@ def _ubl_add_accounting_customer_party_legal_entity_nodes(self, vals): }] def _export_invoice_constraints_new(self, invoice, vals): - constraints = super()._export_invoice_constraints_new(invoice, vals) + # OVERRIDE 'account.edi.xml.ubl_bis3': don't apply Peppol rules + constraints = self.env['account.edi.xml.ubl_20']._export_invoice_constraints(invoice, vals) + constraints.update( + self._invoice_constraints_cen_en16931_ubl_new(invoice, vals) + ) constraints.update(self._export_invoice_constraints_ciusro(vals)) return constraints diff --git a/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml b/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml index 88e2d5d7979004..fbc6740e5c4863 100644 --- a/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml +++ b/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml @@ -52,7 +52,6 @@ - ___ignore___ ref_partner_a diff --git a/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py b/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py index 430569b2a5de24..db4f5a767db546 100644 --- a/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py +++ b/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py @@ -178,7 +178,7 @@ def test_export_no_vat_but_have_company_registry_new(self): def test_export_no_vat_but_have_company_registry_without_prefix(self): self.company_data['company'].write({'vat': False, 'company_registry': '1234567897'}) - self.partner_a.write({'vat': False}) + self.partner_a.write({'vat': False, 'peppol_eas': False, 'peppol_endpoint': False}) invoice = self.create_move("out_invoice", currency_id=self.company.currency_id.id) attachment = self.get_attachment(invoice) self._assert_invoice_attachment(attachment, xpaths=None, expected_file_path='from_odoo/ciusro_out_invoice_no_prefix_vat.xml') From dc921ed783c4212ee01ef18b458eb8437098517e Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Thu, 19 Feb 2026 10:13:06 +0100 Subject: [PATCH 11/23] [FIX] core: map model names to attrs Followup to #247151 after community report that if a test swizzles the model in the registry, looking models up by class leads to a lookup failure and a fallback to the default attributes set, and an almost certain mismatch as a result. Switching to a string lookup fixes the issue and doesn't seem to trigger any problem. closes odoo/odoo#249416 Related: odoo/enterprise#107905 Signed-off-by: Xavier Morel (xmo) --- odoo/tests/common.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 089ba01aeae3a8..041e99b9764c09 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -1020,7 +1020,7 @@ def signal_changes(): cls.startClassPatcher(cls._signal_changes_patcher) cls.attrs_before = { - model: { + model._name: { *vars(model), # __annotations__ pops up during testing on *some* models '__annotations__', @@ -1115,7 +1115,7 @@ def check_attrs(self): } with self._outcome.testPartExecutor(self, isTest=False): # need defaults for custom models created during the test - default_attrs = self.attrs_before[self.registry['base']] | {'_rec_name', '_active_name'} + default_attrs = self.attrs_before['base'] | {'_rec_name', '_active_name'} # TODO: maybe retrieve all abstractmodels and either create a big # set of mixin attributes to always remove or have a mapping # of mixin: attributes to remove on a per-model basis? @@ -1130,7 +1130,7 @@ def check_attrs(self): # registry in place, adding fields on leaf classes if not (f.startswith('x_') and f in model._fields) if (model, f) not in modelClassPatches - }.difference(self.attrs_before.get(model, default_attrs)) + }.difference(self.attrs_before.get(model._name, default_attrs)) if extras: sets = "\n\n".join( f"======== {k} ========\n{v}:\n{tb}\n" From bc5cadbeffdc22df9ab7282e8183eb41d5aafdc3 Mon Sep 17 00:00:00 2001 From: "Andrea Grazioso (agr-odoo)" Date: Tue, 17 Feb 2026 16:43:36 +0100 Subject: [PATCH 12/23] [FIX] l10n_es_edi_facturae: restrict xml currency fields to 2 decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps to reproduce: - Have facturae modules installed - Generate invoice with any amount - Send to Facturae Issue: Resulting XML has 8 digits after the decimal point on several fields, such as unit price, gross amount, and total cost. When trying to validate such an XML with e-Fact [1], this results in validation error: "RCF06001: En facturas emitidas en euros, alguno de los importes de las líneas tiene más de dos decimales (regla 6a del anexo II de la Orden HAP/1650/2015)." It occurs after 88b7ee6d9d2a7fe96512da0a7eaf8efcf9020ee1. Even if the TotalCost and GrossAmount have more digits in the xml, the file will be accepted by the fiscal entity only if the digits after the second one are all 0's. If any of the high precision digits is used, validation may fail. This commit partially revert changes introduced before because only UnitPriceWithoutTax can actually have more precision [1] https://efact.aoc.cat/bustia/validation/validateFile.htm opw-5927356 closes odoo/odoo#248882 Signed-off-by: Laurent Smet (las) --- .../data/facturae_templates.xml | 8 +++---- .../tests/data/expected_ac_document.xml | 4 ++-- .../data/expected_in_invoice_document.xml | 24 +++++++++---------- .../data/expected_invoice_payment_means.xml | 4 ++-- .../data/expected_invoice_period_document.xml | 4 ++-- .../data/expected_out_invoice_4_decimals.xml | 4 ++-- .../data/expected_out_invoice_negative.xml | 8 +++---- .../data/expected_out_invoice_round_glob.xml | 16 ++++++------- .../data/expected_out_invoice_withhold.xml | 4 ++-- .../tests/data/expected_refund_document.xml | 8 +++---- .../tests/data/expected_signed_document.xml | 24 +++++++++---------- .../data/expected_simplified_document.xml | 4 ++-- .../tests/data/import_multiple_invoices.xml | 12 +++++----- .../tests/data/import_withholding_invoice.xml | 4 ++-- 14 files changed, 64 insertions(+), 64 deletions(-) diff --git a/addons/l10n_es_edi_facturae/data/facturae_templates.xml b/addons/l10n_es_edi_facturae/data/facturae_templates.xml index 8ca52377cc176b..12ddc251986cbb 100644 --- a/addons/l10n_es_edi_facturae/data/facturae_templates.xml +++ b/addons/l10n_es_edi_facturae/data/facturae_templates.xml @@ -135,13 +135,13 @@ - + - + @@ -150,11 +150,11 @@ - + - + diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml index ff5dcf631d5a9c..a5f24d7d139619 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml @@ -136,8 +136,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml index 7e21326411ef56..65c09bf3ff8049 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml @@ -137,8 +137,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -158,8 +158,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -179,8 +179,8 @@ 1.0 01 200.00000000 - 200.00000000 - 200.00000000 + 200.00 + 200.00 01 @@ -200,15 +200,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 900.00000000 + 900.00 01 @@ -228,15 +228,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 1100.00000000 + 1100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml index 908a89a9df9a7b..edb9205a56d8f7 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml @@ -97,8 +97,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml index d94bdedbe1f8f2..77e28c0dc3be81 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml @@ -101,8 +101,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml index 25698324f25f4b..409c88ec822371 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml @@ -97,8 +97,8 @@ 22.0 01 2.05909091 - 45.30000000 - 45.30000000 + 45.30 + 45.30 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml index e3c13ed5a56acf..9359fdadbdfd79 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml @@ -129,8 +129,8 @@ 1.0 01 1000.00000000 - 1000.00000000 - 1000.00000000 + 1000.00 + 1000.00 04 @@ -162,8 +162,8 @@ 1.0 01 -100.00000000 - -100.00000000 - -100.00000000 + -100.00 + -100.00 04 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml index 408f0e41f03f30..dde39a2f94980a 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml @@ -127,8 +127,8 @@ 25.0 01 2.02000000 - 50.50000000 - 50.50000000 + 50.50 + 50.50 01 @@ -148,8 +148,8 @@ 2.0 01 7.29000000 - 14.58000000 - 14.58000000 + 14.58 + 14.58 01 @@ -169,8 +169,8 @@ 5.0 01 7.32000000 - 36.60000000 - 36.60000000 + 36.60 + 36.60 01 @@ -190,8 +190,8 @@ 25.0 01 9.50000000 - 237.50000000 - 237.50000000 + 237.50 + 237.50 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml index 3cdb6853991d46..2ff531d4e27d43 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml @@ -97,8 +97,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 04 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml index be4cb775d2d91d..434a9eb9fb1355 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml @@ -124,8 +124,8 @@ 1.0 01 -100.00000000 - -100.00000000 - -100.00000000 + -100.00 + -100.00 01 @@ -148,8 +148,8 @@ 1.0 01 -100.00000000 - -100.00000000 - -100.00000000 + -100.00 + -100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml index 9b0c5782572ba6..97afeba517bc57 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml @@ -137,8 +137,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -158,8 +158,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -179,8 +179,8 @@ 1.0 01 200.00000000 - 200.00000000 - 200.00000000 + 200.00 + 200.00 01 @@ -200,15 +200,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 900.00000000 + 900.00 01 @@ -228,15 +228,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 1100.00000000 + 1100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml index c611c7d53a7315..7f3b5930bf90d3 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml @@ -97,8 +97,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml b/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml index 3dbe827b83bea4..099975b17a6223 100644 --- a/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml +++ b/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml @@ -113,8 +113,8 @@ 1.0 01 320.00000000 - 320.00000000 - 320.00000000 + 320.00 + 320.00 01 @@ -133,8 +133,8 @@ 1.0 01 1799.00000000 - 1799.00000000 - 1799.00000000 + 1799.00 + 1799.00 01 @@ -197,8 +197,8 @@ 3.0 01 320.00000000 - 960.00000000 - 960.00000000 + 960.00 + 960.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml b/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml index 11b69ae7267f10..2d65207122f578 100644 --- a/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml +++ b/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml @@ -115,8 +115,8 @@ 1.0 01 320.00000000 - 320.00000000 - 320.00000000 + 320.00 + 320.00 01 From 09019b4dd5125a3467c40da933bde0ceb5072a39 Mon Sep 17 00:00:00 2001 From: "Mahdi Alijani (malj)" Date: Tue, 17 Feb 2026 13:23:55 +0100 Subject: [PATCH 13/23] [FIX] delivery, website_sale_collect: fix case formatting in pick-up Issue: --- In pick-up point list, the case formatting should be: 1- Pick-up point's `name`, `street` and `city` should not be auto-capitalized. 2- Weekdays should must be always capitalized regardless of language. Steps to reproduce: --- 1- Create a second Company named `store`. 2- Create a wh for the created company and add the wh to click-and-collect pick-up points. (There should be more than 1 pick-up points) 3- In the website, add Dutch lang. 4- In website, open a product and, open the `Click and Collect`. Outcome: --- Name is capitalized to `Store` and the days are not capitalized if you switch to dutch lang. Cause: --- Due to CLDR, luxon doesn't capitalize weekdays in some languages. opw-5941830 closes odoo/odoo#249078 Signed-off-by: Mohammadmahdi Alijani (malj) --- .../location_schedule/location_schedule.js | 3 ++- addons/website_sale_collect/models/delivery_carrier.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js b/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js index aa4c4454701653..f5922004862e2c 100644 --- a/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js +++ b/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js @@ -24,7 +24,8 @@ export class LocationSchedule extends Component { * @return {Object} the localized name of the day (long version). */ getWeekDay(weekday) { - return luxon.Info.weekdays()[weekday] + const dayName = luxon.Info.weekdays()[weekday]; + return dayName.charAt(0).toUpperCase() + dayName.slice(1); } get closedLabel() { diff --git a/addons/website_sale_collect/models/delivery_carrier.py b/addons/website_sale_collect/models/delivery_carrier.py index ebf1beee38b245..c61fe4eee7558e 100644 --- a/addons/website_sale_collect/models/delivery_carrier.py +++ b/addons/website_sale_collect/models/delivery_carrier.py @@ -87,9 +87,9 @@ def _in_store_get_close_locations(self, partner_address, product_id=None): try: pickup_location_values = { 'id': wh.id, - 'name': wh_location['name'].title(), - 'street': wh_location['street'].title(), - 'city': wh_location.city.title(), + 'name': wh_location['name'], + 'street': wh_location['street'], + 'city': wh_location.city, 'zip_code': wh_location.zip or '', 'country_code': wh_location.country_code, 'state': wh_location.state_id.code, From 1208b6a378a82168487e0450f5f6b9a85944fe67 Mon Sep 17 00:00:00 2001 From: dhha-odoo Date: Wed, 18 Feb 2026 19:15:47 +0530 Subject: [PATCH 14/23] [FIX] barcodes_gs1_nomenclature: prevent traceback on barcode rule form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue before this commit: ========================= When opening a Barcode Rule form view, a traceback was raised due to the following python expression: bool(parent.is_gs1_nomenclature or type == 'alias') Steps to Reproduce: ========================= - Install the stock module. - Go to Configuration → Barcode Nomenclatures in the Stock app. - Open any Barcode Nomenclature form. - Go to the Rules tab and open a rule (pop-up form view). - Click on the Expand button. - A traceback is raised. Cause of the issue: ========================= The form view tries to evaluate `parent.is_gs1_nomenclature`, but the `parent` record is not defined when the rule form view is opened directly (via expand), leading to a traceback. This happens because the form view is not defined as a child of any parent view, so no parent context is available, which leads to a traceback. With This Commit: ========================= Removed the usage of `parent.is_gs1_nomenclature` and use `is_gs1_nomenclature` directly instead. The `is_gs1_nomenclature` field on `barcode.rule` is already a related field to `barcode.nomenclature`, so it can be safely used without relying on the parent. opw-5949083 closes odoo/odoo#249293 Signed-off-by: Steve Van Essche --- addons/barcodes_gs1_nomenclature/views/barcodes_view.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml b/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml index 044eba6ac016ef..852a2d327575ac 100644 --- a/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml +++ b/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml @@ -23,6 +23,7 @@ parent.is_gs1_nomenclature + @@ -47,12 +48,12 @@ - parent.is_gs1_nomenclature or type == 'alias' + is_gs1_nomenclature or type == 'alias' - - - + + + From 3eff8c95ac28a6e5b2db47bb3101afce7c665df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre=20=28lul=29?= Date: Thu, 19 Feb 2026 19:17:39 +0100 Subject: [PATCH 15/23] [FIX] spreadsheet: update o_spreadsheet to latest version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Contains the following commits: https://github.com/odoo/o-spreadsheet/commit/ce74496c9 [REL] 18.0.57 [Task: 0](https://www.odoo.com/odoo/2328/tasks/0) https://github.com/odoo/o-spreadsheet/commit/43731776d [FIX] Evaluation: Provide cell position on single formula evaluation [Task: 5798610](https://www.odoo.com/odoo/2328/tasks/5798610) https://github.com/odoo/o-spreadsheet/commit/979c0a365 [FIX] chart-panel: keep buttons visibles [Task: 5926661](https://www.odoo.com/odoo/2328/tasks/5926661) closes odoo/odoo#249571 Signed-off-by: Rémi Rahir (rar) Co-authored-by: Florian Damhaut (flda) Co-authored-by: Anthony Hendrickx (anhe) Co-authored-by: Alexis Lacroix (laa) Co-authored-by: Lucas Lefèvre (lul) Co-authored-by: Adrien Minne (adrm) Co-authored-by: Ronak Mukeshbhai Bharadiya (rmbh) Co-authored-by: Dhrutik Patel (dhrp) Co-authored-by: Rémi Rahir (rar) Co-authored-by: Pierre Rousseau (pro) Co-authored-by: Vincent Schippefilt (vsc) Co-authored-by: Marceline Thomas (matho) --- .../static/src/o_spreadsheet/o_spreadsheet.js | 43 ++++++++++++------- .../src/o_spreadsheet/o_spreadsheet.xml | 9 ++-- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js index 800c6ee2b28b8c..84b45cebf137a9 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js @@ -2,9 +2,9 @@ /** * This file is generated by o-spreadsheet build tools. Do not edit it. * @see https://github.com/odoo/o-spreadsheet - * @version 18.0.56 - * @date 2026-02-06T07:14:56.532Z - * @hash 4587c3416 + * @version 18.0.57 + * @date 2026-02-19T18:17:34.409Z + * @hash ce74496c9 */ import { useEnv, useSubEnv, onWillUnmount, useComponent, status, Component, useRef, onMounted, useEffect, useState, onPatched, onWillPatch, onWillUpdateProps, useExternalListener, onWillStart, xml, useChildSubEnv, markRaw, toRaw } from '@odoo/owl'; @@ -47318,7 +47318,11 @@ class CellComposerStore extends AbstractComposerStore { if (!content.startsWith("=")) { return; } - const evaluated = this.getters.evaluateFormula(this.sheetId, content); + const evaluated = this.getters.evaluateFormula(this.sheetId, content, { + sheetId: this.sheetId, + col: this.col, + row: this.row, + }); if (!isMatrix(evaluated)) { return; } @@ -59017,7 +59021,16 @@ class Evaluator { this.compilationParams.ensureRange(range); } } - updateCompilationParameters() { + updateCompilationParameters(originCellPosition) { + this.compilationParams = buildCompilationParameters(this.context, this.getters, this.computeAndSave.bind(this)); + this.compilationParams.evalContext.__originCellPosition = originCellPosition; + this.compilationParams.evalContext.lookupCaches = this.compilationParams.evalContext + .lookupCaches || { + forwardSearch: new Map(), + reverseSearch: new Map(), + }; + } + updateCompilationParametersForGridEvaluation() { // rebuild the compilation parameters (with a clean cache) this.compilationParams = buildCompilationParameters(this.context, this.getters, this.computeAndSave.bind(this)); this.compilationParams.evalContext.updateDependencies = this.updateDependencies.bind(this); @@ -59092,10 +59105,10 @@ class Evaluator { this.evaluate(this.getAllCells()); console.debug("evaluate all cells", performance.now() - start, "ms"); } - evaluateFormulaResult(sheetId, formulaString) { + evaluateFormulaResult(sheetId, formulaString, originCellPosition) { const compiledFormula = compile(formulaString); const ranges = compiledFormula.dependencies.map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc)); - this.updateCompilationParameters(); + this.updateCompilationParameters(originCellPosition); return this.evaluateCompiledFormula(sheetId, { ...compiledFormula, dependencies: ranges, @@ -59142,7 +59155,7 @@ class Evaluator { this.nextPositionsToUpdate = positions; let currentIteration = 0; while (!this.nextPositionsToUpdate.isEmpty() && currentIteration++ < MAX_ITERATION) { - this.updateCompilationParameters(); + this.updateCompilationParametersForGridEvaluation(); const positions = this.nextPositionsToUpdate.clear(); for (let i = 0; i < positions.length; ++i) { this.evaluatedCells.delete(positions[i]); @@ -59549,15 +59562,15 @@ class EvaluationPlugin extends UIPlugin { // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- - evaluateFormula(sheetId, formulaString) { - const result = this.evaluateFormulaResult(sheetId, formulaString); + evaluateFormula(sheetId, formulaString, originCellPosition) { + const result = this.evaluateFormulaResult(sheetId, formulaString, originCellPosition); if (isMatrix(result)) { return matrixMap(result, (cell) => cell.value); } return result.value; } - evaluateFormulaResult(sheetId, formulaString) { - return this.evaluator.evaluateFormulaResult(sheetId, formulaString); + evaluateFormulaResult(sheetId, formulaString, originCellPosition) { + return this.evaluator.evaluateFormulaResult(sheetId, formulaString, originCellPosition); } evaluateCompiledFormula(sheetId, compiledFormula, getSymbolValue) { return this.evaluator.evaluateCompiledFormula(sheetId, compiledFormula, getSymbolValue); @@ -74850,7 +74863,7 @@ const constants = { export { AbstractCellClipboardHandler, AbstractChart, AbstractFigureClipboardHandler, CellErrorType, CommandResult, CorePlugin, DispatchResult, EvaluationError, Model, PivotRuntimeDefinition, Registry, Revision, SPREADSHEET_DIMENSIONS, Spreadsheet, SpreadsheetPivotTable, UIPlugin, __info__, addFunction, addRenderingLayer, astToFormula, compile, compileTokens, components, constants, convertAstNodes, coreTypes, findCellInNewZone, functionCache, helpers, hooks, invalidateCFEvaluationCommands, invalidateDependenciesCommands, invalidateEvaluationCommands, iterateAstNodes, links, load, parse, parseTokens, readonlyAllowedCommands, registries, setDefaultSheetViewSize, setTranslationMethod, stores, tokenColors, tokenize }; -__info__.version = "18.0.56"; -__info__.date = "2026-02-06T07:14:56.532Z"; -__info__.hash = "4587c3416"; +__info__.version = "18.0.57"; +__info__.date = "2026-02-19T18:17:34.409Z"; +__info__.hash = "ce74496c9"; //# sourceMappingURL=o_spreadsheet.js.map diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml index ba837429da740a..82a6484d496ffd 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml @@ -1,9 +1,9 @@ @@ -2716,13 +2716,12 @@ -
+