From 5b16beb7e2978abd87d1edec6167c9e81c343319 Mon Sep 17 00:00:00 2001 From: Yash Pathak Date: Thu, 30 Jul 2026 20:17:46 +0000 Subject: [PATCH 001/205] [FIX] sale_timesheet: keep timesheet SOL on draft invoice deletion Deleting a draft customer invoice linked to timesheets resets their timesheet_invoice_id so the hours become invoiceable again. This write also marks the timesheets' so_line for recompute, and the re-derivation runs while the lines are no longer protected by the invoice link. When the task or project no longer resolves to a sale order item (e.g. it was unlinked after invoicing), the timesheets lose their sale order item or get reassigned to another one, so the delivered hours silently disappear from the original order line. Protect so_line during the write and drop the pending recompute: deleting an invoice must only make the hours invoiceable again, not change their allocation. Steps to reproduce: - Install Sales and Timesheets - Create a service product with invoice policy "Based on Timesheets" and "Create a task in a new project" - Create and confirm a sale order with this product - Log a timesheet on the generated task - Create the invoice (keep it in draft) - Remove the Sales Order Item from the task and from the project settings (or point them to a sale order item of another order) - Delete the draft invoice - Open the timesheet: its Sales Order Item is emptied (or replaced by the other order's item, whose delivered quantity now includes the hours sold on the original order), and the original line's delivered quantity is reset closes odoo/odoo#281558 X-original-commit: 777966e01b248d2d0fabd85b23cd9ec72288e64c Signed-off-by: Xavier Bol (xbo) --- addons/sale_timesheet/models/account_move_line.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/addons/sale_timesheet/models/account_move_line.py b/addons/sale_timesheet/models/account_move_line.py index 37c53066bf2e08..db6cc5d41bb1a5 100644 --- a/addons/sale_timesheet/models/account_move_line.py +++ b/addons/sale_timesheet/models/account_move_line.py @@ -50,5 +50,10 @@ def unlink(self): if so_line.id in sale_line_ids_per_move[timesheet_invoice.id].ids: timesheet_ids += ids - self.sudo().env['account.analytic.line'].browse(timesheet_ids).write({'timesheet_invoice_id': False}) + timesheets = self.sudo().env['account.analytic.line'].browse(timesheet_ids) + # Clearing the invoice link marks `so_line` to be recomputed, which can + # clear or reassign the allocation when the task/project sale order items + # no longer resolve. Deleting an invoice must not change what was delivered. + with self.env.protecting([timesheets._fields['so_line']], timesheets): + timesheets.write({'timesheet_invoice_id': False}) return super().unlink() From dacaad91bba8f959daf5d89a046c5a1c11e48eec Mon Sep 17 00:00:00 2001 From: Sven Fuehr Date: Fri, 14 Aug 2026 06:59:52 +0000 Subject: [PATCH 002/205] [FIX] l10n_fr_pdp: pdp info already registered action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit 004b56a5c31841bc0bf4a9f17902bbff8d0f509d we added the `l10n_fr_pdp.what_is_pdp` action as PDP version of the `account_peppol.what_is_peppol` action. But one case does not work / was not tested properly: We are registered on PDP already. In that case we just want to go back to the "move send" wizard. But the `what_is_pdp` action does not support that currently. So there is a traceback. Steps to reproduce: 1. Install `l10n_fr_pdp` 2. Activate French E-Invoicing / PDP in Demo mode 3. Create and post an invoice for a French PDP partner (e.g. just use the "FR Company") 4. Click "Send" 5. In the "move send" wizard disable the "French E-Invoicing (Demo)" option 6. Click on "Why should you use it?" in the warning 7. Click "Got it" in the window that pops up. 8. Traceback (see below) ``` Traceback (most recent call last): File "/home/odoo/src/odoo/odoo/http.py", line 2167, in _transactioning return service_model.retrying(func, env=self.env) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/odoo/service/model.py", line 157, in retrying result = func() ^^^^^^ File "/home/odoo/src/odoo/odoo/http.py", line 2134, in _serve_ir_http response = self.dispatcher.dispatch(rule.endpoint, args) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/odoo/http.py", line 2382, in dispatch result = self.request.registry['ir.http']._dispatch(endpoint) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/odoo/addons/base/models/ir_http.py", line 333, in _dispatch result = endpoint(**request.params) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/odoo/http.py", line 754, in route_wrapper result = endpoint(self, *args, **params_ok) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/addons/web/controllers/dataset.py", line 36, in call_kw return call_kw(request.env[model], method, args, kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/odoo/api.py", line 535, in call_kw result = getattr(recs, name)(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/odoo/src/odoo/addons/account_peppol/models/res_config_settings.py", line 159, in button_peppol_reregister self.ensure_one() File "/home/odoo/src/odoo/odoo/models.py", line 6277, in ensure_one raise ValueError("Expected singleton: %s" % self) ValueError: Expected singleton: res.config.settings() ``` task-None closes odoo/odoo#282557 X-original-commit: 29410f4fe7624449ce8193b315a8479a04b0c0cd Signed-off-by: Wala Gauthier (gawa) Signed-off-by: Sven Führ (svfu) --- addons/l10n_fr_pdp/static/src/js/pdp_info.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/addons/l10n_fr_pdp/static/src/js/pdp_info.js b/addons/l10n_fr_pdp/static/src/js/pdp_info.js index c2afae5ac4a649..55c8b2dd6005a4 100644 --- a/addons/l10n_fr_pdp/static/src/js/pdp_info.js +++ b/addons/l10n_fr_pdp/static/src/js/pdp_info.js @@ -16,17 +16,17 @@ class WhatIsPdp extends WhatIsPeppol { } async activate() { - const action_on_activate = this.props.action.context.action_on_activate - const action = await this.orm.call( - "res.config.settings", - "button_peppol_reregister", - [action_on_activate.context.res_config_settings_id] - ); + const action_on_activate = this.props.action.context.action_on_activate; + const action = action_on_activate.context?.res_config_settings_id + ? await this.orm.call("res.config.settings", "button_peppol_reregister", [ + action_on_activate.context.res_config_settings_id, + ]) + : action_on_activate; this.actionService.doAction({ name: action.name, type: action.type, res_model: action.res_model, - res_id: action.res_id, + ...(action?.res_id && { res_id: action.res_id }), views: [[false, action.view_mode]], target: action.target, context: action_on_activate.context, From 8a41156804c55cec83c3c05c6b0ea890414a2c6b Mon Sep 17 00:00:00 2001 From: "Quentin Colla (qucol)" Date: Wed, 12 Aug 2026 12:30:05 +0000 Subject: [PATCH 003/205] [FIX] hr_timesheet: match is_project_overtime search logic to its compute ## Issue When filtering projects using the "Timesheets >100%" filter, some projects with negative remaining hours (and with their `is_project_overtime` field set to True) won't be displayed, even though their expected hours are completed. This happens with projects which have tasks set to the "Done" or "Cancelled" state. The timesheets entries in those tasks are not taken into account when searching using the "Timesheets >100%" filter. ## Steps to reproduce 1. Install *Task Logs* (`hr_timesheet`) 2. Create a Project P (with Timehseets enabled) 3. Set the allocated hours of the project to 3:00 (3 hours) 4. Create two tasks: - T1: State "In progress", and one timesheet entry of 2:00 (2 hours) - T2: State "Done", and one timesheet entry of 2:00 (2 hours) 5. Back to the project view, set the filter to "Timesheets >100%" 6. **Project P is not shown, even though the total time spent on the project is 4 hours, completing the allocated hours set on the project.** ## Cause The `_search_is_project_overtime` method filters out the tasks in "closed" states (Done/Cancelled) when computing the amount of time spent on the project. https://github.com/odoo/odoo/blob/126b5bdd1e85771549198976f8570cd2ff167608/addons/hr_timesheet/models/project_project.py#L103-L114 This does not match with the behavior of the `_compute_is_project_overtime`, which does not take into account the state of the tasks to determine the value of the field: https://github.com/odoo/odoo/blob/126b5bdd1e85771549198976f8570cd2ff167608/addons/hr_timesheet/models/project_project.py#L85-L94 This leads to a confusing behavior, where a project can have its `is_project_overtime` field set to True, but will still not be shown when using the "Timsheets >100%", even though that filter is defined as `[("is_project_overtime", "=", True)]`. The compute method was updated by https://github.com/odoo/odoo/commit/d4252825f52a3172420dcda0ea394e42da9f8853, but the related search method was left unchanged, leading to this slight incoherence between the two methods. opw-6422173 closes odoo/odoo#282549 X-original-commit: 430311e08a49d199c6926c99697b0817f275e007 Signed-off-by: Quentin Colla (qucol) Signed-off-by: Xavier Bol (xbo) --- addons/hr_timesheet/models/project_project.py | 8 ++-- addons/hr_timesheet/tests/test_timesheet.py | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/addons/hr_timesheet/models/project_project.py b/addons/hr_timesheet/models/project_project.py index 34f99346b9528b..57af0b70d22233 100644 --- a/addons/hr_timesheet/models/project_project.py +++ b/addons/hr_timesheet/models/project_project.py @@ -86,14 +86,12 @@ def _search_is_project_overtime(self, operator, value): sql = SQL("""( SELECT Project.id FROM project_project AS Project - JOIN project_task AS Task - ON Project.id = Task.project_id + JOIN account_analytic_line AS aal + ON Project.id = aal.project_id WHERE Project.allocated_hours > 0 AND Project.allow_timesheets = TRUE - AND Task.parent_id IS NULL - AND Task.state IN ('01_in_progress', '02_changes_requested', '03_approved', '04_waiting_normal') GROUP BY Project.id - HAVING Project.allocated_hours - SUM(Task.effective_hours) < 0 + HAVING Project.allocated_hours - SUM(aal.unit_amount) < 0 )""") return [('id', operator, sql)] diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index 9aa14bd659e619..e0afae9af13cdb 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -1047,3 +1047,43 @@ def test_log_timesheet_with_user_has_two_employees_from_different_companies(self 'user_id': self.user_manager.id, }) self.assertEqual(timesheet.company_id, self.env.company) + + def test_is_project_overtime_filter(self): + self.project.allocated_hours = 3.0 + self.assertEqual(self.project.remaining_hours, 3.0) + task_1, task_2 = self.env['project.task'].create([ + { + 'name': 'Task 1', + 'project_id': self.project.id, + }, + { + 'name': 'Task 2 (done)', + 'project_id': self.project.id, + 'state': '1_done', + } + ]) + self.env['account.analytic.line'].create([ + { + 'name': 'Timesheet Task 1', + 'unit_amount': 2.0, + 'project_id': self.project.id, + 'employee_id': self.empl_employee.id, + 'task_id': task_1.id, + }, + { + 'name': 'Timesheet Task 2 (done)', + 'unit_amount': 2.0, + 'project_id': self.project.id, + 'employee_id': self.empl_employee.id, + 'task_id': task_2.id, + }, + ]) + self.assertRecordValues(self.project, [{ + 'is_project_overtime': True, + 'remaining_hours': -1.0 + }]) + self.project.flush_model() # Ensures the `project.allocated_hours` is saved in the database + self.assertIn( + self.project, + self.env['project.project'].search([('is_project_overtime', '=', True)]) + ) From f1a844f24663cfe47ecc71af367ed1c8e559aa27 Mon Sep 17 00:00:00 2001 From: Krishna Patel Date: Thu, 23 Jul 2026 12:24:22 +0530 Subject: [PATCH 004/205] [FIX] website_sale: allow zero-price products with attribute price extras Steps to reproduce: - Install `website_sale` module. - Enable `Product Variants` and `Prevent Sale of Zero Priced Product` in settings. - Create new attribute > set `Variant Creation` as `Never` and also add value with extra price. - Create a product with sales price = 0, assign the attribute, and publish it. - As a public user (incognito), try to add the product to the cart. Issue: - In terminal error `The given product does not exist therefore it cannot be added to cart` is raised. Root cause: - In `_is_add_to_cart_allowed()`[1], the method calls `_get_contextual_price()` [2] to check if the product's price is zero when `prevent_zero_price_sale` is enabled. - However, `_get_contextual_price()` is called without the no-variant attribute values in the context, so it does not account for their `price_extra`. For a product with list price as 0 and attribute with extra price, the price is incorrectly computed as 0, causing `_is_add_to_cart_allowed()` to return `False`. Solution: - Before calling `_is_add_to_cart_allowed()`, set the product's context with the no-variant attribute values via `_get_product_price_context()`, so that `_get_contextual_price()` correctly includes the price extra in its computation. [1]: https://github.com/odoo/odoo/blob/bbafbbd8950ec7123ab652851ede5479484eee26/addons/website_sale/controllers/cart.py#L117-L120 [2]: https://github.com/odoo/odoo/blob/bbafbbd8950ec7123ab652851ede5479484eee26/addons/website_sale/models/product_product.py#L149-L150 opw-6365566 closes odoo/odoo#278620 Signed-off-by: Valentin Chevalier --- addons/website_sale/controllers/cart.py | 8 ++++++ .../tests/test_website_sale_cart.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/addons/website_sale/controllers/cart.py b/addons/website_sale/controllers/cart.py index 852376907c8316..eb7081b2048f5b 100644 --- a/addons/website_sale/controllers/cart.py +++ b/addons/website_sale/controllers/cart.py @@ -114,6 +114,14 @@ def add_to_cart( quantity = int(quantity) # Do not allow float values in ecommerce by default product = request.env['product.product'].browse(product_id).exists() + if product and no_variant_attribute_value_ids: + product = product.with_context( + **product._get_product_price_context( + request.env["product.template.attribute.value"].browse( + [int(v) for v in no_variant_attribute_value_ids] + ) + ) + ) if not product or not product._is_add_to_cart_allowed(): raise UserError(_( "The given product does not exist therefore it cannot be added to cart." diff --git a/addons/website_sale/tests/test_website_sale_cart.py b/addons/website_sale/tests/test_website_sale_cart.py index f1994c577fdbd0..6c8b74bc5e4b20 100644 --- a/addons/website_sale/tests/test_website_sale_cart.py +++ b/addons/website_sale/tests/test_website_sale_cart.py @@ -134,6 +134,31 @@ def test_zero_price_product_rule(self): quantity=1, ) + def test_add_to_cart_zero_price_product_with_no_variant_extra(self): + """Ensure that a zero-priced product with a no-variant attribute + extra price can be added to cart. + """ + self.website.prevent_zero_price_sale = True + self.product.list_price = 0 + self.product.product_tmpl_id.attribute_line_ids = [ + Command.create({ + 'attribute_id': self.no_variant_attribute.id, + 'value_ids': [Command.set(self.no_variant_attribute.value_ids.ids)], + }) + ] + ptav = self.product.product_tmpl_id.attribute_line_ids.product_template_value_ids[0] + ptav.price_extra = 100 + website = self.website.with_user(self.public_user) + + with MockRequest(website.env, website=website) as request: + self.WebsiteSaleCartController.add_to_cart( + product_template_id=self.product.product_tmpl_id.id, + product_id=self.product.id, + no_variant_attribute_value_ids=ptav.ids, + ) + + self.assertEqual(request.cart.order_line.product_no_variant_attribute_value_ids, ptav) + def test_update_cart_before_payment(self): website = self.website.with_user(self.public_user) with MockRequest(website.env, website=website) as request: From 71f88b1b4d26a72c2dfec56785540e14916acf6b Mon Sep 17 00:00:00 2001 From: krip-odoo Date: Fri, 29 May 2026 05:14:25 +0000 Subject: [PATCH 005/205] [FIX] product: display uom name properly in catalog view Steps to produce; - Install `sales` module. - Go to Settings and enable `Units of Measure and Packaging`. - Set a long name for `units` UoM. - Create a Sale Order and add a product via the catalog. Issue: - Long UoM names are not fully visible in the catalog view. Root cause: - The outer `
` has `d-flex` but lacks `w-100`, causing it to overflow its container. Solution: - Added `w-100` to the outer `
` to prevent overflow. - Adjusted the quantity selector layout for better visibility. opw-6253382 closes odoo/odoo#282551 X-original-commit: c12323ab1efebc1f66fb181d4ff31dd18b41c2e1 Related: odoo/enterprise#127984 Signed-off-by: Krishna Arvindkumar Patel (krip) --- .../static/src/product_catalog/order_line/order_line.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/product/static/src/product_catalog/order_line/order_line.xml b/addons/product/static/src/product_catalog/order_line/order_line.xml index 80e569b079836c..42afe68bf99ad9 100644 --- a/addons/product/static/src/product_catalog/order_line/order_line.xml +++ b/addons/product/static/src/product_catalog/order_line/order_line.xml @@ -24,7 +24,7 @@
-
+
+ Date: Mon, 10 Aug 2026 07:57:01 +0000 Subject: [PATCH 011/205] [FIX] html_editor: don't distribute table color to nested table cells Problem: When a `table` with a `color`/`backgroundColor` contains a nested `table`, `distributeTableColorsToAllCells` propagates the outer table's color to every `td` in the subtree, including cells belonging to the inner table. The inner table's own color is then discarded since its `td`s already have a value. Cause: `table.querySelectorAll("td")` returns every `td` in the entire subtree, not just the table's own direct cells. Solution: Scope the selected `td`s to `td.closest("table") === table`, so a table's color is only distributed to its own cells. Steps to reproduce: 1. Add a `background-color` to an outer `table`. 2. Nest a `table` with a different `background-color` inside one of its cells. 3. Load/normalize the content in the editor. 4. Observe both tables' cells carry the outer table's color. opw-6438972 closes odoo/odoo#281850 X-original-commit: 111042266444a3e08a1bc5ce31a11c5600b6d1d3 Signed-off-by: David Monjoie (dmo) Signed-off-by: Walid Sahli (wasa) --- .../static/src/main/table/table_plugin.js | 4 ++- .../static/tests/normalize.test.js | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/addons/html_editor/static/src/main/table/table_plugin.js b/addons/html_editor/static/src/main/table/table_plugin.js index f7d5de545ed0fe..5da2b8b8cfc62f 100644 --- a/addons/html_editor/static/src/main/table/table_plugin.js +++ b/addons/html_editor/static/src/main/table/table_plugin.js @@ -233,7 +233,9 @@ export class TablePlugin extends Plugin { [...root.querySelectorAll("table")] .filter((table) => table.style["color"] || table.style["backgroundColor"]) .forEach((table) => { - const tds = table.querySelectorAll("td"); + const tds = [...table.querySelectorAll("td")].filter( + (td) => closestElement(td, "table") === table + ); for (const td of tds) { td.style["color"] = td.style["color"] || table.style["color"]; td.style["backgroundColor"] = diff --git a/addons/html_editor/static/tests/normalize.test.js b/addons/html_editor/static/tests/normalize.test.js index e28dd622782bfe..5522b43cbdb74e 100644 --- a/addons/html_editor/static/tests/normalize.test.js +++ b/addons/html_editor/static/tests/normalize.test.js @@ -58,3 +58,34 @@ test("Should properly add feffs around icons", async () => { contentBeforeEdit: `
\ufeff\u200b\ufeff
`, }); }); + +test("should not distribute table color to tds of a nested table", async () => { + await testEditor({ + contentBefore: unformat(` + + + +
ab
+ + +
cd
+
+ `), + contentBeforeEdit: unformat(` +


+ + + + + +
ab
+ + + + +
cd
+
+


+ `), + }); +}); From ffbbfeaebd92b833e074ecdd64f774d57551f071 Mon Sep 17 00:00:00 2001 From: "Achraf (abz)" Date: Fri, 3 Jul 2026 07:47:54 +0000 Subject: [PATCH 012/205] [FIX] mail: Correct activity count on initial load Steps: - Install sale_management - Make sure you have 100 quotations with 1 activity each - Open activity view - Default pager displays `1-100/100` - Activity count display `To-do 80` ActivityController uses `useModel` which passes the raw `component.props` to `model.load()`, including the limit from `ir.actions.act_window` (default 80). This value ended up in `fetchActivityData` via `params.limit || this.initialLimit`, overriding `ActivityModel.DEFAULT_LIMIT` (100). The records list was not affected because `RelationalModel._getNextConfig` never reads `params.limit` (limit is not a `SEARCH_KEY`), so it always loaded 100 records correctly. But `fetchActivityData` used 80, causing a mismatch between the records shown and the activity counts in the column headers. ```js export const SEARCH_KEYS = ["comparison", "context", "domain", "groupBy", "orderBy"]; ``` The fix strips `params.limit` in `ActivityModel.load()` before passing params to `fetchActivityData`, so it falls back to `this.initialLimit (100)`. The pager `onUpdate` handler calls `fetchActivityData` directly with its own `params.limit` and is not affected. However, `ActivityController` never forwards `limit` to the model. This is why we always have `ActivityModel.DEFAULT_LIMIT (100)` without taking into account actions's limit. To fix this we have to add the limit via `this.props.limit`, as `ListController`. `useModelWithSampleData` already had the correct behavior by calling `model.load(getSearchParams(props))` which filters out non-search params like limit. In 19.0 useModel was updated to do the same, so the issue does not exist there. Link to 19.0 fix: https://github.com/odoo/odoo/pull/211697 opw-6281125 closes odoo/odoo#281163 X-original-commit: ba372ded9ff2f71ae5cf50a4783b49569bb6cbca Signed-off-by: Renaud Thiry (reth) Signed-off-by: Achraf Ben Azzouz (abz) --- .../src/views/web/activity/activity_controller.js | 1 + .../src/views/web/activity/activity_model.js | 2 ++ addons/test_mail/static/tests/activity.test.js | 14 ++++++++++++++ 3 files changed, 17 insertions(+) diff --git a/addons/mail/static/src/views/web/activity/activity_controller.js b/addons/mail/static/src/views/web/activity/activity_controller.js index e7759ba47fd7b7..367ddf3bc6b2a0 100644 --- a/addons/mail/static/src/views/web/activity/activity_controller.js +++ b/addons/mail/static/src/views/web/activity/activity_controller.js @@ -61,6 +61,7 @@ export class ActivityController extends Component { resModel, fields, }, + limit: this.props.limit, }; } diff --git a/addons/mail/static/src/views/web/activity/activity_model.js b/addons/mail/static/src/views/web/activity/activity_model.js index 0ebc2c2e61d8fc..62f50e2722a47e 100644 --- a/addons/mail/static/src/views/web/activity/activity_model.js +++ b/addons/mail/static/src/views/web/activity/activity_model.js @@ -11,6 +11,8 @@ export class ActivityModel extends RelationalModel { if (params && "groupBy" in params) { params.groupBy = []; } + // avoid mismatch between records count (initialLimit) and activity count (params.limit) + delete params.limit; await Promise.all([this.fetchActivityData(params), super.load(params)]); } diff --git a/addons/test_mail/static/tests/activity.test.js b/addons/test_mail/static/tests/activity.test.js index cbaaeb3676aaa5..44b993085ab051 100644 --- a/addons/test_mail/static/tests/activity.test.js +++ b/addons/test_mail/static/tests/activity.test.js @@ -678,6 +678,20 @@ test("activity view: group_by in the action has no effect", async () => { await waitForSteps(["get_activity_data"]); }); +test("activity view: fetchActivityData uses the action limit when provided", async () => { + onRpc("get_activity_data", ({ kwargs }) => { + asyncStep("get_activity_data:limit:" + kwargs.limit); + }); + await start(); + registerArchs(archs); + await openView({ + res_model: "mail.test.activity", + views: [[false, "activity"]], + limit: 80, + }); + await waitForSteps(["get_activity_data:limit:80"]); +}); + test("activity view: search more to schedule an activity for a record of a respecting model", async () => { const mailTestActivityId1 = pyEnv["mail.test.activity"].create({ name: "MailTestActivity 3", From ccce9fcc79edcfb1f310b49a16de8235d987b74b Mon Sep 17 00:00:00 2001 From: pkri-odoo Date: Mon, 29 Jun 2026 09:37:23 +0000 Subject: [PATCH 013/205] [FIX] l10n_din5008: missing self-billing header on vendor bills ***Steps to reproduce*:** * Install `l10n_din5008` module. * Navigate to **Accounting** and create a new Purchase Journal. * Enable **Self Billing** for the journal. * Create a vendor bill. * Print the vendor bill. ***Observed behavior*:** * The printed document displays the regular vendor bill header instead of the self-billing header. ***Cause*:** * The condition required to display the self-billing header was missing from the report template if self-billing is enabled. ***Fix*:** * Add the missing condition so that the self-billing header is displayed when **Self Billing** is enabled on the journal. opw-6281066 closes odoo/odoo#282664 X-original-commit: bf4678472a8ffea33a8293c63bc669d4fe1a7299 Signed-off-by: Paolo Gatti (pgi) Signed-off-by: Krishna Pathak (pkri) --- addons/l10n_din5008/report/din5008_report.xml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/addons/l10n_din5008/report/din5008_report.xml b/addons/l10n_din5008/report/din5008_report.xml index 94eb49509ea4dd..cd8537bcf17e62 100644 --- a/addons/l10n_din5008/report/din5008_report.xml +++ b/addons/l10n_din5008/report/din5008_report.xml @@ -270,8 +270,20 @@ Cancelled Invoice Credit Note - Vendor Credit Note - Vendor Bill + + Vendor Credit Note + + + Self Billing Credit Note + + + Vendor Bill + + + Self Billing + From 70a9e22bb85017207a1fe38b2f0dc7c292a1856f Mon Sep 17 00:00:00 2001 From: neloduka-sobe Date: Fri, 26 Jun 2026 11:29:47 -0400 Subject: [PATCH 014/205] [FIX] auth_timeout: validate credential type in _check_identity `IrHttp._check_identity()` forwarded the client-supplied `credential` dict straight to `_check_credentials()` without checking that `credential['type']` was one of the user's actually-enabled authentication methods (`user._get_auth_methods()`). An authenticated user can send a credential of a type they don't have enabled - e.g. `{"type": "totp", "token": "000000"}` against an account without TOTP configured - which raises an unhandled `TypeError` deep inside `base64.b32decode(False)` instead of a clean authentication failure: curl -s -b "$JAR" "$BASE/auth-timeout/session/check-identity" \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"call","params":{"type":"totp","token":"000000"},"id":null}' `_check_identity()` validates `credential['type']` against the user's enabled auth methods before forwarding it to `_check_credentials()`. Any mismatch (e.g. a TOTP credential sent to an account without TOTP enabled, or any unrecognized type) now raises a clean `AccessDenied` (403) instead of an unhandled `TypeError`. --- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr Part-of: odoo/odoo#272563 Signed-off-by: Julien Castiaux (juc) --- addons/auth_timeout/models/ir_http.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/addons/auth_timeout/models/ir_http.py b/addons/auth_timeout/models/ir_http.py index 93818a3849048c..3a96dd1eda8454 100644 --- a/addons/auth_timeout/models/ir_http.py +++ b/addons/auth_timeout/models/ir_http.py @@ -3,6 +3,7 @@ import time from odoo import api, models +from odoo.exceptions import AccessDenied from odoo.http import request, root, SessionExpiredException @@ -88,6 +89,9 @@ def _check_identity(cls, credential): - {"mfa": True, "auth_methods": [...]} if a second factor is required, - None if re-authentication is complete. + :raises AccessDenied: if the credential type is not one of the user's enabled + authentication methods, or if the credential itself is invalid. + :rtype: dict or None """ check_identity = cls._must_check_identity() or {} @@ -99,6 +103,9 @@ def _check_identity(cls, credential): auth_methods.remove(first_fa) return {"user_id": user.id, "login": user.login, "auth_methods": auth_methods} + if credential.get("type") not in auth_methods: + raise AccessDenied() + if credential.get("type") in ("totp", "totp_mail"): credential["token"] = int(re.sub(r"\s", "", credential["token"])) From 39497a4f6c82ff39a48dd1b4c96bbb10b9b304eb Mon Sep 17 00:00:00 2001 From: neloduka-sobe Date: Fri, 26 Jun 2026 14:26:58 -0400 Subject: [PATCH 015/205] [CLA] neloduka-sobe closes odoo/odoo#272563 Signed-off-by: Julien Castiaux (juc) --- doc/cla/individual/neloduka-sobe.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 doc/cla/individual/neloduka-sobe.md diff --git a/doc/cla/individual/neloduka-sobe.md b/doc/cla/individual/neloduka-sobe.md new file mode 100644 index 00000000000000..0870c31130915b --- /dev/null +++ b/doc/cla/individual/neloduka-sobe.md @@ -0,0 +1,11 @@ +Canada, 2026-06-26 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Borys Łangowicz nelodukasobe@gmail.com https://github.com/neloduka-sobe \ No newline at end of file From ca6a579d23798cd04a6023e6937d2bab8292a5c7 Mon Sep 17 00:00:00 2001 From: "David Monnom (moda)" Date: Tue, 11 Aug 2026 18:26:26 +0530 Subject: [PATCH 016/205] [FIX] pos_self_order: fix combo hierarchy check in self-order Be sure that combo product of the current line belong to its combo parent line. closes odoo/odoo#281741 Signed-off-by: Manu Vaillant (manv) --- addons/pos_self_order/models/pos_order.py | 81 ++++- .../tests/test_self_order_combo.py | 301 ++++++++++++++++++ .../tests/test_self_order_controller.py | 32 ++ 3 files changed, 412 insertions(+), 2 deletions(-) diff --git a/addons/pos_self_order/models/pos_order.py b/addons/pos_self_order/models/pos_order.py index 1f7318f0953e85..7bcd2d602f8729 100644 --- a/addons/pos_self_order/models/pos_order.py +++ b/addons/pos_self_order/models/pos_order.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging +import math from odoo import Command, models, fields, api, _ from odoo.exceptions import UserError @@ -141,13 +142,37 @@ def _check_pos_order_lines(self, pos_config, order, line, fiscal_position_id): command = Command.CREATE if line[0] == Command.CREATE else Command.UPDATE id_to_use = line[1] if line[0] == Command.UPDATE else 0 + # An update must target a line of the order being synced. Without this an arbitrary + # line id would be written, and reparented to this order through the order_id below. + if command == Command.UPDATE and id_to_use not in existing_lines.ids: + return [] + + # A public payload must carry finite, strictly positive quantities. A negative or + # non-finite quantity is a way to zero a combo total (see _compute_combo_price): + # returns, if ever needed, require a separate authorized refund flow. + qty = line_data.get('qty') + if command == Command.CREATE or qty is not None: + if isinstance(qty, bool) or not isinstance(qty, (int, float)) or not math.isfinite(qty) or qty <= 0: + raise UserError(_("Invalid quantity")) + + # Attribute extras are priced server-side (their price_extra is summed into the line + # price, see _compute_combo_price). The payload must therefore not attach an attribute + # that does not belong to the ordered product's template, otherwise an unrelated + # possibly negative, attribute extra could be applied to this line. + requested_attr_ids = [id for id in line_data.get('attribute_value_ids', []) if isinstance(id, int)] + attribute_values = pos_config.env['product.template.attribute.value'].browse(requested_attr_ids).exists() + if set(attribute_values.ids) != set(requested_attr_ids) or any( + ptav.product_tmpl_id != product.product_tmpl_id for ptav in attribute_values + ): + raise UserError(_("Invalid product attribute")) + return [command, id_to_use, { 'combo_id': line_data.get('combo_id'), 'product_id': line_data.get('product_id'), 'tax_ids': tax_ids.ids, - 'attribute_value_ids': [id for id in line_data.get('attribute_value_ids', []) if isinstance(id, int)], + 'attribute_value_ids': attribute_values.ids, 'price_unit': line_data.get('price_unit'), - 'qty': line_data.get('qty'), + 'qty': qty, 'price_subtotal': 0.0, # always recomputed server-side by recompute_prices(). 'price_subtotal_incl': 0.0, # always recomputed server-side by recompute_prices(). 'price_extra': line_data.get('price_extra'), @@ -173,6 +198,12 @@ def _check_pos_order(self, pos_config, order, device_type, table=None): if not preset_id and pos_config.use_presets: raise UserError(_("Invalid preset")) + if preset_id and not preset_id.available_in_self and preset_id != pos_config.default_preset_id: + raise UserError(_("Preset is not available in self-ordering")) + + if preset_id and not preset_id in pos_config.available_preset_ids: + raise UserError(_("Preset is not available in this configuration")) + existing_order = pos_config.env['pos.order']._get_open_order(order) if not existing_order.exists(): pos_reference, tracking_number = pos_config._get_next_order_refs() @@ -196,6 +227,14 @@ def _check_pos_order(self, pos_config, order, device_type, table=None): partner_id = order.get('partner_id') partner = pos_config.env['res.partner'].browse(partner_id) if partner_id else None + if order.get('id') and order.get('uuid') and isinstance(order['id'], int): + exists = pos_config.env['pos.order'].search_count([ + ('id', '=', order['id']), + ('uuid', '=', order['uuid']), + ]) + if not exists: + raise UserError(_("The order ID isn't linked to the order UUID. This is a sign of a tampered payload.")) + return { 'id': order.get('id'), 'table_stand_number': order.get('table_stand_number'), @@ -236,8 +275,46 @@ def _check_pos_order(self, pos_config, order, device_type, table=None): 'relations_uuid_mapping': order.get('relations_uuid_mapping', {}), } + def _check_combo_lines(self): + """ + Refuse an order whose combo hierarchy has been tampered with. + + A combo child is the only line whose price is derived from another line instead of + from its own product (see _compute_combo_price), so a parent or a combo item chosen + freely from the public self-order route is a way to get any product for the price of + a combo item. + """ + for line in self.lines: + parent = line.combo_parent_id + combo_item = line.combo_item_id + children = line.combo_line_ids + + if not parent and not combo_item and not children: + continue + + # Child -> parent edge: a combo child must point up to a valid parent and item of + # this order. This rejects a child whose parent belongs to another order, or a child + # sold through an unrelated combo item. + if parent or combo_item: + if ( + parent.order_id != self + or parent.product_id.type != 'combo' + or combo_item.combo_id not in parent.product_id.combo_ids + or combo_item.product_id != line.product_id + ): + raise UserError(_("Invalid combo line")) + + # Parent -> child edge: every line reachable through a parent's inverse collection + # must belong to this order and point back to it. The upward check alone is + # one-directional; without this a foreign child injected into combo_line_ids would + # never be validated + for child in children: + if child.order_id != self or child.combo_parent_id != line: + raise UserError(_("Invalid combo line")) + def recompute_prices(self): self.ensure_one() + self._check_combo_lines() company = self.company_id for line in self.lines: diff --git a/addons/pos_self_order/tests/test_self_order_combo.py b/addons/pos_self_order/tests/test_self_order_combo.py index 208384020fb224..4c86466ae9f081 100644 --- a/addons/pos_self_order/tests/test_self_order_combo.py +++ b/addons/pos_self_order/tests/test_self_order_combo.py @@ -7,6 +7,7 @@ from odoo.addons.pos_self_order.tests.self_order_common_test import SelfOrderCommonTest from odoo.addons.point_of_sale.tests.common_setup_methods import setup_product_combo_items from odoo.fields import Command +from odoo.tools import mute_logger @odoo.tests.tagged("post_install", "-at_install") @@ -636,3 +637,303 @@ def test_combo_extra_price_is_not_trusted_from_the_frontend(self): self.assertNotEqual(order.state, 'paid', msg="An unpaid combo order must not be accepted as paid") self.assertFalse(order.payment_ids) + + def _setup_kiosk_session(self): + self.pos_config.write({ + 'self_ordering_mode': 'kiosk', + 'available_preset_ids': [(5, 0)], + 'use_presets': False, + }) + self.pos_config.with_user(self.pos_user).open_ui() + self.pos_config.current_session_id.set_opening_control(0, "") + + def _post_self_order(self, lines, relations_uuid_mapping=None): + """Send a raw order payload on the public self-order endpoint.""" + order_uuid = str(uuid4()) + response = self.url_open( + "/pos-self-order/process-order/kiosk", + headers={"Content-Type": "application/json"}, + data=json.dumps({ + "jsonrpc": "2.0", + "method": "call", + "id": str(uuid4()), + "params": { + "access_token": self.pos_config.access_token, + "table_identifier": None, + "order": { + "id": None, + "session_id": self.pos_config.current_session_id.id, + "state": "draft", + "preset_id": False, + "amount_total": 0, + "amount_tax": 0, + "amount_paid": 0, + "amount_return": 0, + "uuid": order_uuid, + "lines": lines, + "relations_uuid_mapping": relations_uuid_mapping or {}, + }, + }, + }), + ) + return response.json(), order_uuid + + def _make_expensive_combo(self): + expensive_product = self.env['product.product'].create({ + 'available_in_pos': True, + 'list_price': 100.0, + 'name': 'Expensive Product', + 'taxes_id': False, + }) + combo = self.env['product.combo'].create({ + 'name': 'Expensive Combo', + 'qty_free': 1, + 'qty_max': 1, + 'combo_item_ids': [ + Command.create({'product_id': expensive_product.id, 'extra_price': 0}), + ], + }) + combo_product = self.env['product.product'].create({ + 'available_in_pos': True, + 'list_price': 1.0, + 'name': 'Cheap Combo Product', + 'type': 'combo', + 'combo_ids': [Command.set([combo.id])], + 'taxes_id': False, + }) + return expensive_product, combo, combo_product + + @mute_logger('odoo.http') + def test_combo_parent_from_another_order_is_refused(self): + """ + A combo child is priced from its parent line. If the parent belongs to another order it + is never repriced with the child, so the child would keep the zero price of the payload: + such an order must be refused. + """ + self._setup_kiosk_session() + expensive_product, combo, _ = self._make_expensive_combo() + + result, _ = self._post_self_order([[0, 0, { + "uuid": str(uuid4()), "product_id": self.cola.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }]]) + first_order = self.env['pos.order'].browse(result['result']['pos.order'][0]['id']) + parent_line_id = result['result']['pos.order.line'][0]['id'] + + result, order_uuid = self._post_self_order([[0, 0, { + "uuid": str(uuid4()), "product_id": expensive_product.id, "qty": 1, + "combo_parent_id": parent_line_id, + "combo_item_id": combo.combo_item_ids.id, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }]]) + + self.assertIn('error', result, "An order whose combo parent belongs to another order must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + self.assertFalse(first_order.lines.combo_line_ids, + msg="No combo relation must be created towards the line of another order") + + @mute_logger('odoo.http') + def test_forged_combo_composition_is_refused(self): + """ + A single order made of a cheap non-combo parent and a child selling an expensive product + through an unrelated combo item would have the child priced from the parent instead of + from its own product: such an order must be refused. + """ + self._setup_kiosk_session() + expensive_product, combo, _ = self._make_expensive_combo() + parent_uuid, child_uuid = str(uuid4()), str(uuid4()) + + result, order_uuid = self._post_self_order([ + [0, 0, { + "uuid": parent_uuid, "product_id": self.free.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + [0, 0, { + "uuid": child_uuid, "product_id": expensive_product.id, "qty": 1, + "combo_item_id": combo.combo_item_ids.id, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + ], {"pos.order.line": {child_uuid: {"combo_parent_id": parent_uuid}}}) + + self.assertIn('error', result, "An order with a forged combo composition must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + + def test_valid_combo_order_is_still_accepted(self): + """The check on the combo hierarchy must leave a legitimate combo order untouched.""" + self._setup_kiosk_session() + _, combo, combo_product = self._make_expensive_combo() + order = self._process_zeroed_combo_order(combo_product, combo.combo_item_ids) + self.assertAlmostEqual(order.amount_total, combo_product.lst_price, + msg="A valid combo order must be priced from the combo product") + self.assertEqual(order.lines.filtered(lambda line: line.combo_parent_id).combo_parent_id.product_id, + combo_product, msg="The combo child must stay linked to its parent line") + + def _create_external_combo_child(self, combo_product, combo_item): + """Create a legitimate combo (parent + child) in its own order and return the child + line together with the uuid it was created with, so another request can try to steal it.""" + parent_uuid, child_uuid = str(uuid4()), str(uuid4()) + result, _ = self._post_self_order([ + [0, 0, { + "uuid": parent_uuid, "product_id": combo_product.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + [0, 0, { + "uuid": child_uuid, "product_id": combo_item.product_id.id, + "combo_item_id": combo_item.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + ], {"pos.order.line": {child_uuid: {"combo_parent_id": parent_uuid}}}) + order = self.env['pos.order'].browse(result['result']['pos.order'][0]['id']) + child_line = order.lines.filtered(lambda line: line.combo_parent_id) + return order, child_uuid, child_line + + @mute_logger('odoo.http') + def test_foreign_negative_attribute_on_combo_child_is_refused(self): + """ + Attribute extras are summed into the combo child price server-side. An attribute that + belongs to an unrelated product (e.g. a negative-price one) must not be accepted on the + child line, otherwise it can zero an otherwise valid combo child. + """ + self._setup_kiosk_session() + expensive_product, combo, combo_product = self._make_expensive_combo() + + # A negative-price attribute value that belongs to a *different* product. + discount_attribute = self.env['product.attribute'].create({ + 'name': 'Rogue Discount', + 'create_variant': 'no_variant', + 'value_ids': [Command.create({'name': 'Minus'})], + }) + foreign_template = self.env['product.template'].create({ + 'name': 'Foreign Product', + 'available_in_pos': True, + 'list_price': 0.0, + 'attribute_line_ids': [Command.create({ + 'attribute_id': discount_attribute.id, + 'value_ids': [Command.set(discount_attribute.value_ids.ids)], + })], + }) + foreign_ptav = foreign_template.attribute_line_ids.product_template_value_ids + foreign_ptav.price_extra = -100.0 + + parent_uuid, child_uuid = str(uuid4()), str(uuid4()) + result, order_uuid = self._post_self_order([ + [0, 0, { + "uuid": parent_uuid, "product_id": combo_product.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + [0, 0, { + "uuid": child_uuid, "product_id": expensive_product.id, + "combo_item_id": combo.combo_item_ids.id, "qty": 1, + "attribute_value_ids": [foreign_ptav.id], + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + ], {"pos.order.line": {child_uuid: {"combo_parent_id": parent_uuid}}}) + + self.assertIn('error', result, + "A combo child carrying an attribute of an unrelated product must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + + @mute_logger('odoo.http') + def test_negative_combo_parent_quantity_is_refused(self): + """A negative (or non-finite) quantity on the combo parent zeroes the combo total and + must be refused: public self-order quantities have to be finite and strictly positive.""" + self._setup_kiosk_session() + expensive_product, combo, combo_product = self._make_expensive_combo() + parent_uuid, child_uuid = str(uuid4()), str(uuid4()) + + result, order_uuid = self._post_self_order([ + [0, 0, { + "uuid": parent_uuid, "product_id": combo_product.id, "qty": -1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + [0, 0, { + "uuid": child_uuid, "product_id": expensive_product.id, + "combo_item_id": combo.combo_item_ids.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }], + ], {"pos.order.line": {child_uuid: {"combo_parent_id": parent_uuid}}}) + + self.assertIn('error', result, "A combo order with a negative parent quantity must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + + @mute_logger('odoo.http') + def test_zero_and_fractional_quantities_are_refused(self): + """Zero, and non-finite quantities must be rejected on the public route.""" + self._setup_kiosk_session() + for bad_qty in (0, "1", None): + with self.subTest(qty=bad_qty): + result, order_uuid = self._post_self_order([[0, 0, { + "uuid": str(uuid4()), "product_id": self.cola.id, "qty": bad_qty, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }]]) + self.assertIn('error', result, "A non-positive/invalid quantity must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + + @mute_logger('odoo.http') + def test_external_child_stolen_through_relations_uuid_mapping_is_refused(self): + """ + relations_uuid_mapping is applied generically under sudo() by the base sync_from_ui. A + public payload that uses it to re-parent a combo child of another order onto a line of + its own puts a foreign line in the new parent's inverse collection: _check_combo_lines + validates that downward edge and must refuse the whole request. + """ + self._setup_kiosk_session() + _, combo, combo_product = self._make_expensive_combo() + + first_order, external_child_uuid, external_child = self._create_external_combo_child( + combo_product, combo.combo_item_ids) + original_parent = external_child.combo_parent_id + + new_parent_uuid = str(uuid4()) + result, order_uuid = self._post_self_order( + [[0, 0, { + "uuid": new_parent_uuid, "product_id": combo_product.id, "qty": 1, + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }]], + {"pos.order.line": {external_child_uuid: {"combo_parent_id": new_parent_uuid}}}, + ) + + self.assertIn('error', result, + "Re-parenting a foreign combo child through relations_uuid_mapping must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + external_child.invalidate_recordset() + self.assertEqual(external_child.combo_parent_id, original_parent, + msg="The rolled-back request must leave the external combo child untouched") + self.assertEqual(external_child.order_id, first_order, + msg="The external combo child must stay on its original order") + + @mute_logger('odoo.http') + def test_external_child_stolen_through_combo_line_ids_is_refused(self): + """ + A raw integer combo_line_ids referencing an existing child line of another order puts + that foreign line in the new parent's inverse collection. _check_combo_lines validates + that downward edge and must refuse the whole request. + """ + self._setup_kiosk_session() + _, combo, combo_product = self._make_expensive_combo() + + first_order, _, external_child = self._create_external_combo_child( + combo_product, combo.combo_item_ids) + original_parent = external_child.combo_parent_id + + result, order_uuid = self._post_self_order([[0, 0, { + "uuid": str(uuid4()), "product_id": combo_product.id, "qty": 1, + "combo_line_ids": [external_child.id], + "price_unit": 0, "price_subtotal": 0, "price_subtotal_incl": 0, + }]]) + + self.assertIn('error', result, + "Re-parenting a foreign combo child through raw combo_line_ids must be refused") + self.assertFalse(self.env['pos.order'].search([('uuid', '=', order_uuid)]), + msg="The refused order must not be created") + external_child.invalidate_recordset() + self.assertEqual(external_child.combo_parent_id, original_parent, + msg="The rolled-back request must leave the external combo child untouched") + self.assertEqual(external_child.order_id, first_order, + msg="The external combo child must stay on its original order") diff --git a/addons/pos_self_order/tests/test_self_order_controller.py b/addons/pos_self_order/tests/test_self_order_controller.py index 4b3346f2194efc..76c56b11335fb2 100644 --- a/addons/pos_self_order/tests/test_self_order_controller.py +++ b/addons/pos_self_order/tests/test_self_order_controller.py @@ -3,6 +3,7 @@ import json import odoo.tests from datetime import timedelta +from odoo import Command from odoo.addons.pos_self_order.tests.self_order_common_test import SelfOrderCommonTest @@ -314,3 +315,34 @@ def test_order_sanatization(self): data = self.env['pos.order']._check_pos_order(self.pos_config, params, 'mobile') self.assertFalse('account_move' in data) # Do not add it back, if needed contact the PoS team. self.assertFalse('access_token' in data) # Do not add it back, if needed contact the PoS team. + + def test_foreign_line_update_is_dropped(self): + self.pos_config.write({ + 'self_ordering_mode': 'mobile', + 'self_ordering_pay_after': 'each', + }) + self.pos_config.with_user(self.pos_user).open_ui() + self.pos_config.current_session_id.set_opening_control(0, '') + + victim_order, _ = self.create_backend_pos_order({ + 'order_data': {'table_id': self.pos_table_1.id}, + 'line_data': [{'qty': 1, 'price_unit': 1.0, 'product_id': self.cola.id}], + }) + victim_line = victim_order.lines[0] + + params = { + 'uuid': '61f8181c-18e1-4b83-8a7b-21224750fe2f', # attacker order, unrelated to victim_order + 'state': 'draft', + 'preset_id': self.in_preset.id, + 'session_id': self.pos_config.current_session_id.id, + 'lines': [[Command.UPDATE, victim_line.id, { + 'product_id': self.cola.id, 'qty': 10, + 'price_unit': self.cola.lst_price, + }]], + } + data = self.env['pos.order']._check_pos_order(self.pos_config, params, 'mobile') + + # The update targets a line of another order: it must not reach sync_from_ui. + self.assertFalse(data['lines']) + self.assertEqual(victim_line.qty, 1) + self.assertEqual(victim_line.order_id, victim_order) From acadac672fcf5dc48bed15bac582aa6ecd44aeee Mon Sep 17 00:00:00 2001 From: hisi-odoo Date: Tue, 11 Aug 2026 18:40:27 +0530 Subject: [PATCH 017/205] [IMP] account: improve context in FEC import validation errors Some generic validation errors raised by core account models lack enough context to identify which record caused the issue, making FEC imports harder to troubleshoot. This commit improves the two error cases identified for this use case: - `account.account._check_account_code` now includes the invalid account code in the error message. - `account.move.write` now includes the move name/reference with showing technical field names when attempting to modify read-only fields on posted entries. Although motivated by FEC import, these are generic core validations, so the improvements are implemented at the source to benefit all callers rather than only the FEC import flow. Enrichment is scoped to the two cases above; other constraints/errors across these models are intentionally left unchanged for now, since editing core error messages more broadly should be done deliberately and on a case-by-case basis, not as a blanket rewrite. task-5346068 closes odoo/odoo#281746 Signed-off-by: Maximilien La Barre (malb) --- addons/account/models/account_account.py | 5 +++-- addons/account/models/account_move.py | 6 +++++- addons/account/tests/test_account_move_entry.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/addons/account/models/account_account.py b/addons/account/models/account_account.py index 606f1197239acd..352c0e16150d51 100644 --- a/addons/account/models/account_account.py +++ b/addons/account/models/account_account.py @@ -311,8 +311,9 @@ def _check_account_type_sales_purchase_journal(self): def _check_account_code(self): for account in self: if account.code and not re.match(ACCOUNT_CODE_REGEX, account.code): - raise ValidationError(_( - "The account code can only contain alphanumeric characters and dots." + raise ValidationError(self.env._( + "The account code can only contain alphanumeric characters and dots. (account code: %s)", + account.code, )) @api.constrains('account_type') diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 2477df4b145fe2..a4c5132c3b64c7 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -3947,7 +3947,11 @@ def write(self, vals): 'invoice_payment_term_id', 'currency_id', 'fiscal_position_id', 'invoice_cash_rounding_id') readonly_fields = [val for val in vals if val in unmodifiable_fields] if not self.env.context.get('skip_readonly_check') and move_state == "posted" and readonly_fields: - raise UserError(_("You cannot modify the following readonly fields on a posted move: %s", ', '.join(readonly_fields))) + raise UserError(self.env._( + "You cannot modify the following readonly fields on the posted move %(move)s: %(fields)s", + move=move.name or move.ref or move.id, + fields=', '.join(readonly_fields), + )) if move.journal_id.sequence_override_regex and vals.get('name') and vals['name'] != '/' and not re.match(move.journal_id.sequence_override_regex, vals['name']): if not self.env.user.has_group('account.group_account_manager'): diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index a20567b78d71cf..0adf2a80c1b2d2 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -315,7 +315,7 @@ def test_modify_posted_move_readonly_fields(self): readonly_fields = ('invoice_line_ids', 'line_ids', 'invoice_date', 'date', 'partner_id', 'invoice_payment_term_id', 'currency_id', 'fiscal_position_id', 'invoice_cash_rounding_id') for field in readonly_fields: - with self.assertRaisesRegex(UserError, "You cannot modify the following readonly fields on a posted move"): + with self.assertRaisesRegex(UserError, "You cannot modify the following readonly fields on the posted move %s" % self.test_move.name): self.test_move.write({field: False}) def test_misc_move_onchange(self): From 270d28b67acc1480f7a66fe852fb8f82f5c039a7 Mon Sep 17 00:00:00 2001 From: paan-odoo Date: Mon, 17 Aug 2026 12:17:22 +0530 Subject: [PATCH 018/205] [FIX] pos_self_order: skip payment page for zero amount self-order *: pos_online_payment_self_order Before this commit: - Self-orders with a total amount of zero are still redirected to the payment page, which was unnecessary. After this commit: - The payment step is now skipped for zero-amount self-orders, providing a smoother checkout flow. task-5106938 closes odoo/odoo#230218 Signed-off-by: David Monnom (moda) --- .../unit/scenario/pos_self_order_flow.test.js | 37 +++++++++ .../components/order_widget/order_widget.js | 5 +- .../app/pages/payment_page/payment_page.xml | 2 +- .../src/app/services/self_order_service.js | 6 +- .../unit/components/order_widget.test.js | 5 ++ .../data/pos_self_order_custom_link.data.js | 13 +++- .../static/tests/unit/ui_utils.js | 35 +++++++++ .../pos_self_order/static/tests/unit/utils.js | 78 ++++++++++++++++++- 8 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 addons/pos_online_payment_self_order/static/tests/unit/scenario/pos_self_order_flow.test.js create mode 100644 addons/pos_self_order/static/tests/unit/ui_utils.js diff --git a/addons/pos_online_payment_self_order/static/tests/unit/scenario/pos_self_order_flow.test.js b/addons/pos_online_payment_self_order/static/tests/unit/scenario/pos_self_order_flow.test.js new file mode 100644 index 00000000000000..fc67aa3fe45e77 --- /dev/null +++ b/addons/pos_online_payment_self_order/static/tests/unit/scenario/pos_self_order_flow.test.js @@ -0,0 +1,37 @@ +import { test } from "@odoo/hoot"; +import { setupSelfPosEnv, mockRouterNavigate } from "@pos_self_order/../tests/unit/utils"; +import { definePosSelfModels } from "@pos_self_order/../tests/unit/data/generate_model_definitions"; +import * as Utils from "@pos_self_order/../tests/unit/ui_utils"; + +definePosSelfModels(); + +test("zero amount total order flow with payment method", async () => { + mockRouterNavigate(); + await setupSelfPosEnv( + "kiosk", + "counter", + "each", + { + use_presets: false, + available_preset_ids: [], + }, + true + ); + // For zero amount total order will be redirected to confirmation page instead of payment page. + await Utils.clickOrderNow(); + await Utils.clickCategory("Category 2"); + await Utils.clickProduct("Free Product - Wood chair"); + await Utils.clickBtn("Checkout"); + await Utils.checkIsNoBtn("Pay"); + await Utils.clickBtn("Order"); + await Utils.checkConfirmationPage(); + await Utils.clickBtn("Close"); + // For non-zero amount total order will be redirected to payment page. + await Utils.clickOrderNow(); + await Utils.clickCategory("Food"); + await Utils.clickProduct("Bacon burger"); + await Utils.clickBtn("Checkout"); + await Utils.checkIsNoBtn("Order"); + await Utils.clickBtn("Pay"); + await Utils.checkPaymentPage(); +}); diff --git a/addons/pos_self_order/static/src/app/components/order_widget/order_widget.js b/addons/pos_self_order/static/src/app/components/order_widget/order_widget.js index 7a3140a47a1f7e..416cc2b6368099 100644 --- a/addons/pos_self_order/static/src/app/components/order_widget/order_widget.js +++ b/addons/pos_self_order/static/src/app/components/order_widget/order_widget.js @@ -47,7 +47,10 @@ export class OrderWidget extends Component { label = _t("Order"); disabled = isNoLine; } else { - label = this.selfOrder.hasPaymentMethod() ? _t("Pay") : _t("Order"); + label = + this.selfOrder.hasPaymentMethod() && this.selfOrder.currentOrder.priceIncl > 0 + ? _t("Pay") + : _t("Order"); } return { label, disabled }; diff --git a/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml b/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml index 3e9412475f0f24..b44cf299592f9b 100644 --- a/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml +++ b/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml @@ -1,7 +1,7 @@ -
diff --git a/addons/pos_self_order/static/src/app/services/self_order_service.js b/addons/pos_self_order/static/src/app/services/self_order_service.js index 67950290b4306d..7d34438d15ec5f 100644 --- a/addons/pos_self_order/static/src/app/services/self_order_service.js +++ b/addons/pos_self_order/static/src/app/services/self_order_service.js @@ -371,9 +371,9 @@ export class SelfOrder extends Reactive { return; } - // When no payment methods redirect to confirmation page - // the client will be able to pay at counter - if (paymentMethods.length === 0) { + // Redirects users directly to the order confirmation page if no payment methods are available or if the total order amount is zero. + // Allows customers to pay at the counter when no payment option is configured. + if (!(paymentMethods.length && order.priceIncl)) { let screenMode = "pay"; if (orderHasChanges) { diff --git a/addons/pos_self_order/static/tests/unit/components/order_widget.test.js b/addons/pos_self_order/static/tests/unit/components/order_widget.test.js index 947064b0613db9..d6509816af5e5a 100644 --- a/addons/pos_self_order/static/tests/unit/components/order_widget.test.js +++ b/addons/pos_self_order/static/tests/unit/components/order_widget.test.js @@ -22,6 +22,11 @@ test("buttonToShow", async () => { // With valid payment method models["pos.payment.method"].getFirst().use_payment_terminal = "stripe"; expect(comp.buttonToShow).toMatchObject({ label: "Pay", disabled: false }); + // With zero amount order + store.currentOrder.lines.forEach((line) => { + line.price_unit = 0; + }); + expect(comp.buttonToShow).toMatchObject({ label: "Order", disabled: false }); }); test("lineNotSend", async () => { diff --git a/addons/pos_self_order/static/tests/unit/data/pos_self_order_custom_link.data.js b/addons/pos_self_order/static/tests/unit/data/pos_self_order_custom_link.data.js index 66c0627f068882..42b94c0b2b213f 100644 --- a/addons/pos_self_order/static/tests/unit/data/pos_self_order_custom_link.data.js +++ b/addons/pos_self_order/static/tests/unit/data/pos_self_order_custom_link.data.js @@ -4,6 +4,17 @@ export class PosSelfOrderCustomLink extends models.ServerModel { _name = "pos_self_order.custom_link"; _load_pos_data_fields() { - return []; + return ["id", "name", "style", "sequence", "url", "link_html"]; } + + _records = [ + { + id: 1, + name: "Order Now", + style: "primary", + sequence: 1, + url: "/pos-self/1/products", + link_html: "Order Now", + }, + ]; } diff --git a/addons/pos_self_order/static/tests/unit/ui_utils.js b/addons/pos_self_order/static/tests/unit/ui_utils.js new file mode 100644 index 00000000000000..f9750f95f1def6 --- /dev/null +++ b/addons/pos_self_order/static/tests/unit/ui_utils.js @@ -0,0 +1,35 @@ +import { expect } from "@odoo/hoot"; +import { animationFrame, waitFor } from "@odoo/hoot-dom"; +import { contains } from "@web/../tests/web_test_helpers"; + +export async function clickOrderNow() { + await contains(".btn:contains('Order Now'), .btn:contains('Order now')").click(); + await animationFrame(); +} + +export async function clickProduct(name) { + await contains(`.product_list .o_self_product_box span:contains('${name}')`).click(); + await animationFrame(); +} + +export async function clickCategory(name) { + await contains(`.category_btn:contains('${name}')`).click(); + await animationFrame(); +} + +export async function clickBtn(buttonName) { + await contains(`.btn:contains('${buttonName}')`).click(); + await animationFrame(); +} + +export async function checkConfirmationPage() { + await waitFor(".confirmation-page"); +} + +export async function checkPaymentPage() { + await waitFor(".payment-page"); +} + +export async function checkIsNoBtn(text) { + expect(`.btn:contains('${text}')`).toHaveCount(0); +} diff --git a/addons/pos_self_order/static/tests/unit/utils.js b/addons/pos_self_order/static/tests/unit/utils.js index 8bb0e5c9dc6afe..7529771f7507b0 100644 --- a/addons/pos_self_order/static/tests/unit/utils.js +++ b/addons/pos_self_order/static/tests/unit/utils.js @@ -12,6 +12,38 @@ import { registry } from "@web/core/registry"; import { selfOrderIndex } from "@pos_self_order/app/self_order_index"; import { setupPosEnv } from "@point_of_sale/../tests/unit/utils"; import { unpatchSelf } from "@pos_self_order/app/services/data_service"; +import { SelfOrderRouter } from "@pos_self_order/app/services/self_order_router_service"; +import { PosSession } from "@point_of_sale/../tests/unit/data/pos_session.data"; + +function checkPosOrder(deviceType, order) { + const count = MockServer.env["pos.order"].search_count([]) + 1; + const configId = order.config_id || 1; + const pos_reference = `0001-001-${String(count).padStart(5, "0")}`; + const prefix = deviceType === "kiosk" ? `K${configId}-` : "S"; + const tracking_number = `${prefix}${count}`; + + if (!order.access_token) { + order.access_token = uuidv4(); + } + + let floating_order_name = order.floating_order_name; + if (deviceType === "kiosk") { + floating_order_name = order.table_stand_number + ? `Table tracker ${order.table_stand_number}` + : String(count); + } else if (!floating_order_name) { + floating_order_name = order.table_id + ? `Self-Order T ${order.table_id}` + : `Self-Order ${count}`; + } + + order.pos_reference = pos_reference; + order.tracking_number = tracking_number; + order.floating_order_name = floating_order_name; + order.state = order.state || "draft"; + order.source = deviceType === "kiosk" ? "kiosk" : "mobile"; + return order; +} export function initMockRpc() { onRpc("/pos-self/relations/1", () => @@ -23,6 +55,11 @@ export function initMockRpc() { const mockProcssOrder = async (request) => { const { params } = await request.json(); + const deviceType = request.url.includes("/kiosk") ? "kiosk" : "mobile"; + if (params.order.amount_total == 0) { + params.order.state = "paid"; + } + checkPosOrder(deviceType, params.order); const response = MockServer.env["pos.order"].sync_from_ui([params.order]); const models = MockServer.env["pos.session"]._load_self_data_models(); return Object.fromEntries(Object.entries(response).filter(([key]) => models.includes(key))); @@ -30,8 +67,8 @@ export function initMockRpc() { onRpc("/pos-self-order/process-order/kiosk", mockProcssOrder); onRpc("/pos-self-order/process-order/mobile", mockProcssOrder); - onRpc("/pos-self-order/get-slots/", () => ({ usage_utc: {} })); onRpc("/pos-self-order/remove-order", () => ({})); + onRpc("/pos-self-order/change-printer-status", () => ({})); } export const setupPoSEnvForSelfOrder = async () => { @@ -42,15 +79,31 @@ export const setupPoSEnvForSelfOrder = async () => { export const setupSelfPosEnv = async ( mode = "kiosk", service_mode = "counter", - pay_after = "each" + pay_after = "each", + configOverrides = {}, + sessionOpened = false ) => { // Do not change these variables, they are in accordance with the setup data + odoo.pos_config_id = 1; + odoo.self_ordering_mode = mode; odoo.access_token = uuidv4(); odoo.info = { isEnterprise: true, }; + + if (sessionOpened) { + odoo.pos_session_id = 1; + PosSession._records = PosSession._records.map((r) => ({ + ...r, + state: "opened", + })); + } else { + odoo.pos_session_id = null; + } + patchWithCleanup(session, { db: "test", + test_mode: true, data: { config_id: 1, }, @@ -70,10 +123,31 @@ export const setupSelfPosEnv = async ( store.config.self_ordering_service_mode = service_mode; store.config.self_ordering_pay_after = pay_after; + if (Object.keys(configOverrides).length) { + Object.assign(store.config, configOverrides); + store.initProducts(); + store.computeAvailableCategories(); + } + await mountWithCleanup(selfOrderIndex); return store; }; +export const mockRouterNavigate = () => { + patchWithCleanup(SelfOrderRouter.prototype, { + navigate(routeName, routeParams = {}, historyState = {}) { + const { route } = this.registeredRoutes[routeName]; + const pathName = route.replace( + /\{\w+:(\w+)\}/g, + (match, paramName) => routeParams[paramName] + ); + this.path = pathName; + this.historyPage = pathName; + window.history.replaceState(historyState, ""); + }, + }); +}; + export const getFilledSelfOrder = async (store) => { const product1 = store.models["product.template"].get(5); const product2 = store.models["product.template"].get(6); From 95f76213d3f732f1d198c740a908e8037c376114 Mon Sep 17 00:00:00 2001 From: Louis Gobert Date: Mon, 17 Aug 2026 14:48:30 +0200 Subject: [PATCH 019/205] [FIX] l10n_fr_pdp: fix function signature Fix the function signature of the _reset_peppol_configuration function Step to reproduce: - Install l10n_fr_pdp and register a company on Peppol - When the _peppol_out_of_sync_disconnect_this_database will be called, it will call the _reset_peppol_configuration(soft=True), and since l10n_fr_pdp overrides this function but don't declare the soft parameter, it will raise a TypeError. opw-5728596 closes odoo/odoo#282763 Signed-off-by: Claire Bretton (clbr) --- addons/l10n_fr_pdp/models/res_company.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/l10n_fr_pdp/models/res_company.py b/addons/l10n_fr_pdp/models/res_company.py index 83c742385e4e50..5fb1041e36e202 100644 --- a/addons/l10n_fr_pdp/models/res_company.py +++ b/addons/l10n_fr_pdp/models/res_company.py @@ -175,7 +175,7 @@ def _l10n_fr_pdp_get_f10_moves_query(self, account_ids, date_company_conditions) def _check_pdp_identifier(self, pdp_identifier, warning=False): return pdp_identifier and PDP_identifier_re.match(pdp_identifier) - def _reset_peppol_configuration(self): + def _reset_peppol_configuration(self, soft=False): # Extend `account_peppol` to reset PDP specific fields self.write({ 'l10n_fr_pdp_send_to_ppf': True, From eefd9757e3e2bdae55534d78212a938173f4c4bf Mon Sep 17 00:00:00 2001 From: jbw-odoo Date: Mon, 8 Jun 2026 12:50:17 +0000 Subject: [PATCH 020/205] [IMP] l10n_fr_pdp: allow reset to draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow resetting sent moves to draft. Ensures a rectificative flow exists or is created. Allow to create an empty rectificative report (if no more invoices to report after being reste to draft). closes odoo/odoo#282120 Task: 6273211 X-original-commit: 84a5125f81ffeb8a735bde84d358e754f7df4886 Signed-off-by: Florian Gilbert (flg) Signed-off-by: de Wouters de Bouchout Jean-Benoît (jbw) --- addons/l10n_fr_pdp/models/account_move.py | 37 ++++++------------- addons/l10n_fr_pdp/models/pdp_flow.py | 2 +- .../models/pdp_flow_xml_builder.py | 20 ++++------ .../l10n_fr_pdp/tests/test_flow_lifecycle.py | 31 +++++++++++++++- 4 files changed, 50 insertions(+), 40 deletions(-) diff --git a/addons/l10n_fr_pdp/models/account_move.py b/addons/l10n_fr_pdp/models/account_move.py index 5dc71607237b8f..26952c8308b07c 100644 --- a/addons/l10n_fr_pdp/models/account_move.py +++ b/addons/l10n_fr_pdp/models/account_move.py @@ -120,13 +120,6 @@ class AccountMove(models.Model): copy=False, ) - @api.depends('l10n_fr_pdp_sent_in_flow_ids') - def _compute_show_reset_to_draft_button(self): - # EXTEND 'account_peppol' to hide the reset to draft button for sent PDP invoices - # account_peppol already prevents resetting those sent via Peppol - super()._compute_show_reset_to_draft_button() - self.filtered(lambda move: move.sudo().l10n_fr_pdp_sent_in_flow_ids).show_reset_to_draft_button = False - @api.depends( 'line_ids.matched_debit_ids.debit_move_id', 'line_ids.matched_credit_ids.credit_move_id', @@ -363,12 +356,6 @@ def button_cancel(self): status = 'refused' if status and self.filtered('pdp_can_send_response') and (action := self.action_pdp_open_response_wizard(status=status)): return action - - for move in self: - if move.state == 'posted' and move.l10n_fr_pdp_sent_in_flow_ids: - # move was sent, must rectify - self.env['l10n.fr.pdp.reports.flow']._get_open_flow_and_create_if_needed(move) - move.with_context(l10n_fr_pdp_bypass_draft_check=True).button_draft() return res # ------------------------------------------------------------------------- @@ -638,15 +625,15 @@ def _need_ubl_cii_xml(self, invoice_edi_format): return False return super()._need_ubl_cii_xml(invoice_edi_format) - # ------------------------------------------------------------------------- - # CRUD Override - # ------------------------------------------------------------------------- - - def _check_draftable(self): - """Prevent resetting to draft when invoice already sent to PDP.""" - if not self.env.context.get('l10n_fr_pdp_bypass_draft_check') and self.l10n_fr_pdp_sent_in_flow_ids: - raise UserError(self.env._( - "You cannot reset an invoice to draft if it was already sent to PDP. " - "Create a credit note and issue a new invoice instead or cancel this invoice." - )) - return super()._check_draftable() + def button_draft(self): + for move in self: + if move.l10n_fr_pdp_sent_in_flow_ids and move.state == 'posted': + # When a flow is sent it compares the moves it sends vs the moves of the previous + # flow to avoid sending the data twice if it's strictly the same. + # Setting "l10n_fr_pdp_sent_in_flow_ids" to None will ensure the move is not already + # considered as sent in previous flow, and allow the current flow to be sent even if + # it's the only diffrence between the 2 flows. + move.l10n_fr_pdp_sent_in_flow_ids = False + # Ensure RE flow exist for current move period. + self.env['l10n.fr.pdp.reports.flow']._get_open_flow_and_create_if_needed(move) + return super().button_draft() diff --git a/addons/l10n_fr_pdp/models/pdp_flow.py b/addons/l10n_fr_pdp/models/pdp_flow.py index a5e5d1fbf99b0b..0678cb924603e3 100644 --- a/addons/l10n_fr_pdp/models/pdp_flow.py +++ b/addons/l10n_fr_pdp/models/pdp_flow.py @@ -215,7 +215,7 @@ def _build_payload(self, moves=None): moves = flow._get_moves() valid_moves = moves.filtered(lambda move: move.l10n_fr_pdp_status not in invalid_move_states) - if not valid_moves: + if not flow.initial_flow_id and not valid_moves: flow._message_post_once(self.env._("Payload build failed: no valid invoices.")) continue diff --git a/addons/l10n_fr_pdp/models/pdp_flow_xml_builder.py b/addons/l10n_fr_pdp/models/pdp_flow_xml_builder.py index 18d16405d337ba..c653a305a8d6af 100644 --- a/addons/l10n_fr_pdp/models/pdp_flow_xml_builder.py +++ b/addons/l10n_fr_pdp/models/pdp_flow_xml_builder.py @@ -27,9 +27,6 @@ class PdpFlow10XMLBuilder(models.AbstractModel): @api.model def _build_payload(self, flow, valid_moves): - if not valid_moves: - return False - document = {'_tag': 'Report'} self._add_report_header(document, flow) # TB-1 @@ -191,15 +188,14 @@ def _add_transactions(self, document, flow, moves): b2bi_invoices = self._get_b2bi_transaction_nodes(flow, b2bi_moves) b2c_agregates = self._get_b2c_transaction_nodes(b2c_moves) - if b2bi_invoices or b2c_agregates: - document['TransactionsReport'] = { - 'ReportPeriod': { - 'StartDate': {'_text': self._format_date(flow.period_start)}, - 'EndDate': {'_text': self._format_date(flow.period_end)}, - }, - 'Invoice': b2bi_invoices, - 'Transactions': b2c_agregates, - } + document['TransactionsReport'] = { + 'ReportPeriod': { + 'StartDate': {'_text': self._format_date(flow.period_start)}, + 'EndDate': {'_text': self._format_date(flow.period_end)}, + }, + 'Invoice': b2bi_invoices, + 'Transactions': b2c_agregates, + } @api.model def _get_b2bi_transaction_nodes(self, flow, moves): diff --git a/addons/l10n_fr_pdp/tests/test_flow_lifecycle.py b/addons/l10n_fr_pdp/tests/test_flow_lifecycle.py index a1d350d221930a..cc02ef9940e359 100644 --- a/addons/l10n_fr_pdp/tests/test_flow_lifecycle.py +++ b/addons/l10n_fr_pdp/tests/test_flow_lifecycle.py @@ -1188,7 +1188,7 @@ def test_invoice_corrected_before_initial_flow_is_sent_stays_in_initial_flow(sel ) initial_flow = invoice.l10n_fr_pdp_last_flow_id - invoice.with_context(l10n_fr_pdp_bypass_draft_check=True).button_draft() + invoice.button_draft() invoice.invoice_line_ids.price_unit = 150.0 invoice.action_post() invoice.is_move_sent = True @@ -1643,7 +1643,7 @@ def test_draft_and_cancelled_invoices_are_excluded_from_ready_payload(self): ) flow = kept_invoice.l10n_fr_pdp_last_flow_id - draft_invoice.with_context(l10n_fr_pdp_bypass_draft_check=True).button_draft() + draft_invoice.button_draft() cancelled_invoice.button_cancel() self._refresh_pdp_fields(draft_invoice | cancelled_invoice) xml = self._build_flow_xml(flow) @@ -2041,3 +2041,30 @@ def test_force_update_l10n_fr_f10_moves(self): self.assertFalse(self.company.l10n_fr_pdp_flow_10_start_date) self.company._force_update_l10n_fr_f10_moves() self.assertTrue(invoice.l10n_fr_pdp_last_flow_id) + + def test_reset_move_to_draft(self): + invoice = self._create_form_invoice( + partner=self.b2bi_customer, + invoice_date='2025-09-03', + lines=[{ + 'price_unit': 100.0, + 'tax_ids': self._get_tax_on_payment_20(), + }], + ) + self.assertFalse(invoice.l10n_fr_pdp_last_flow_id.initial_flow_id) # IN + self.assertTrue(invoice.l10n_fr_pdp_last_flow_id) # IN + self._run_send_cron('2025-09-20', identifier='FULL-FORM-RE-INITIAL') + invoice.button_draft() + # check RE flow has been created and can successfully build a payload + re_flow = self.env['l10n.fr.pdp.reports.flow'].search([('initial_flow_id', '!=', False)]) + self.assertFalse(re_flow.payload_id) + xml = self._build_flow_xml(re_flow) + # RE with no invoice + invoices = xml.findall('./TransactionsReport/Invoice') + self.assertFalse(invoices) + invoice.action_post() + # RE with re-posted invoice + xml = self._build_flow_xml(re_flow) + invoices = xml.findall('./TransactionsReport/Invoice') + self.assertEqual(len(invoices), 1) + self.assertEqual(invoices[0].findtext('ID'), invoice.name) From 0824dc24665de6bfa805d540e756cdcb006edba6 Mon Sep 17 00:00:00 2001 From: Louis Travaux Date: Mon, 17 Aug 2026 11:48:00 +0200 Subject: [PATCH 021/205] [IMP] point_of_sale: bring back default code on product conf We reintroduce the default code on the product configuration modal, and ensure that searching for a variant reference opens the right variant. task-6463377 closes odoo/odoo#282707 Signed-off-by: Yaroslav Soroko (yaso) --- .../product_configurator_popup.js | 4 ++++ .../product_configurator_popup.xml | 5 +++++ .../screens/product_screen/product_screen.js | 6 ++++-- .../unit/components/product_screen.test.js | 21 ++++++++++++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.js b/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.js index 9b96e212d8f41d..ef2389ffe8b890 100644 --- a/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.js +++ b/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.js @@ -266,6 +266,10 @@ export class ProductConfiguratorPopup extends Component { const total = this.env.utils.formatCurrency(info?.raw_total_included_currency || 0.0); return `${this.props.productTemplate.display_name} | ${total}`; } + get defaultCode() { + const product = this.product || this.props.productTemplate; + return `[${product.default_code}]`; + } get showInfoBanner() { return this.props.productTemplate.is_storable; } diff --git a/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.xml b/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.xml index 8f3a771e43085a..779bb8befee8de 100644 --- a/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.xml +++ b/addons/point_of_sale/static/src/app/components/popups/product_configurator_popup/product_configurator_popup.xml @@ -193,6 +193,11 @@ + +
diff --git a/addons/l10n_din5008_sale/report/din5008_sale_templates.xml b/addons/l10n_din5008_sale/report/din5008_sale_templates.xml index d8674aee7e7d2d..2937e793354fbf 100644 --- a/addons/l10n_din5008_sale/report/din5008_sale_templates.xml +++ b/addons/l10n_din5008_sale/report/din5008_sale_templates.xml @@ -12,11 +12,11 @@ Quotation Date: -
+
Expiration: -
+
@@ -26,11 +26,11 @@ Order Date: -
+
Delivery Date: -
+
From 5b0c5503bf914a5982970871fb6b1d27238d2a83 Mon Sep 17 00:00:00 2001 From: plha-odoo Date: Tue, 18 Aug 2026 10:55:38 +0200 Subject: [PATCH 067/205] [FIX] sale_stock,stock_account: correct cogs when rereturned **Steps to reproduce:** - create a storable product with a positive quantity a cost of 10 and average perpetual category - confirm a SO for 1 quantity, validate delivery - confirm invoice for 1 (COGS should be 10) - return the delivery and validate - create a credit note from the invoice for 1 and confirm (COGS should be 10) - return the return and validate - change the standard price to 100 - create an invoice from the SO for 1 and confirm **Current behavior:** cogs are 190 **Expected behavior:** cogs should be 100 **Cause of the issue:** _get_posted_cogs_value doesn't take into account the credit notes (only the account moves with type 'out_invoice' are taken into account in the sum) https://github.com/odoo/odoo/blob/0824dc24665de6bfa805d540e756cdcb006edba6/addons/sale_stock/models/account_move.py#L185-L186 So in our case the first invoice and the credit note don't cancel out each other. The same goes for _get_cogs_qty (which returns the total cogs past + current), in the past cogs it doesn't take into account the quantities of the credit note. https://github.com/odoo/odoo/blob/0824dc24665de6bfa805d540e756cdcb006edba6/addons/sale_stock/models/account_move.py#L172-L174 So the quantity of the first invoice and the one of the credit note don't cancel out each other. As a result, the return value from _get_cogs_value() for the second invoice is : price unit = 100 returned by _get_cogs_price_unit() https://github.com/odoo/odoo/blob/0824dc24665de6bfa805d540e756cdcb006edba6/addons/stock_account/models/account_move_line.py#L68 which returned the standard price because the product has an average cost method https://github.com/odoo/odoo/blob/0824dc24665de6bfa805d540e756cdcb006edba6/addons/stock_account/models/stock_move.py#L275-L280 cogs_qty = 2 (instead of 1 if credit was taken into account as -1 in the sum) self._get_posted_cogs_value() = 10 (instead of 0 if credit note cogs were taken into account in the sum as -10) return value = (100 * 2 -10)/1 = 190 https://github.com/odoo/odoo/blob/0824dc24665de6bfa805d540e756cdcb006edba6/addons/stock_account/models/account_move_line.py#L75 **fix:** if we take into account the credit note the return value will be : (100 * 1 - 0)/1 = 100 the mechanism of the already posted cogs value is there for cases where we only delivered a part of the quantity and then delivered the rest, but in the case where we delivered and then returned (with credit notes) it shouldn't have an impact. Thus the idea to include the credit note so that it can cancel out the first invoice opw-6426111 closes odoo/odoo#282893 Signed-off-by: William Henrotin (whe) --- addons/sale_stock/models/account_move.py | 4 +- .../tests/test_anglo_saxon_valuation.py | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/addons/sale_stock/models/account_move.py b/addons/sale_stock/models/account_move.py index be21775165f1db..d57b03701d9445 100644 --- a/addons/sale_stock/models/account_move.py +++ b/addons/sale_stock/models/account_move.py @@ -171,7 +171,7 @@ def _get_cogs_qty(self): self.ensure_one() valuation_account = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=self.move_id.fiscal_position_id)['stock_valuation'] sale_lines = self.sale_line_ids - posted_cogs_lines = sale_lines.order_id.invoice_ids.filtered(lambda m: m.move_type == 'out_invoice').line_ids.filtered( + posted_cogs_lines = sale_lines.order_id.invoice_ids.filtered(lambda m: m.move_type in ['out_invoice', 'out_refund']).line_ids.filtered( lambda line: line.display_type == 'cogs' and line.account_id == valuation_account and line.cogs_origin_id.sale_line_ids & sale_lines ) posted_cogs_qty_prod_uom = sum(posted_cogs_lines.mapped( @@ -184,7 +184,7 @@ def _get_posted_cogs_value(self): self.ensure_one() valuation_account = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=self.move_id.fiscal_position_id)['stock_valuation'] sale_lines = self.sale_line_ids - posted_cogs_value = - sum(sale_lines.order_id.invoice_ids.filtered(lambda m: m.move_type == 'out_invoice').line_ids.filtered( + posted_cogs_value = - sum(sale_lines.order_id.invoice_ids.filtered(lambda m: m.move_type in ['out_invoice', 'out_refund']).line_ids.filtered( lambda line: line.display_type == 'cogs' and line.account_id == valuation_account and line.cogs_origin_id.sale_line_ids & sale_lines ).mapped('balance')) return posted_cogs_value + super()._get_posted_cogs_value() diff --git a/addons/sale_stock/tests/test_anglo_saxon_valuation.py b/addons/sale_stock/tests/test_anglo_saxon_valuation.py index bb1ef68563b64e..1a8c8cabe0d285 100644 --- a/addons/sale_stock/tests/test_anglo_saxon_valuation.py +++ b/addons/sale_stock/tests/test_anglo_saxon_valuation.py @@ -1828,3 +1828,63 @@ def test_multi_steps_partially_delivered(self): {'account_id': self.account_stock_valuation.id, 'debit': 0.0, 'credit': 10.0}, {'account_id': self.account_expense.id, 'debit': 10.0, 'credit': 0.0}, ]) + + def test_cogs_avco_return_redelivered(self): + """ + avco pereptual product with standard price 10 + SO for 1 -> deliver -> invoice (cogs 10) + return 1 -> credit note (cogs -10) + change standard price to 100 + + check that when redelivering and reinvoicing cogs are 100 + """ + # SO for 1 -> deliver -> invoice (cogs 10) + self.product_avco_auto.invoice_policy = 'delivery' + self._make_in_move(self.product_avco_auto, 2, 10) + sale_order = self._so_deliver(self.product_avco_auto, 1) + original_delivery = sale_order.picking_ids + + invoice = sale_order._create_invoices() + invoice.action_post() + cogs_aml = invoice.line_ids.filtered(lambda l: l.display_type == 'cogs').sorted('debit') + self.assertRecordValues(cogs_aml, [ + {'account_id': self.account_stock_valuation.id, 'debit': 0.0, 'credit': 10.0}, + {'account_id': self.account_expense.id, 'debit': 10.0, 'credit': 0.0}, + ]) + + # return 1 -> credit note (cogs -10) + ctx = {'active_id': original_delivery.id, 'active_model': 'stock.picking'} + return_wizard = Form(self.env['stock.return.picking'].with_context(ctx)).save() + return_wizard.product_return_moves.quantity = 1 + return_picking = return_wizard._create_return() + return_picking.move_ids.write({'quantity': 1, 'picked': True}) + return_picking.button_validate() + + ctx = {'active_model': 'account.move', 'active_ids': invoice.ids} + refund_wizard = self.env['account.move.reversal'].with_context(ctx).create({'journal_id': invoice.journal_id.id}) + action = refund_wizard.refund_moves() + credit_note = self.env['account.move'].browse(action['res_id']) + credit_note.invoice_line_ids[0].quantity = 1 + credit_note.action_post() + return_cogs_aml = credit_note.line_ids.filtered(lambda l: l.display_type == 'cogs').sorted('debit') + self.assertRecordValues(return_cogs_aml, [ + {'account_id': self.account_expense.id, 'debit': 0.0, 'credit': 10.0}, + {'account_id': self.account_stock_valuation.id, 'debit': 10.0, 'credit': 0.0}, + ]) + + # change standard price, re-deliver and re-invoice + self.product_avco_auto.standard_price = 100 + ctx = {'active_id': return_picking.id, 'active_model': 'stock.picking'} + redelivery_wizard = Form(self.env['stock.return.picking'].with_context(ctx)).save() + redelivery_wizard.product_return_moves.quantity = 1 + redelivery_picking = redelivery_wizard._create_return() + redelivery_picking.move_ids.write({'quantity': 1, 'picked': True}) + redelivery_picking.button_validate() + + reinvoice = sale_order._create_invoices() + reinvoice.action_post() + cogs_aml = reinvoice.line_ids.filtered(lambda l: l.display_type == 'cogs').sorted('debit') + self.assertRecordValues(cogs_aml, [ + {'account_id': self.account_stock_valuation.id, 'debit': 0.0, 'credit': 100.0}, + {'account_id': self.account_expense.id, 'debit': 100.0, 'credit': 0.0}, + ]) From 62c175b765436cf8b5679aadcc0d929c82e93869 Mon Sep 17 00:00:00 2001 From: "Omar Khalil (omkha)" Date: Wed, 19 Aug 2026 11:18:46 +0200 Subject: [PATCH 068/205] [FIX] mail: allow chat windows to have a configurable z-index The z-index of chat windows on mobile views was previously fixed at `1020`, preventing other modules from adjusting their stacking order. This commit introduces a configurable z-index for chat windows, defaulting to `1020` while allowing other modules to override it when needed. task-6412411 closes odoo/odoo#283178 Related: odoo/enterprise#128346 Signed-off-by: Panagiotis Kyriakou (paky) --- addons/mail/static/src/core/common/chat_window.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/mail/static/src/core/common/chat_window.scss b/addons/mail/static/src/core/common/chat_window.scss index f092fd5e3e7ad8..08ab926ebb0374 100644 --- a/addons/mail/static/src/core/common/chat_window.scss +++ b/addons/mail/static/src/core/common/chat_window.scss @@ -3,7 +3,7 @@ z-index: $zindex-sticky; &.o-mobile { - z-index: $zindex-sticky - 2 !important; + z-index: var(--mail-ChatWindow-mobileZindex, $zindex-sticky - 2) !important; } &:not(.o-mobile) { --border-opacity: .15; From 3bc52e92943ec67175f082da0f8702a589ae7626 Mon Sep 17 00:00:00 2001 From: "Walid (wasa)" Date: Tue, 18 Aug 2026 14:04:19 +0200 Subject: [PATCH 069/205] [FIX] html_editor: fix gradient text visibility in link selection Problem: When text formatted with `.text-gradient` is inside a link with `.o_link_in_selection`, the selected text becomes invisible. `.text-gradient` sets `-webkit-text-fill-color: transparent`, which prevents `color: black !important` on `.o_link_in_selection` from taking effect. Cause: `-webkit-text-fill-color: transparent` from `.text-gradient` overrides standard text `color` rendering, causing the text to stay transparent against the selection highlight background. Solution: Set `-webkit-text-fill-color: black` on `.o_link_in_selection` to ensure text inside gradient links is rendered in black and remains clearly visible when selected. Steps to reproduce: - Add text "ABCD". - Apply gradient color to all text. - Create a link on "BC". - Place cursor/selection inside the new link. - Observe that the text is not visible. opw-6479350 closes odoo/odoo#282952 Signed-off-by: David Monjoie (dmo) --- addons/html_editor/static/src/main/link/link.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/html_editor/static/src/main/link/link.scss b/addons/html_editor/static/src/main/link/link.scss index 564c7fe9ed84a1..5d6736b420290e 100644 --- a/addons/html_editor/static/src/main/link/link.scss +++ b/addons/html_editor/static/src/main/link/link.scss @@ -3,6 +3,7 @@ color: black !important; border: 1px dashed #008f8c; margin: -1px; + -webkit-text-fill-color: black; } .odoo-editor-editable { From ea56e09c1adb33587807192569fbf44bc041ad61 Mon Sep 17 00:00:00 2001 From: rhe-odoo Date: Wed, 22 Jul 2026 12:48:52 +0200 Subject: [PATCH 070/205] [FIX] pos_online_payment: fix validation error after changing order with online payment When products were added after selecting an online payment method and going back to the floor plan, the subsequent validation failed with "Invalid online payments" because the server's amount_unpaid was based on the old order total. Fix: sync the order to the server before querying amount_unpaid (both when online payment lines remain and when checking synced orders after deletion), so the server always has the latest total when checkRemainingOnlinePaymentLines is called. Also guard cancelPayment against calling the payment terminal interface on online payment methods that do not use one. closes odoo/odoo#277749 Task-id: 6330704 Signed-off-by: David Monnom (moda) --- .../static/src/app/models/pos_payment.js | 8 +++---- .../src/app/utils/order_payment_validation.js | 3 ++- .../static/tests/tours/online_payment_tour.js | 21 +++++++++++++++++++ .../pos_online_payment/tests/test_frontend.py | 8 +++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/addons/pos_online_payment/static/src/app/models/pos_payment.js b/addons/pos_online_payment/static/src/app/models/pos_payment.js index 9283e3c8e42724..06fa8ca11c8c71 100644 --- a/addons/pos_online_payment/static/src/app/models/pos_payment.js +++ b/addons/pos_online_payment/static/src/app/models/pos_payment.js @@ -3,11 +3,11 @@ import { patch } from "@web/core/utils/patch"; patch(PosPayment.prototype, { //@override - canBeAdjusted() { + async cancelPayment() { if (this.payment_method_id.is_online_payment) { - return false; - } else { - return super.canBeAdjusted(); + this.setPaymentStatus("retry"); + return true; } + return super.cancelPayment(...arguments); }, }); diff --git a/addons/pos_online_payment/static/src/app/utils/order_payment_validation.js b/addons/pos_online_payment/static/src/app/utils/order_payment_validation.js index fd446f22b5577c..0b06cc62843ef6 100644 --- a/addons/pos_online_payment/static/src/app/utils/order_payment_validation.js +++ b/addons/pos_online_payment/static/src/app/utils/order_payment_validation.js @@ -85,6 +85,7 @@ patch(OrderPaymentValidation.prototype, { let lastOrderServerOPData = null; for (const onlinePaymentLine of onlinePaymentLines) { const onlinePaymentLineAmount = onlinePaymentLine.getAmount(); + await this.pos.syncAllOrders({ orders: [this.order] }); // The local state is not aware if the online payment has already been done. lastOrderServerOPData = await this.pos.updateOnlinePaymentsDataWithServer( this.order, @@ -118,7 +119,6 @@ patch(OrderPaymentValidation.prototype, { return false; } - await this.pos.syncAllOrders({ orders: [this.order] }); onlinePaymentLine.setPaymentStatus("waiting"); this.order.selectPaymentline(onlinePaymentLine); const onlinePaymentData = { @@ -168,6 +168,7 @@ patch(OrderPaymentValidation.prototype, { await this.afterPaidOrderSavedOnServer(lastOrderServerOPData.paid_order); return false; // Cancel normal flow because the current order is already saved on the server. } else if (this.order.isSynced) { + await this.pos.syncAllOrders({ orders: [this.order] }); const orderServerOPData = await this.pos.updateOnlinePaymentsDataWithServer( this.order, 0 diff --git a/addons/pos_online_payment/static/tests/tours/online_payment_tour.js b/addons/pos_online_payment/static/tests/tours/online_payment_tour.js index 85871eefc3fdda..0044108b64665c 100644 --- a/addons/pos_online_payment/static/tests/tours/online_payment_tour.js +++ b/addons/pos_online_payment/static/tests/tours/online_payment_tour.js @@ -1,6 +1,7 @@ import * as ProductScreen from "@point_of_sale/../tests/pos/tours/utils/product_screen_util"; import * as Chrome from "@point_of_sale/../tests/pos/tours/utils/chrome_util"; import * as PaymentScreen from "@point_of_sale/../tests/pos/tours/utils/payment_screen_util"; +import * as ReceiptScreen from "@point_of_sale/../tests/pos/tours/utils/receipt_screen_util"; import * as Dialog from "@point_of_sale/../tests/generic_helpers/dialog_util"; import { registry } from "@web/core/registry"; @@ -130,3 +131,23 @@ registry.category("web_tour.tours").add("test_payment_method_customer_required", Dialog.is({ title: "Payment provider requirement" }), ].flat(), }); + +registry.category("web_tour.tours").add("RestaurantOnlinePaymentTour", { + steps: () => + [ + Chrome.startPoS(), + Dialog.confirm("Open Register"), + ProductScreen.addOrderline("Letter Tray", "1"), + ProductScreen.clickPayButton(), + PaymentScreen.clickPaymentMethod("Online payment"), + PaymentScreen.clickBackToProductScreen(), + ProductScreen.clickDisplayedProduct("Letter Tray"), + ProductScreen.selectedOrderlineHas("Letter Tray", "2.0"), + ProductScreen.clickPayButton(), + PaymentScreen.totalIs("9.60"), + PaymentScreen.clickPaymentlineDelButton("Online payment", "4.80"), + PaymentScreen.clickPaymentMethod("Cash"), + PaymentScreen.clickValidate(), + ReceiptScreen.isShown(), + ].flat(), +}); diff --git a/addons/pos_online_payment/tests/test_frontend.py b/addons/pos_online_payment/tests/test_frontend.py index 08083cd418c9e5..ddd5c7a2ac247c 100644 --- a/addons/pos_online_payment/tests/test_frontend.py +++ b/addons/pos_online_payment/tests/test_frontend.py @@ -453,6 +453,14 @@ def test_online_payment_amount_updated_after_order_modification(self): self.assertEqual(order.state, "draft", "The order should still be in draft state, awaiting the online payment.") self.assertEqual(order.amount_total, 96.0, "The increased order total should be synced to the server.") + def test_restaurant_online_payment_flow(self): + self.pos_config.with_user(self.pos_admin).open_ui() + self.start_pos_tour('RestaurantOnlinePaymentTour', login="pos_admin") + order = self.pos_config.current_session_id.order_ids.sorted(lambda o: o.id, reverse=True)[0] + self.assertEqual(order.state, "paid", "The order should be paid.") + self.assertEqual(len(order.payment_ids), 1, "There should be one payment line in the order.") + self.assertEqual(order.payment_ids[0].payment_method_id.id, self.cash_payment_method.id, "The payment should be Cash.") + @classmethod def tearDownClass(cls): # Restore company values after the tests From c4076ca0a37f383f9e8649306d9c026260696a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Dieudonn=C3=A9?= Date: Wed, 19 Aug 2026 14:47:33 +0200 Subject: [PATCH 071/205] [FIX] web: colorlist: apply color classes outside `.o_colorlist` The `o_colorlist_item_color_*` classes were scoped to `.o_colorlist > button` by 1aa9b957afdd , but they are also used standalone outside any colorlist, e.g. in Planning's `many2one_avatar_resource` field. `web_enterprise`'s dark-mode counterpart also defines them unscoped, so the two stylesheets disagreed. Move the color rules back to the root scope. The colors themselves and the `color-contrast()` text color introduced by the refactoring are kept. Steps to reproduce: - Go to "Planning" - Open "Configuration" => the resources in the "Resources" column. closes odoo/odoo#283232 Signed-off-by: Romeo Fragomeli (rfr) --- .../static/src/core/colorlist/colorlist.scss | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/addons/web/static/src/core/colorlist/colorlist.scss b/addons/web/static/src/core/colorlist/colorlist.scss index 536707d6d073da..532823a543c188 100644 --- a/addons/web/static/src/core/colorlist/colorlist.scss +++ b/addons/web/static/src/core/colorlist/colorlist.scss @@ -57,19 +57,19 @@ > button { aspect-ratio: 1; + } +} - // No Color - &.o_colorlist_item_color_0 { - background: transparent; - box-shadow: inset 0 0 0 1px $gray-500; - } +// No Color +.o_colorlist_item_color_0 { + background: transparent; + box-shadow: inset 0 0 0 1px $gray-500; +} - // Set all the colors but the "no-color" one - @for $size from 2 through length($o-colors) { - &.o_colorlist_item_color_#{$size - 1} { - @include o-print-color(nth($o-colors, $size), background-color, bg-opacity); - @include o-print-color(color-contrast(nth($o-colors, $size)), color, text-opacity); - } - } +// Set all the colors but the "no-color" one +@for $size from 2 through length($o-colors) { + .o_colorlist_item_color_#{$size - 1} { + @include o-print-color(nth($o-colors, $size), background-color, bg-opacity); + @include o-print-color(color-contrast(nth($o-colors, $size)), color, text-opacity); } } From d713b676136fc86f40d4b6517eaafce854bc09f5 Mon Sep 17 00:00:00 2001 From: nsirjacobs Date: Mon, 13 Apr 2026 16:45:49 +0200 Subject: [PATCH 072/205] [FIX] hr_skills_event: ensure new onsite events match view domain Onsite events created from the "Onsite" view or the employee resume selector do not appear immediatlely after creation This occurs because currently the domain for onsite events requires that the event to have multiple slots as well as to have at least one employee registered to it. Therefore, newly created records often fail these criteria and remain hidden. In further versions, this pr: https://github.com/odoo/odoo/pull/246285/ changes the domain of the event selector in the employee resume by removing the dependency on the multiple slots and filtering by the specific employee for registration. This change is not stable to backport as it indroduces the `employee_id` field as an invisible field in the xml to be able to compare in the domain. This commit partly changes both domains to not require the multiple slots anymore, while still showing all events for which an employee is registered. This commit also ensures that when an event is created from the Onsite view or selector, the current user's employee will be registered to it. Steps to reproduce - Go to employees->Learning->Onsite - Select New and create an event - Go back to Onsite Courses - You will not see the created event (unless it is multi_slot and an employee was registered) opw-5915686 closes odoo/odoo#258952 Signed-off-by: Thibault Delavallee (tde) --- addons/hr_skills/views/hr_views.xml | 4 +- addons/hr_skills_event/__manifest__.py | 10 +- addons/hr_skills_event/models/__init__.py | 1 + addons/hr_skills_event/models/event_event.py | 23 ++++ .../hr_skills_event/models/hr_resume_line.py | 3 +- .../static/tests/tours/onsite_skill_tour.js | 111 ++++++++++++++++++ addons/hr_skills_event/tests/__init__.py | 3 + addons/hr_skills_event/tests/test_onsite.py | 16 +++ .../views/event_event_views.xml | 3 +- .../views/hr_resume_line_views.xml | 2 +- 10 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 addons/hr_skills_event/models/event_event.py create mode 100644 addons/hr_skills_event/static/tests/tours/onsite_skill_tour.js create mode 100644 addons/hr_skills_event/tests/__init__.py create mode 100644 addons/hr_skills_event/tests/test_onsite.py diff --git a/addons/hr_skills/views/hr_views.xml b/addons/hr_skills/views/hr_views.xml index 19233edcd7edc9..cd66dfb8fa25df 100644 --- a/addons/hr_skills/views/hr_views.xml +++ b/addons/hr_skills/views/hr_views.xml @@ -108,7 +108,7 @@ Adding fields in the list arch below makes them accessible to the widget --> - + @@ -175,7 +175,7 @@ Adding fields in the list arch below makes them accessible to the widget --> - + diff --git a/addons/hr_skills_event/__manifest__.py b/addons/hr_skills_event/__manifest__.py index 64278fc0554c34..c1eedaed8d4cea 100644 --- a/addons/hr_skills_event/__manifest__.py +++ b/addons/hr_skills_event/__manifest__.py @@ -19,7 +19,15 @@ 'views/hr_views.xml', ], 'auto_install': True, - 'assets': {}, + 'assets': { + 'web.assets_tests': [ + 'hr_skills_event/static/tests/tours/**/*', + ], + 'web.assets_unit_tests': [ + 'hr_skills_event/static/tests/**/*', + ('remove', 'hr_skills_event/static/tests/tours/**/*'), + ], + }, 'author': 'Odoo S.A.', 'license': 'LGPL-3', } diff --git a/addons/hr_skills_event/models/__init__.py b/addons/hr_skills_event/models/__init__.py index 5c7a095038ffdc..8293e84c0c7e70 100644 --- a/addons/hr_skills_event/models/__init__.py +++ b/addons/hr_skills_event/models/__init__.py @@ -1,3 +1,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import hr_resume_line +from . import event_event diff --git a/addons/hr_skills_event/models/event_event.py b/addons/hr_skills_event/models/event_event.py new file mode 100644 index 00000000000000..dd87ca4ec71a8b --- /dev/null +++ b/addons/hr_skills_event/models/event_event.py @@ -0,0 +1,23 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api + + +class EventEvent(models.Model): + _inherit = 'event.event' + + @api.model_create_multi + def create(self, vals_list): + # TODO: Should be replaced by a boolean flag on events in master to make 'onsite' events easier to identify. + # When creating an event considered as "onsite event" register + # current employee as attendee of the event + events = super().create(vals_list) + if self.env.context.get('hr_skills_event_add_employee'): + if employee := self.env['hr.employee'].search([('id', '=', self.env.context['default_employee_id']), ('work_contact_id', '!=', False)], limit=1): + partner = employee.work_contact_id + vals_list = [ + {'partner_id': partner.id, 'event_id': event.id} + for event in events.filtered(lambda e: partner not in e.registration_ids.partner_id) + ] + self.env['event.registration'].create(vals_list) + return events diff --git a/addons/hr_skills_event/models/hr_resume_line.py b/addons/hr_skills_event/models/hr_resume_line.py index f49c1825afc29c..6cc4f9c7668c4a 100644 --- a/addons/hr_skills_event/models/hr_resume_line.py +++ b/addons/hr_skills_event/models/hr_resume_line.py @@ -9,7 +9,8 @@ class HrResumeLine(models.Model): event_id = fields.Many2one( 'event.event', string="Onsite Course", compute='_compute_event_id', store=True, readonly=True, index='btree_not_null', - domain="[('is_multi_slots', '=', True), ('registration_ids', 'any', [('partner_id.employee', '=', True)])]" + domain="[('registration_ids', 'any', [('partner_id.employee', '=', True)])]", + context={'hr_skills_event_add_employee': True}, ) course_type = fields.Selection( selection_add=[('onsite', 'Onsite')], diff --git a/addons/hr_skills_event/static/tests/tours/onsite_skill_tour.js b/addons/hr_skills_event/static/tests/tours/onsite_skill_tour.js new file mode 100644 index 00000000000000..51b06f28811aed --- /dev/null +++ b/addons/hr_skills_event/static/tests/tours/onsite_skill_tour.js @@ -0,0 +1,111 @@ +import { registry } from "@web/core/registry"; + +registry.category("web_tour.tours").add("hr_skills_event_onsite_tour", { + url: "/odoo", + steps: () => [ + { + content: "Open Employees app", + trigger: ".o_app[data-menu-xmlid='hr.menu_hr_root']", + run: "click", + }, + { + content: "Go to test employee", + trigger: "span:contains('Test Employee')", + run: "click", + }, + { + content: "Go to Resume Tab", + trigger: "a.nav-link[name='resume']", + run: "click", + }, + { + content: "Open New Resume Line form", + trigger: "button:contains('Create Resume Lines')", + run: "click", + }, + { + content: "Go to Training Tab", + trigger: "span.o_selection_badge:contains('Training')", + run: "click", + }, + { + content: "Select Onsite course type", + trigger: "input[id='radio_field_0_onsite']", + run: "click", + }, + { + content: "Open dropdown menu", + trigger: "input[id='event_id_0']", + run: "click", + }, + { + content: "Ensure we don't have any options except 'Create'", + trigger: "div[name='event_id'] ul", + async run() { + const liList = document.querySelectorAll("div[name='event_id'] ul>li"); + if (liList.length !== 1) { + throw new Error(`Expected 1 item, found ${liList.length}`); + } + }, + }, + { + content: "Open Form to create a new event", + trigger: "li.o_m2o_dropdown_option_create_edit", + run: "click", + }, + { + content: "Write a name for the event", + trigger: "textarea[id='name_0']", + run: "edit Event1", + }, + { + content: "Save the event", + trigger: "div.modal-content:has(h4:contains('Create Onsite Course')) button.o_form_button_save", + run: "click", + }, + { + content: "Select External course type to refresh the event dropdown", + trigger: "input[id='radio_field_0_external']", + run: "click", + }, + { + content: "Select Onsite course type", + trigger: "input[id='radio_field_0_onsite']", + run: "click", + }, + { + content: "Open dropdown menu", + trigger: "input[id='event_id_0']", + run: "click", + }, + { + content: "Ensure we now have the new event as an option", + trigger: "div[name='event_id'] ul", + async run() { + const liList = document.querySelectorAll("div[name='event_id'] ul>li"); + if (liList.length !== 2) { + throw new Error(`Expected 2 items, found ${liList.length}`); + } + }, + }, + { + content: "Save the resume line with the event", + trigger: "div.modal-content:has(h4:contains('New Resume Line')) button.o_form_button_save", + run: "click", + }, + { + content: "Check that the event is correctly displayed in the resume line", + trigger: ".o_resume_line_title:contains('Event1')", + }, + { + content: "Save the employee form", + trigger: "button.o_form_button_save", + run: "click", + }, + { + content: "Wait for the form to save completely", + trigger: "body:not(:has(button.o_form_button_save:visible))", + }, + + ], +}); diff --git a/addons/hr_skills_event/tests/__init__.py b/addons/hr_skills_event/tests/__init__.py new file mode 100644 index 00000000000000..54746c30f4f031 --- /dev/null +++ b/addons/hr_skills_event/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_onsite diff --git a/addons/hr_skills_event/tests/test_onsite.py b/addons/hr_skills_event/tests/test_onsite.py new file mode 100644 index 00000000000000..5f3abe59da6485 --- /dev/null +++ b/addons/hr_skills_event/tests/test_onsite.py @@ -0,0 +1,16 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import HttpCase, tagged + + +@tagged('-at_install', 'post_install') +class TestOnsite(HttpCase): + def test_onsite_employee_registration(self): + self.env['hr.employee'].create({ + 'name': 'Test Employee', + }) + # We create this event to check that only events with employees registered are proposed in the resume line form view + self.env['event.event'].create({ + 'name': 'Test Event', + }) + self.start_tour("/odoo", 'hr_skills_event_onsite_tour', login='admin') diff --git a/addons/hr_skills_event/views/event_event_views.xml b/addons/hr_skills_event/views/event_event_views.xml index 1380001d79473f..27ef8aaf8f9104 100644 --- a/addons/hr_skills_event/views/event_event_views.xml +++ b/addons/hr_skills_event/views/event_event_views.xml @@ -4,7 +4,8 @@ Onsite Courses event.event kanban,calendar,list,form,pivot,graph,activity - [('is_multi_slots', '=', True), ('registration_ids', 'any', [('partner_id.employee', '=', True)])] + [('registration_ids', 'any', [('partner_id.employee', '=', True)])] + {'hr_skills_event_add_employee': True}

Create an Event diff --git a/addons/hr_skills_event/views/hr_resume_line_views.xml b/addons/hr_skills_event/views/hr_resume_line_views.xml index 7498cb4abe2fc5..60254267070b41 100644 --- a/addons/hr_skills_event/views/hr_resume_line_views.xml +++ b/addons/hr_skills_event/views/hr_resume_line_views.xml @@ -6,7 +6,7 @@ - + 0 From be92016abc49ee8b0e84e6670ef849066064580b Mon Sep 17 00:00:00 2001 From: calkikhunt Date: Wed, 19 Aug 2026 14:01:54 +0530 Subject: [PATCH 073/205] [FIX] base: use double-quoted attributes in the auto-generated avatar SVG avatar.mixin._avatar_generate_svg() generated its XML declaration with single-quoted attribute values (). Some libmagic versions/databases classify that specific byte pattern as generic text/xml instead of image/svg+xml, since SVG detection rules commonly key off a double-quoted declaration. Browsers then receive the wrong Content-Type when the auto-generated avatar (used whenever a contact/user has no uploaded photo) is served through /web/image/..., and download it instead of rendering it inline. Use double-quoted attributes throughout the generated markup so the mimetype is sniffed correctly regardless of the installed libmagic version. No other code depends on the exact quoting of this generated SVG (res_users.py and hr_employee.py both only assign the returned bytes to an image field), and avatar fields are computed, non-stored, so nothing needs to be migrated - every record gets the corrected markup on its next read. Part-of: odoo/odoo#283149 Signed-off-by: Ruben Gomes (rugo) --- odoo/addons/base/models/avatar_mixin.py | 8 +++---- odoo/addons/base/tests/test_avatar_mixin.py | 25 ++++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/odoo/addons/base/models/avatar_mixin.py b/odoo/addons/base/models/avatar_mixin.py index 6fdc62e71c6e24..623be4252577ec 100644 --- a/odoo/addons/base/models/avatar_mixin.py +++ b/odoo/addons/base/models/avatar_mixin.py @@ -66,10 +66,10 @@ def _avatar_generate_svg(self): initial = html_escape(self[self._avatar_name_field][0].upper()) bgcolor = get_hsl_from_seed(self[self._avatar_name_field] + str(self.create_date.timestamp() if self.create_date else "")) return b64encode(( - "" - "" - f"" - f"{initial}" + '' + '' + f'' + f'{initial}' "" ).encode()) diff --git a/odoo/addons/base/tests/test_avatar_mixin.py b/odoo/addons/base/tests/test_avatar_mixin.py index 228d088650a5df..0406ed41863093 100644 --- a/odoo/addons/base/tests/test_avatar_mixin.py +++ b/odoo/addons/base/tests/test_avatar_mixin.py @@ -4,6 +4,7 @@ from base64 import b64decode from odoo.tests.common import TransactionCase +from odoo.tools.mimetypes import guess_mimetype class TestAvatarMixin(TransactionCase): @@ -44,23 +45,31 @@ def test_partner_has_avatar_even_if_it_has_no_image(self): def test_content_of_generated_partner_avatar(self): expectedAvatar = ( - "" - "" - "" - "M" + '' + '' + '' + 'M' "" ) self.assertEqual(expectedAvatar, b64decode(self.user_without_image.partner_id.avatar_1920).decode('utf-8')) + def test_generated_partner_avatar_mimetype(self): + # the XML declaration must use double-quoted attributes: some + # libmagic versions/databases misdetect a single-quoted declaration + # as text/xml instead of image/svg+xml, which made browsers download + # the auto-generated avatar instead of rendering it inline. + avatar = b64decode(self.user_without_image.partner_id.avatar_1920) + self.assertEqual(guess_mimetype(avatar), 'image/svg+xml') + def test_partner_without_name_has_default_placeholder_image_as_avatar(self): self.assertEqual(self.user_without_name.partner_id._avatar_get_placeholder(), b64decode(self.user_without_name.partner_id.avatar_1920)) def test_external_partner_has_default_placeholder_image_as_avatar(self): expectedAvatar = ( - "" - "" - "" - "J" + '' + '' + '' + 'J' "" ) self.assertEqual(expectedAvatar, b64decode(self.external_partner.avatar_1920).decode('utf-8')) From cbd441c9b4aa905e3d96a930eaf8c9fc2296b9de Mon Sep 17 00:00:00 2001 From: calkikhunt Date: Wed, 19 Aug 2026 14:01:54 +0530 Subject: [PATCH 074/205] [CLA] Kalkivi Dharmeshbhai Khunt (calkikhunt) signing Individual Contributor License Agreement closes odoo/odoo#283149 Signed-off-by: Ruben Gomes (rugo) --- doc/cla/individual/calkikhunt.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 doc/cla/individual/calkikhunt.md diff --git a/doc/cla/individual/calkikhunt.md b/doc/cla/individual/calkikhunt.md new file mode 100644 index 00000000000000..7cda4160c32fbd --- /dev/null +++ b/doc/cla/individual/calkikhunt.md @@ -0,0 +1,11 @@ +India, 2026-08-19 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Kalkivi Dharmeshbhai Khunt calkikhunt123@gmail.com https://github.com/calkikhunt From 24b4f4748ae81125746b65570c6c65a08d15b4f2 Mon Sep 17 00:00:00 2001 From: "Louis (loti)" Date: Thu, 20 Aug 2026 11:07:37 +0200 Subject: [PATCH 075/205] [FIX] website_sale: make ptav selector more specific The selector was matching unrelated inputs because it wasn't specific enough. closes odoo/odoo#283464 Signed-off-by: Louis Tinel (loti) --- addons/website_sale/static/src/js/variant_mixin.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js index 3dedd8d2fc59e6..56b6bb3ead5467 100644 --- a/addons/website_sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/variant_mixin.js @@ -236,7 +236,8 @@ const VariantMixin = { */ _disableInput(parent, attributeValueId, excludedBy, attributeNames, productName) { const input = parent.querySelector( - `option[value="${attributeValueId}"], input[value="${attributeValueId}"]` + `select.js_variant_change option[value="${attributeValueId}"], + input.js_variant_change[value="${attributeValueId}"]` ); input.classList.add('css_not_available') input.closest('label')?.classList?.add('css_not_available'); From 7e73c10ff9b95ef378d05cb0f72cba6e7dbc90fc Mon Sep 17 00:00:00 2001 From: agbr-odoo Date: Wed, 24 Jun 2026 16:35:01 +0530 Subject: [PATCH 076/205] [FIX] {purchase_,}stock: Show notifcation on replenishing products Currently when the user does manual replenishment no notification is displayed. Steps to produce: - Install Inventory and Purchase - Create a product `Chocolate Icecream` and Enable `Track Inventory` - Purchase > Add a Vendor `Ice cream man` - Reordering rules > Create a new reordering rule and save: - Trigger: Manual - Min: 5 - Max:10 - Press the `Order` button Observed Behavior: No notification is displayed about the newly created purchase order. Root cause: When the Order button is pressed, the action_replenish method is called. This method invokes _procure_orderpoint_confirm at [1]. The _procure_orderpoint_confirm function is responsible for creating procurements from orderpoints. During this process, it retrieves the procurement values that are later used at [2]. However, _prepare_procurement_values only includes the orderpoint in the procurement values when the orderpoint's trigger is set to automatic, and not when it is manual, as shown at [3]. These procurement values are then used by _run_buy to create a purchase order and purchase order line at [4]. Since the orderpoint is not linked to the purchase order line in this case, no matching order is found at [5], which leads to the reported issue. Which commit caused this unintentional behavior? This behavior was unintentionally introduced by commit [6]. That commit fixed an issue where purchase order lines were not being merged for temporary manual orderpoints that are created dynamically based on product demand. Solution: Instead of removing the orderpoint ID from the procurement values, we reuse the same conditions used to identify temporary orderpoints for cleanup at [7]. Based on this, we determine how purchase order lines should be merged in the _run_buy method. With the previous implementation, no orderpoint was included in the procurement values. As a result, the condition at [8] always evaluated to True, causing the system to identify an existing purchase order line for the same product as a merge candidate. This solution allows us to retain that fix as well as avoid the error of notifications not showing up. [1]: https://github.com/odoo/odoo/blob/c076281dedeed2e25844c43c49ec17511434e3fc/addons/stock/models/stock_orderpoint.py#L342-L348 [2]: https://github.com/odoo/odoo/blob/c076281dedeed2e25844c43c49ec17511434e3fc/addons/stock/models/stock_orderpoint.py#L737-L741 [3]: https://github.com/odoo/odoo/blob/c076281dedeed2e25844c43c49ec17511434e3fc/addons/stock/models/stock_orderpoint.py#L687-L701 [4]: https://github.com/odoo/odoo/blob/c076281dedeed2e25844c43c49ec17511434e3fc/addons/purchase_stock/models/stock_rule.py#L156-L165 [5]: https://github.com/odoo/odoo/blob/c076281dedeed2e25844c43c49ec17511434e3fc/addons/purchase_stock/models/stock.py#L276-L296 [6]: https://github.com/odoo/odoo/commit/2a0d2c64d0027f540101447289b4c1a10cb3ecdf [7]: https://github.com/odoo/odoo/blob/c076281dedeed2e25844c43c49ec17511434e3fc/addons/stock/models/stock_orderpoint.py#L365 [8]: https://github.com/odoo/odoo/blob/6a84d3e519892be333552e2e0ebf8da87e0a760c/addons/purchase_stock/models/purchase_order_line.py#L380-L384 opw-6311520 closes odoo/odoo#271993 Signed-off-by: William Henrotin (whe) --- addons/purchase_stock/models/purchase_order_line.py | 8 +++++++- addons/purchase_stock/tests/test_purchase_lead_time.py | 2 +- addons/stock/models/stock_orderpoint.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/addons/purchase_stock/models/purchase_order_line.py b/addons/purchase_stock/models/purchase_order_line.py index 74b9f049f27541..cc87886a653d20 100644 --- a/addons/purchase_stock/models/purchase_order_line.py +++ b/addons/purchase_stock/models/purchase_order_line.py @@ -377,9 +377,15 @@ def _find_candidate(self, product_id, product_qty, product_uom, location_id, nam description_picking = '' if values.get('product_description_variants'): description_picking = values['product_description_variants'] + has_temp_manual_orderpoint = ( + values.get('orderpoint_id') + and values['orderpoint_id'].create_uid.id == SUPERUSER_ID + and values['orderpoint_id'].trigger == 'manual' + ) lines = self.filtered( lambda l: l.propagate_cancel == values['propagate_cancel'] - and (l.orderpoint_id in [values['orderpoint_id'], False] if values['orderpoint_id'] and not values['move_dest_ids'] else True) + and (l.orderpoint_id in [values['orderpoint_id'], False] if values['orderpoint_id'] + and not values['move_dest_ids'] and not has_temp_manual_orderpoint else True) and (l.product_uom_id == product_uom if values.get('force_uom') else True) ) diff --git a/addons/purchase_stock/tests/test_purchase_lead_time.py b/addons/purchase_stock/tests/test_purchase_lead_time.py index aa72140c16ead6..97a99bddcb33d4 100644 --- a/addons/purchase_stock/tests/test_purchase_lead_time.py +++ b/addons/purchase_stock/tests/test_purchase_lead_time.py @@ -310,7 +310,7 @@ def test_merge_po_line_4(self): self.assertEqual(len(orderpoint), 1) # First replenishment trigger - orderpoint.action_replenish() + self.assertTrue(('tag', 'display_notification') in orderpoint.action_replenish().items()) po_line = self.env['purchase.order.line'].search([('product_id', '=', self.product.id)]) self.assertEqual(len(po_line), 1, 'A purchase order line should be created') self.assertEqual(po_line.product_qty, 5) diff --git a/addons/stock/models/stock_orderpoint.py b/addons/stock/models/stock_orderpoint.py index 30e7e97f7881e1..f2c6d38442998b 100644 --- a/addons/stock/models/stock_orderpoint.py +++ b/addons/stock/models/stock_orderpoint.py @@ -697,7 +697,7 @@ def _prepare_procurement_values(self, date=False): 'date_order': dates_info['date_order'], 'date_deadline': date or False, 'warehouse_id': self.warehouse_id, - 'orderpoint_id': self.trigger == 'auto' and self, + 'orderpoint_id': self, } reference = self.env.context.get('origins') if reference: From 4291b65978287b737a2e094d3122c20ed8564852 Mon Sep 17 00:00:00 2001 From: Jinjiu Liu Date: Wed, 24 Jun 2026 17:43:31 +0200 Subject: [PATCH 077/205] [FIX] html_editor*: select name of followable field on click by default *: project Before this commit: when clicking a field having sub fields (canFollowRelationFor is true), we just return this field's id, which is not very useful in most cases. After this commit: We created subclass of DynamicPlaceholderPopover, EditorDynamicPlaceholderPopover, which uses EditorModelFieldSelectorPopover. We use the display name of the followable field by default and if the user really want the id, they may choose the id subfield. We also show the followable field's name as the default placeholder instead of "Display name". task-6265223 closes odoo/odoo#272129 Related: odoo/enterprise#121785 Signed-off-by: David Monjoie (dmo) --- addons/html_editor/__manifest__.py | 2 + .../src/others/dynamic_placeholder_plugin.js | 4 +- .../editor_dynamic_placeholder_popover.js | 50 +++++++++++++++++ .../editor_dynamic_placeholder_popover.xml | 20 +++++++ .../static/tests/dynamic_placeholder.test.js | 54 +++++++++++++++++-- addons/project/__manifest__.py | 2 + 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.js create mode 100644 addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.xml diff --git a/addons/html_editor/__manifest__.py b/addons/html_editor/__manifest__.py index 97ef0da2fb50a5..6f0356c53e7ea2 100644 --- a/addons/html_editor/__manifest__.py +++ b/addons/html_editor/__manifest__.py @@ -34,6 +34,8 @@ 'web.assets_backend': [ ('include', 'html_editor.assets_editor'), 'html_editor/static/src/others/dynamic_placeholder_plugin.js', + 'html_editor/static/src/others/editor_dynamic_placeholder_popover.js', + 'html_editor/static/src/others/editor_dynamic_placeholder_popover.xml', 'html_editor/static/src/backend/**/*', 'html_editor/static/src/fields/**/*', 'html_editor/static/lib/vkbeautify/**/*', diff --git a/addons/html_editor/static/src/others/dynamic_placeholder_plugin.js b/addons/html_editor/static/src/others/dynamic_placeholder_plugin.js index 49a7de21e1961c..c3c0ba5cbba84d 100644 --- a/addons/html_editor/static/src/others/dynamic_placeholder_plugin.js +++ b/addons/html_editor/static/src/others/dynamic_placeholder_plugin.js @@ -1,8 +1,8 @@ import { Plugin } from "@html_editor/plugin"; import { _t } from "@web/core/l10n/translation"; -import { DynamicPlaceholderPopover } from "@web/views/fields/dynamic_placeholder_popover"; import { withSequence } from "@html_editor/utils/resource"; import { isHtmlContentSupported } from "@html_editor/core/selection_plugin"; +import { EditorDynamicPlaceholderPopover } from "./editor_dynamic_placeholder_popover"; /** * @typedef {Object} DynamicPlaceholderShared @@ -39,7 +39,7 @@ export class DynamicPlaceholderPlugin extends Plugin { this.defaultResModel = this.config.dynamicPlaceholderResModel; /** @type {import("@html_editor/core/overlay_plugin").Overlay} */ - this.overlay = this.dependencies.overlay.createOverlay(DynamicPlaceholderPopover, { + this.overlay = this.dependencies.overlay.createOverlay(EditorDynamicPlaceholderPopover, { hasAutofocus: true, className: "popover", }); diff --git a/addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.js b/addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.js new file mode 100644 index 00000000000000..5a507add6d0c44 --- /dev/null +++ b/addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.js @@ -0,0 +1,50 @@ +import { DynamicPlaceholderPopover } from "@web/views/fields/dynamic_placeholder_popover"; +import { ModelFieldSelectorPopover } from "@web/core/model_field_selector/model_field_selector_popover"; + +class EditorModelFieldSelectorPopover extends ModelFieldSelectorPopover { + // When clicking on a field of which we can follow relation, we return the + // display name by default. + async selectFieldDisplayname(fieldDef) { + const { modelsInfo } = await this.keepLast.add( + this.fieldService.loadPath( + fieldDef.is_property ? fieldDef.relation : this.state.page.resModel, + `${fieldDef.name}.*` + ) + ); + const { fieldDefs } = modelsInfo.at(-1); + const fieldName = `${fieldDef.name}.display_name`; + const fieldData = fieldDefs.display_name; + this.state.label = fieldDef.string; + return [fieldName, fieldData]; + } + + async selectField(field) { + if (field.type === "properties") { + return this.followRelation(field); + } + this.state.isFollowable = this.canFollowRelationFor(field); + const [fieldName, fieldData] = this.state.isFollowable + ? await this.selectFieldDisplayname(field) + : [field.name, field]; + this.keepLast.add(Promise.resolve()); + this.state.page.selectedName = fieldName; + if (this.state.isFollowable) { + this.props.update(this.state.page.path, fieldData, this.state.label); + } else { + this.props.update(this.state.page.path, fieldData); + } + this.props.close(true); + } +} + +export class EditorDynamicPlaceholderPopover extends DynamicPlaceholderPopover { + static template = "html_editor.EditorDynamicPlaceholderPopover"; + static components = { + EditorModelFieldSelectorPopover, + }; + setPath(path, fieldInfo, forceLabel = null) { + this.state.path = path; + this.state.fieldName = forceLabel || fieldInfo?.string; + this.fieldType = fieldInfo?.type; + } +} diff --git a/addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.xml b/addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.xml new file mode 100644 index 00000000000000..a65b1d8b32739b --- /dev/null +++ b/addons/html_editor/static/src/others/editor_dynamic_placeholder_popover.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/addons/html_editor/static/tests/dynamic_placeholder.test.js b/addons/html_editor/static/tests/dynamic_placeholder.test.js index 2e937a30d0bbde..b0b164d12b9778 100644 --- a/addons/html_editor/static/tests/dynamic_placeholder.test.js +++ b/addons/html_editor/static/tests/dynamic_placeholder.test.js @@ -1,9 +1,18 @@ import { expect, test } from "@odoo/hoot"; import { animationFrame } from "@odoo/hoot-mock"; -import { click, manuallyDispatchProgrammaticEvent, press } from "@odoo/hoot-dom"; +import { click, manuallyDispatchProgrammaticEvent, press, queryFirst } from "@odoo/hoot-dom"; import { MAIN_PLUGINS } from "@html_editor/plugin_sets"; import { DYNAMIC_PLACEHOLDER_PLUGINS } from "@html_editor/backend/plugin_sets"; -import { defineModels, models, onRpc, serverState } from "@web/../tests/web_test_helpers"; +import { unformat } from "@html_editor/../tests/_helpers/format"; +import { getContent } from "@html_editor/../tests/_helpers/selection"; +import { + defineModels, + models, + fields, + onRpc, + serverState, + contains, +} from "@web/../tests/web_test_helpers"; import { setupEditor } from "./_helpers/editor"; import { insertText } from "./_helpers/user_actions"; @@ -16,9 +25,20 @@ class ResUsers extends models.Model { ]; } +class OneModel extends models.Model { + name = fields.Char({ string: "The many2one model name" }); +} + +class SomeModel extends models.Model { + _name = "some.model"; + + field = fields.Char({ string: "My little field" }); + many2one_model_id = fields.Many2one({ relation: "one.model" }); +} + onRpc("has_group", () => true); onRpc("mail_allowed_qweb_expressions", () => []); -defineModels([ResUsers]); +defineModels([ResUsers, OneModel, SomeModel]); test("inserted value from dynamic placeholder should contain the data-oe-t-inline attribute", async () => { const { editor } = await setupEditor("

test[]

", { @@ -59,3 +79,31 @@ test("inserted value from dynamic placeholder should contain the data-oe-t-inlin expect("t[data-oe-t-inline]").toHaveCount(1); }); + +test("add many2one dynamic placeholder should take the name by default", async () => { + const { editor, el } = await setupEditor(`
[hop hop]
`, { + config: { + Plugins: [...MAIN_PLUGINS, ...DYNAMIC_PLACEHOLDER_PLUGINS], + dynamicPlaceholderResModel: "some.model", + }, + }); + await insertText(editor, "/"); + await contains(".o-we-powerbox .o-we-command-name:contains(/^Dynamic Placeholder$/)").click(); + + await contains( + ".o_model_field_selector_popover_page li[data-name='many2one_model_id'] button" + ).click(); + expect(queryFirst(".o_model_field_selector_popover span")).toHaveText("Many2one model"); + + await contains(".o_model_field_selector_popover button.btn-primary").click(); + await animationFrame(); + expect(getContent(el)).toBe( + unformat(` +


+
+ [] +
+


+ `) + ); +}); diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 2c4baf12f8bebe..d4892415899e07 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -218,6 +218,8 @@ ('include', 'html_editor.assets_editor'), 'html_editor/static/src/others/dynamic_placeholder_plugin.js', + 'html_editor/static/src/others/editor_dynamic_placeholder_popover.js', + 'html_editor/static/src/others/editor_dynamic_placeholder_popover.xml', 'html_editor/static/src/backend/**/*', 'html_editor/static/src/fields/**/*', 'html_editor/static/src/scss/html_editor.common.scss', From b166b1299a63b4f78e32cbfd6ee426caacfbbb4d Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Mon, 17 Aug 2026 15:01:46 -0700 Subject: [PATCH 078/205] [FIX] stock_account: show unbalanced variation for products with no qty Problem: If multiple valuation/variation accounts are used, the Inventory Valuation report will not show the balance of accounts attached to products that have 0 quantity available if a different account has quantity. Solution: In order to maintain the performance improvements intended by the commit that introduced the `qty_available != 0` filter, we will avoid calculating `total_value` for products with 0 quantity. We will still run `stock_accounting_value` on these products in order to capture interim accounting value on the Inventory Valuation report. Steps to Reproduce (Runbot v19): (defer to the test for more info) 1. Create an extra set of valuation/variation accounts 2. Create a product, avco perpetual accounting the default valuation/variation accounts 3. Create a second product, avco perpetual accounting the new valuation/variation accounts 4. Purchase 1 unit of each of the products and receive, bill both 5. Sell 1 unit of the product attached to the new valuation/variation 6. Go to Accounting > Review > Inventory Valuation, and note that the new valuation/variation accounts are not present. If you click Generate Entry, you will see that these accounts need to be balanced opw-6473319 closes odoo/odoo#282819 Signed-off-by: William Henrotin (whe) --- .../report/stock_valuation_report.py | 12 +-- .../tests/test_stockvaluation.py | 94 +++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/addons/stock_account/report/stock_valuation_report.py b/addons/stock_account/report/stock_valuation_report.py index 9dd043b04fc49a..6586e034139f8d 100644 --- a/addons/stock_account/report/stock_valuation_report.py +++ b/addons/stock_account/report/stock_valuation_report.py @@ -43,16 +43,16 @@ def _get_report_data(self, date=False, product_category=False, warehouse=False): )._with_valuation_context() if date: valued_product_context = valued_product_context.with_context(at_date=date, to_date=date) - valued_products = valued_product_context.search( - company._get_valuation_product_domain() - + ['|', ('qty_available', '!=', 0), ('lot_valuated', '=', True)] - ) + domain = company._get_valuation_product_domain() + valued_products = valued_product_context.search(domain) + products_with_qty = valued_product_context.search(domain + ['|', ('qty_available', '!=', 0), ('lot_valuated', '=', True)]) accounts_by_product = company._get_accounts_by_product(products=valued_products) + accounts_by_product_with_qty = {p: accounts_by_product[p] for p in products_with_qty} if not date: - inventory_data = company.stock_value(accounts_by_product) + inventory_data = company.stock_value(accounts_by_product_with_qty) accounting_data = company.stock_accounting_value(accounts_by_product) else: - inventory_data = company.stock_value(accounts_by_product, at_date=date) + inventory_data = company.stock_value(accounts_by_product_with_qty, at_date=date) accounting_data = company.stock_accounting_value(accounts_by_product, at_date=date) accounts = inventory_data.keys() | accounting_data.keys() diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index 78af9653fd2438..c6a5b0bd36d176 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -3669,3 +3669,97 @@ def test_update_standard_price_with_limited_access_users(self): # Ensure that we didn't do 109 / 9 to compute the price self.assertEqual(product.standard_price, 1.0) + + def test_report_includes_account_with_zero_qty_products_but_non_zero_balance(self): + """ + Verify that the valuation report includes accounts that have non-zero + accounting balances, even if all products associated with those accounts + have zero quantity available. + """ + # Ensure we are in a clean state regarding locations + valued_locations = self.env['stock.location'].with_context(active_test=False).search( + [('is_valued_internal', '=', True)] + ) + self.assertTrue(valued_locations, "Should have at least one valued location") + + # Create two valuation accounts + account_a = self.env['account.account'].create({ + 'name': 'Valuation Account A', + 'code': 'VAL.A', + 'account_type': 'asset_current', + 'reconcile': True, + }) + account_b = self.env['account.account'].create({ + 'name': 'Valuation Account B', + 'code': 'VAL.B', + 'account_type': 'asset_current', + 'reconcile': True, + }) + + # Create two categories, one for each account + categ_a = self.env['product.category'].create({ + 'name': 'Category A', + 'property_valuation': 'real_time', + 'property_cost_method': 'fifo', + 'property_stock_valuation_account_id': account_a.id, + }) + categ_b = self.env['product.category'].create({ + 'name': 'Category B', + 'property_valuation': 'real_time', + 'property_cost_method': 'fifo', + 'property_stock_valuation_account_id': account_b.id, + }) + + # Create products + product_a = self.env['product.product'].create({ + 'name': 'Product A', + 'is_storable': True, + 'categ_id': categ_a.id, + }) + product_b = self.env['product.product'].create({ + 'name': 'Product B', + 'is_storable': True, + 'categ_id': categ_b.id, + }) + + # 1. Product A has inventory (Account A) + self._make_in_move(product_a, 10, unit_cost=10) + # Ensure qty_available is computed in the correct context + self.assertEqual(product_a.with_context(location=valued_locations.ids).qty_available, 10) + + # 2. Product B has zero quantity but a non-zero accounting balance (Account B) + journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.env.company.id)], limit=1) + counterpart_account = self.env.company.account_journal_suspense_account_id or account_a + + self.env['account.move'].create({ + 'journal_id': journal.id, + 'line_ids': [ + (0, 0, { + 'name': 'Simulated discrepancy', + 'account_id': account_b.id, + 'debit': 100, + 'credit': 0, + }), + (0, 0, { + 'name': 'Counterpart', + 'account_id': counterpart_account.id, + 'debit': 0, + 'credit': 100, + }), + ] + }).action_post() + + self.assertEqual(product_b.with_context(location=valued_locations.ids).qty_available, 0) + + # Get report data + report_data = self.env['stock_account.stock.valuation.report'].with_company(self.env.company)._get_report_data() + + # Check that both accounts are in the report + account_ids_in_report = report_data['accounts_by_id'].keys() + self.assertIn(account_a.id, account_ids_in_report, "Account A should be in the report (has qty)") + self.assertIn(account_b.id, account_ids_in_report, f"Account B (ID {account_b.id}) should be in the report (has balance but 0 qty). Found accounts: {list(account_ids_in_report)}") + + # Specifically, check initial_balance or ending_stock for Account B + initial_balance = report_data['initial_balance'] + self.assertEqual(initial_balance['lines_by_account_id'][account_b.id]['value'], 100, + "Account B should show its 100 balance in the report data") From 4c00d5cac387c7ef2b268a5e42f18daff5b363f0 Mon Sep 17 00:00:00 2001 From: khah-odoo Date: Wed, 19 Aug 2026 14:36:23 +0200 Subject: [PATCH 079/205] [FIX] hr_attendance: exclude attendance ending at day start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When recomputing overtime, attendances overlapping the affected day are retrieved based on their check-in and check-out. An attendance whose check-out is exactly at the start of the following day is currently considered to overlap that day because the domain uses an inclusive lower bound on `check_out`. This can cause overtime from the previous day to be recomputed using an incomplete set of attendances. Steps to reproduce: * Configure an employee with a daily quantity overtime rule based on the expected hours from the contract. * On the first day, create multiple attendances, with the last one ending exactly at midnight. * Ensure the total worked hours on that day result in overtime. * On the following day, create another attendance. * Observe that recomputing the second day's overtime also retrieves the attendance ending at midnight. * The previous day's overtime is then recomputed without the other attendances from that day, resulting in an incorrect overtime value. * Regenerating the overtime ruleset restores the correct value. For example, with 8.4 expected hours: ``` Day 1: 09:30 - 11:30 14:30 - 18:19 21:00 - 00:00 Day 2: create/update an attendance ``` The `21:00 - 00:00` attendance is incorrectly included in Day 2's recomputation because its check-out equals the start of Day 2. The other Day 1 attendances are not included, so Day 1 is recomputed from only 3 hours of work. To fix the issue we treat `check_out` as an exclusive interval boundary when determining overlap. An attendance ending exactly at the start of a day does not overlap that day, while attendances actually crossing midnight continue to be included. opw-5474120 closes odoo/odoo#283226 Signed-off-by: Mélanie Peyrat (mepe) --- addons/hr_attendance/models/hr_attendance.py | 2 +- .../tests/test_hr_attendance_overtime.py | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/addons/hr_attendance/models/hr_attendance.py b/addons/hr_attendance/models/hr_attendance.py index 459dc5aa2b603d..3330316f063783 100644 --- a/addons/hr_attendance/models/hr_attendance.py +++ b/addons/hr_attendance/models/hr_attendance.py @@ -285,7 +285,7 @@ def _get_overtimes_to_update_domain(self): domain_list.append(Domain.AND([ Domain('employee_id', '=', employee.id), Domain('check_in', '<=', tz.localize(datetime.combine(date_to, datetime.max.time())).astimezone(utc).replace(tzinfo=None)), - Domain('check_out', '>=', tz.localize(datetime.combine(date_from, datetime.min.time())).astimezone(utc).replace(tzinfo=None)), + Domain('check_out', '>', tz.localize(datetime.combine(date_from, datetime.min.time())).astimezone(utc).replace(tzinfo=None)), ])) if not domain_list: return Domain.FALSE diff --git a/addons/hr_attendance/tests/test_hr_attendance_overtime.py b/addons/hr_attendance/tests/test_hr_attendance_overtime.py index 2413be7a56b0ec..81957c370f04d8 100644 --- a/addons/hr_attendance/tests/test_hr_attendance_overtime.py +++ b/addons/hr_attendance/tests/test_hr_attendance_overtime.py @@ -1774,6 +1774,37 @@ def test_overtime_recomputation_attendance_overlapping_midnight(self): }) self.assertEqual(attendance2.overtime_hours, 4.0, "The whole attendance should be in overtime.") + def test_overtime_recomputation_attendance_ending_at_midnight(self): + self.env['hr.attendance'].create({ + 'employee_id': self.employee.id, + 'check_in': datetime(2021, 1, 4, 8, 0), + 'check_out': datetime(2021, 1, 4, 17, 0), + }) + attendance = self.env['hr.attendance'].create({ + 'employee_id': self.employee.id, + 'check_in': datetime(2021, 1, 4, 21, 0), + 'check_out': datetime(2021, 1, 5, 0, 0), + }) + + self.assertEqual( + attendance.overtime_hours, + 3.0, + "There should be 3 hours of overtime on the 4th.", + ) + + self.env['hr.attendance'].create({ + 'employee_id': self.employee.id, + 'check_in': datetime(2021, 1, 5, 8, 0), + 'check_out': datetime(2021, 1, 5, 17, 0), + }) + + self.assertEqual( + attendance.overtime_hours, + 3.0, + "An attendance ending at midnight should not be recomputed " + "when updating overtime for the following day.", + ) + def test_weekly_overtime_flexible_resource_public_holiday(self): self.ruleset.rule_ids.write({ 'expected_hours_from_contract': True, From 0a00f59b5950d3497895e699e91a1cd30a03316a Mon Sep 17 00:00:00 2001 From: romo Date: Mon, 10 Aug 2026 11:14:52 +0200 Subject: [PATCH 080/205] [FIX] website_blog: enable debug before dynamic snippet tour The blog post dynamic snippet options tour needs debug mode because the dynamic snippet belongs to the Debug snippet group. The tour used to put `debug=1` in the preview iframe path, while the initial website preview client action was opened without debug. This meant `request.session.debug` was only updated once the iframe request was handled. If the snippet template was rendered before that request, QWeb used the empty session debug value and omitted the Debug snippet group. After this commit, we open the preview action directly in edit and debug mode from the Python test instead, so the first server request sets `request.session.debug` before the website builder loads the snippets. closes odoo/odoo#281433 Signed-off-by: Francois Georis (fge) --- .../static/tests/tours/blog_posts_dynamic_snippet.js | 2 +- addons/website_blog/tests/test_ui.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website_blog/static/tests/tours/blog_posts_dynamic_snippet.js b/addons/website_blog/static/tests/tours/blog_posts_dynamic_snippet.js index 890e69a0b9f7e6..24851d0f0f42a0 100644 --- a/addons/website_blog/static/tests/tours/blog_posts_dynamic_snippet.js +++ b/addons/website_blog/static/tests/tours/blog_posts_dynamic_snippet.js @@ -21,7 +21,7 @@ const blogPostsSnippet = { registerWebsitePreviewTour( "blog_posts_dynamic_snippet_options", { - url: "/?debug=1", + url: "/", edition: true, }, () => [ diff --git a/addons/website_blog/tests/test_ui.py b/addons/website_blog/tests/test_ui.py index 40f46d0e959bdd..665f4e96097c5d 100644 --- a/addons/website_blog/tests/test_ui.py +++ b/addons/website_blog/tests/test_ui.py @@ -139,7 +139,7 @@ def test_sidebar_with_date_and_tag(self): self.start_tour("/blog", "blog_tags_with_date", login="admin") def test_blog_posts_dynamic_snippet_options(self): - self.start_tour(self.env['website'].get_client_action_url('/'), 'blog_posts_dynamic_snippet_options', login='admin') + self.start_tour(self.env['website'].get_client_action_url('/', True, True), 'blog_posts_dynamic_snippet_options', login='admin') def test_blog_posts_dynamic_snippet_visibility(self): # Checks snippets visibility with or without content. From 57c7c9938725d392a6f2cd6c89a861d2a8385c44 Mon Sep 17 00:00:00 2001 From: "Tudor-Calin Panzaru (tupan)" Date: Tue, 14 Jul 2026 09:44:05 +0200 Subject: [PATCH 081/205] [FIX] payment_stripe: avoid duplicate refund txs from webhooks Steps to reproduce: - Configure Stripe with manual capture. - Authorize and capture an online payment. - Refund the captured payment from Odoo. - Let the `charge.refunded` webhook be processed. The refund initiated from Odoo is created as a child of the capture transaction, while the webhook resolves the charge to the source transaction. The webhook only checked direct refund children of that source transaction, so it missed the existing refund and created a second refund transaction with the same Stripe refund reference. Look up existing Stripe refund transactions in the child and grandchild transactions of the source transaction before creating webhook refund transactions, so the webhook recognizes refunds already created under capture children. opw-6359020 closes odoo/odoo#276154 Signed-off-by: Valentin Chevalier --- addons/payment_stripe/controllers/main.py | 5 ++- .../payment_stripe/tests/test_refund_flows.py | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index 2971eb00686ec2..11f2680bffb806 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -129,7 +129,10 @@ def stripe_webhook(self): has_more = additional_refunds['has_more'] # Process the refunds for which a refund transaction has not been created yet. - processed_refund_ids = tx_sudo.child_transaction_ids.filtered( + # Include refunds of capture transactions, as they are grandchildren of the + # source transaction found from the charge. + child_txs = tx_sudo.child_transaction_ids + processed_refund_ids = (child_txs | child_txs.child_transaction_ids).filtered( lambda tx: tx.operation == 'refund' ).mapped('provider_reference') for refund in filter(lambda r: r['id'] not in processed_refund_ids, refunds): diff --git a/addons/payment_stripe/tests/test_refund_flows.py b/addons/payment_stripe/tests/test_refund_flows.py index 70239525113bb8..e08ea458cbb491 100644 --- a/addons/payment_stripe/tests/test_refund_flows.py +++ b/addons/payment_stripe/tests/test_refund_flows.py @@ -48,6 +48,39 @@ def test_canceled_refund_webhook_notification_triggers_processing(self): self._make_json_request(url, data=self.canceled_refund_payment_data) self.assertEqual(process_mock.call_count, 1) + @mute_logger( + 'odoo.addons.payment_stripe.controllers.main', + 'odoo.addons.payment_stripe.models.payment_transaction', + ) + def test_refund_webhook_notification_matches_refund_of_capture_child(self): + """ Test that refund webhooks match refunds created from capture transactions. """ + source_tx = self._create_transaction('direct', state='done') + capture_tx = source_tx._create_child_transaction( + source_tx.amount, state='done', provider_reference='pi_capture' + ) + refund_tx = capture_tx._create_child_transaction( + capture_tx.amount, + is_refund=True, + state='done', + provider_reference=self.refund_object['id'], + ) + url = self._build_url(StripeController._webhook_url) + payload = dict(self.refund_payment_data) + payload['data'] = dict(payload['data']) + payload['data']['object'] = dict(payload['data']['object'], captured=True) + with patch( + 'odoo.addons.payment_stripe.controllers.main.StripeController._verify_signature' + ), patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction._process' + ) as process_mock: + self._make_json_request(url, data=payload) + refund_txs = self.env['payment.transaction'].search([ + ('operation', '=', 'refund'), + ('source_transaction_id', 'in', (source_tx | capture_tx).ids), + ]) + self.assertEqual(process_mock.call_count, 0) + self.assertEqual(refund_txs, refund_tx) + @mute_logger( 'odoo.addons.payment_stripe.controllers.main', 'odoo.addons.payment_stripe.models.payment_transaction', From 6a3655c8efd2de68754ace38bd1ab5eb6499faca Mon Sep 17 00:00:00 2001 From: Jeremy Bethmont Date: Tue, 18 Aug 2026 17:29:52 +0200 Subject: [PATCH 082/205] [CLA] erpvibe: sign corporate CLA closes odoo/odoo#283477 Signed-off-by: Martin Trigaux (mat) --- doc/cla/corporate/erpvibe.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 doc/cla/corporate/erpvibe.md diff --git a/doc/cla/corporate/erpvibe.md b/doc/cla/corporate/erpvibe.md new file mode 100644 index 00000000000000..1c357441a10cfd --- /dev/null +++ b/doc/cla/corporate/erpvibe.md @@ -0,0 +1,15 @@ +Hong Kong, 2026-08-18 + +ERPVibe Limited agrees to the terms of the Odoo Corporate Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Jérémy Bethmont jeremy@erpvibe.com https://github.com/jerem + +List of contributors: + +Jérémy Bethmont jeremy@erpvibe.com https://github.com/jerem From 42eb341273a1c3a175a93d9ee3c56b18494f4f87 Mon Sep 17 00:00:00 2001 From: Dirk Douglas Date: Mon, 20 Jul 2026 12:34:44 -0400 Subject: [PATCH 083/205] [FIX] website: Make page caching logic consistent when cache expires **Problem:** When the cached response expires, a new response is retrieved, then cached. However, unlike the initial caching, it is not checked if the response is not None or is allowed to be cached, and `flatten()` is not called on the response. Not calling `flatten()` rarely causes an issue as `flatten()` is usually called later which propagates to the cache value as it is the same object referenced. In cases where it is not called, trying to use the cache value will cause a traceback when accessing `response.response[0]`. Additionally, if the response is None, it should not be cached at all, and the same goes for `_allow_cache_insertion()`. **Solution:** When the cached response is too old, ensure there is a response, call `flatten()` on it, and ensure it is allowed to be cached before caching it. opw-6382359 closes odoo/odoo#277590 Signed-off-by: Habib Ayob (ayh) --- addons/website/models/website_page.py | 5 ++++- addons/website/tests/test_page.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py index 536fe7a0a0deb5..16178800e30080 100644 --- a/addons/website/models/website_page.py +++ b/addons/website/models/website_page.py @@ -396,7 +396,10 @@ def _get_response(self, request): # The cached response is too old and considered out-of-date. Get it # from scratch and update the cache accordingly. response = self._get_response_raw(request) - self._get_response_cached.__cache__.add_value(self, request, cache_value=(response, cache_key)) + if response: + response.flatten() + if self._allow_cache_insertion(response.response[-1]): + self._get_response_cached.__cache__.add_value(self, request, cache_value=(response, cache_key)) return response return self._get_response_raw(request) diff --git a/addons/website/tests/test_page.py b/addons/website/tests/test_page.py index 8a8c1cd2e2b292..3d92dd19df8103 100644 --- a/addons/website/tests/test_page.py +++ b/addons/website/tests/test_page.py @@ -2,6 +2,8 @@ from lxml import html from unittest.mock import patch +from freezegun import freeze_time +from datetime import date from odoo.addons.website.controllers.main import Website from odoo.addons.http_routing.tests.common import MockRequest @@ -280,6 +282,20 @@ def test_unpublished_page(self): self.assertEqual(r.status_code, 200, "Admin should see the specific unpublished page") self.assertEqual('I am a specific page' in r.text, True, "Admin should see the specific unpublished page") + def test_unpublished_page_no_cache(self): + """Ensure that a previously-published page that is now unpublished will not be cached.""" + self.authenticate(None, None) + + with freeze_time(date(2025, 12, 30)): + r = self.url_open(self.page.url) + self.assertEqual(r.status_code, 200, "Restricted users should see the published page") + + self.page.write({'is_published': False}) + + with freeze_time(date(2025, 12, 31)): + r = self.url_open(self.page.url) + self.assertEqual(r.status_code, 404, "Restricted users should see a 404 as the page is unpublished") + @mute_logger('odoo.addons.rpc.controllers.xmlrpc') def test_search(self): dbname = common.get_db_name() From e254904b51b88bf40de17ecc745901a856e20e5a Mon Sep 17 00:00:00 2001 From: dhba Date: Fri, 7 Aug 2026 14:25:03 +0530 Subject: [PATCH 084/205] [FIX] html_editor: handle multiple table header rows on paste Steps to Reproduce - Copy a table with multiple header rows from Google Docs. - Paste the table into the editor. Issue: - The pasted table contains multiple header rows, but the editor supports only the first row as the table header row. Cause: - During paste, `cleanForPaste` does not handle tables with multiple header rows - As a result, header cells (``) in rows other than the first row remain as header cells instead of being converted to normal table cells (``). Solution: - Update `cleanForPaste` to handle tables with multiple header rows. - If a table contains `` elements in any row other than the first row, replace those `` elements with `` elements. - This ensures that only the first row is treated as the table header row. task-6455248 closes odoo/odoo#281234 Signed-off-by: David Monjoie (dmo) --- .../static/src/core/clipboard_plugin.js | 6 ++++ addons/html_editor/static/tests/paste.test.js | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/addons/html_editor/static/src/core/clipboard_plugin.js b/addons/html_editor/static/src/core/clipboard_plugin.js index 858538650e758c..3e1fcfc8d83fc8 100644 --- a/addons/html_editor/static/src/core/clipboard_plugin.js +++ b/addons/html_editor/static/src/core/clipboard_plugin.js @@ -16,6 +16,7 @@ import { } from "@html_editor/utils/base_container"; import { DIRECTIONS } from "../utils/position"; import { isHtmlContentSupported } from "./selection_plugin"; +import { getRowIndex } from "@html_editor/utils/table"; /** * @typedef { import("./selection_plugin").EditorSelection } EditorSelection @@ -494,6 +495,11 @@ export class ClipboardPlugin extends Plugin { } } else if (node.nodeType !== Node.TEXT_NODE) { if (["TD", "TH"].includes(node.nodeName)) { + // Convert table headers to cells when they are not + // in the first row. + if (node.nodeName === "TH" && getRowIndex(node) !== 0) { + node = this.dependencies.dom.setTagName(node, "td"); + } // Insert base container into empty TD. if (isEmptyBlock(node)) { const baseContainer = this.dependencies.baseContainer.createBaseContainer(); diff --git a/addons/html_editor/static/tests/paste.test.js b/addons/html_editor/static/tests/paste.test.js index 752a3a1ad04119..bd862a5a5d4392 100644 --- a/addons/html_editor/static/tests/paste.test.js +++ b/addons/html_editor/static/tests/paste.test.js @@ -110,6 +110,39 @@ describe("Html Paste cleaning - whitelist", () => { }); }); + test("should convert table headers in non-first rows to normal cells on paste", async () => { + await testEditor({ + contentBefore: ` +

[]

+ `, + stepFunction: async (editor) => { + pasteHtml( + editor, + unformat(` + + + + + + + + +
Header 1
Header 2
Cell
+ `) + ); + }, + contentAfter: unformat(` + + + + + + +
Header 1
Header 2
Cell[]
+ `), + }); + }); + test("should not keep span", async () => { await testEditor({ contentBefore: "

123[]

", From 81a79ab8d37a416a2cc3c2ec91aae48ec1aded68 Mon Sep 17 00:00:00 2001 From: Arnav Varshney Date: Fri, 12 Jun 2026 11:29:27 +0800 Subject: [PATCH 085/205] [ADD] l10n_lk_invoice: Sri Lanka tax invoice sequence and report layout This commit introduces the `l10n_lk_invoice` module to support specific tax invoicing requirements for the Sri Lankan localization. Key features include: * Custom Sequence Format: Implements the mandatory Sri Lankan tax invoice sequence format `YYMMM_QQQQ_XXXXX` (e.g., `26MAY_BRN01_00001`), utilizing the journal code as the `QQQQ` component. * VAT Registration Tracking: Adds a `l10n_lk_vat_registered` boolean field to `res.partner` and `res.company`. This auto-computes based on the Sri Lankan VAT format (requiring >= 13 digits and ending in the "7000" suffix). * PDF Report Modifications: * Replaces the "Invoice" title with "Tax Invoice" when both the supplier and the customer are VAT registered, AND the invoice contains taxable supplies (excludes fully exempt invoices). * Replaces "Delivery Date" with "Supply Date" on tax invoices. * Injects "Mode of Payment" into the document header when a preferred payment method is selected on a tax invoice. * Resequencing Wizard Support: Overrides `account.resequence.wizard` to seamlessly handle Sri Lanka's specific month abbreviation formatting during mass resequencing. Task-6209151 closes odoo/odoo#273592 Signed-off-by: Nicolas Viseur (vin) --- .weblate.json | 6 + addons/l10n_lk_invoice/__init__.py | 3 + addons/l10n_lk_invoice/__manifest__.py | 28 + addons/l10n_lk_invoice/models/__init__.py | 6 + addons/l10n_lk_invoice/models/account_move.py | 274 ++++++ .../models/account_resequence.py | 76 ++ addons/l10n_lk_invoice/models/res_company.py | 15 + addons/l10n_lk_invoice/models/res_partner.py | 25 + addons/l10n_lk_invoice/tests/__init__.py | 3 + .../tests/test_lk_tax_invoice_sequence.py | 799 ++++++++++++++++++ .../l10n_lk_invoice/views/report_invoice.xml | 72 ++ .../views/res_company_views.xml | 13 + .../views/res_partner_views.xml | 14 + 13 files changed, 1334 insertions(+) create mode 100644 addons/l10n_lk_invoice/__init__.py create mode 100644 addons/l10n_lk_invoice/__manifest__.py create mode 100644 addons/l10n_lk_invoice/models/__init__.py create mode 100644 addons/l10n_lk_invoice/models/account_move.py create mode 100644 addons/l10n_lk_invoice/models/account_resequence.py create mode 100644 addons/l10n_lk_invoice/models/res_company.py create mode 100644 addons/l10n_lk_invoice/models/res_partner.py create mode 100644 addons/l10n_lk_invoice/tests/__init__.py create mode 100644 addons/l10n_lk_invoice/tests/test_lk_tax_invoice_sequence.py create mode 100644 addons/l10n_lk_invoice/views/report_invoice.xml create mode 100644 addons/l10n_lk_invoice/views/res_company_views.xml create mode 100644 addons/l10n_lk_invoice/views/res_partner_views.xml diff --git a/.weblate.json b/.weblate.json index c01b2738cbf05e..a54383c192a604 100644 --- a/.weblate.json +++ b/.weblate.json @@ -2647,6 +2647,12 @@ "new_base": "addons/l10n_lb_account/i18n/l10n_lb_account.pot", "language_regex": "^(ar|fr)$" }, + { + "name": "l10n_lk_invoice", + "filemask": "addons/l10n_lk_invoice/i18n/*.po", + "new_base": "addons/l10n_lk_invoice/i18n/l10n_lk_invoice.pot", + "language_regex": "^(si|ta)$" + }, { "name": "l10n_lt", "filemask": "addons/l10n_lt/i18n/*.po", diff --git a/addons/l10n_lk_invoice/__init__.py b/addons/l10n_lk_invoice/__init__.py new file mode 100644 index 00000000000000..d6210b1285d37e --- /dev/null +++ b/addons/l10n_lk_invoice/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/l10n_lk_invoice/__manifest__.py b/addons/l10n_lk_invoice/__manifest__.py new file mode 100644 index 00000000000000..80e5c7c1595dfd --- /dev/null +++ b/addons/l10n_lk_invoice/__manifest__.py @@ -0,0 +1,28 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + "name": "Sri Lanka - Tax Invoice", + "icon": "/account/static/description/l10n.png", + "summary": "Sri Lanka tax invoice sequence format and report layout.", + "description": """ +Sri Lanka Tax Invoice +===================== +- Custom tax invoice sequence format: YYMMM_QQQQ_XXXXX +- Tax Invoice / Supply Date / Mode of Payment in PDF report +- VAT registration tracking for companies and partners + """, + "category": "Accounting/Localizations", + "website": "https://www.odoo.com/documentation/latest/applications/finance/fiscal_localizations.html", + "depends": [ + "l10n_lk", + ], + "version": "1.0", + "author": "Odoo S.A.", + "installable": True, + "auto_install": ["l10n_lk"], + "data": [ + "views/report_invoice.xml", + "views/res_partner_views.xml", + "views/res_company_views.xml", + ], + "license": "LGPL-3", +} diff --git a/addons/l10n_lk_invoice/models/__init__.py b/addons/l10n_lk_invoice/models/__init__.py new file mode 100644 index 00000000000000..3c071a7e7954e8 --- /dev/null +++ b/addons/l10n_lk_invoice/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move +from . import account_resequence +from . import res_company +from . import res_partner diff --git a/addons/l10n_lk_invoice/models/account_move.py b/addons/l10n_lk_invoice/models/account_move.py new file mode 100644 index 00000000000000..f8d585abe92b4f --- /dev/null +++ b/addons/l10n_lk_invoice/models/account_move.py @@ -0,0 +1,274 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re +from collections import defaultdict + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import SQL, Query, frozendict + +# Month abbreviations are hardcoded rather than relying on strftime('%b') +# because the latter is locale-dependent +LK_MONTH_ABBR = { + 1: "JAN", + 2: "FEB", + 3: "MAR", + 4: "APR", + 5: "MAY", + 6: "JUN", + 7: "JUL", + 8: "AUG", + 9: "SEP", + 10: "OCT", + 11: "NOV", + 12: "DEC", +} +LK_MONTH_BY_ABBR = {v: k for k, v in LK_MONTH_ABBR.items()} + +LK_TAX_INVOICE_REGEX = re.compile( + r"^(?P\d{2})(?P[A-Z]{3})_(?P[A-Za-z0-9]{1,15})_(?P\d+)(?P\D*?)$", +) + +LK_TAX_INVOICE_FORMAT = "{year:0{year_length}d}{month_abbr}_{journal_code}_{seq:0{seq_length}d}{suffix}" +LK_TAX_INVOICE_MAX_LENGTH = 40 + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _lk_sql_seq_regex(self): + r"""PSQL-safe pattern for LK names (no named groups, no lazy quantifiers). + + The Python `LK_TAX_INVOICE_REGEX` uses named groups (``(?P...)``), + which PostgreSQL's ``~`` operator does not support. + `_make_regex_non_capturing` converts them to non-capturing groups, but + it is not sufficient on its own: it leaves the lazy quantifier of the + suffix group (``\D*?``) untouched. Since that group only matches + non-digit characters at the end of the name, its greedy equivalent + (``\D*``) matches exactly the same names, so it is used instead. + """ + return self._make_regex_non_capturing(LK_TAX_INVOICE_REGEX.pattern).replace(r"\D*?", r"\D*") + + @api.constrains(lambda self: (self._sequence_field,)) + def _constrains_l10n_lk_sequence_length(self): + for record in self: + if record._l10n_lk_use_tax_invoice_sequence(): + sequence = record[record._sequence_field] + if sequence and len(sequence) > LK_TAX_INVOICE_MAX_LENGTH: + raise UserError( + self.env._( + "Invoice number exceeds %(max)d characters: %(name)s", + max=LK_TAX_INVOICE_MAX_LENGTH, + name=sequence, + ), + ) + + def _l10n_lk_is_tax_invoice_company(self): + """ + Whether this invoice qualifies as a tax invoice under LK VAT law. + + Requires both parties to be VAT-registered (the customer's status is + that of its commercial partner) and all lines to be 18%/zero-rated + (gazette s.4.2). Excludes debit notes. Controls PDF-level display. + """ + self.ensure_one() + return bool( + self.country_code == "LK" + and self.company_id.l10n_lk_vat_registered + and self.commercial_partner_id.l10n_lk_vat_registered + # Debit notes are not tax invoices, even for registered suppliers. + and not (self._fields.get("debit_origin_id") and self.debit_origin_id) + and self._l10n_lk_has_taxable_taxes(), + ) + + def _get_name_invoice_report(self): + self.ensure_one() + if self.country_code == "LK": + return "l10n_lk_invoice.report_invoice_document" + return super()._get_name_invoice_report() + + def _l10n_lk_has_taxable_taxes(self): + """All product lines must carry 18% or zero-rated taxes only (gazette + s.4.2). + + WHT/AIT withholding taxes are ignored for this determination: they are + deducted at payment time and do not qualify (nor disqualify) a line as + a taxable supply. + """ + self.ensure_one() + product_lines = self.invoice_line_ids.filtered( + lambda line: line.display_type == "product", + ) + if not product_lines: + return False + ChartTemplate = self.env["account.chart.template"].with_company(self.company_id) + group_18 = ChartTemplate.ref("l10n_lk_tax_group_18", raise_if_not_found=False) + group_zero_rated = ChartTemplate.ref("l10n_lk_tax_group_zero_rated", raise_if_not_found=False) + group_wht = ChartTemplate.ref("l10n_lk_tax_group_wht", raise_if_not_found=False) + group_ait = ChartTemplate.ref("l10n_lk_tax_group_ait", raise_if_not_found=False) + taxable_groups = (group_18, group_zero_rated) + for line in product_lines: + vat_taxes = line.tax_ids.filtered(lambda tax: tax.tax_group_id not in (group_wht, group_ait)) + all_taxable = all(tax.tax_group_id in taxable_groups for tax in vat_taxes) + if not vat_taxes or not all_taxable: + return False + return True + + def _l10n_lk_use_tax_invoice_sequence(self): + """ + Use YYMMM_QQQQ_XXXXX format for all LK sale documents from + VAT-registered companies. Unlike _l10n_lk_is_tax_invoice_company, + does not check partner VAT or line taxability. + """ + return ( + self.country_code == "LK" + and self.company_id.l10n_lk_vat_registered + and self.is_sale_document(include_receipts=False) + and self.move_type != "out_refund" + ) + + def _get_last_sequence(self, relaxed=False, with_prefix=None): + """ + Override to fetch the last LK sequence using a custom regex pattern. + + The standard method uses sequence_prefix for filtering, but LK sequences + use YYMMM_JOURNAL_SEQ format where the journal code is part of the name, + not the prefix. We use a PSQL regex via _lk_sql_seq_regex to match + LK-specific pattern and fetch the correct last sequence. + """ + if not self._l10n_lk_use_tax_invoice_sequence(): + return super()._get_last_sequence(relaxed=relaxed, with_prefix=with_prefix) + self.ensure_one() + sequence_field = self._fields.get(self._sequence_field) + if not sequence_field or not sequence_field.store: + raise ValidationError(self.env._("%(field_name)s is not a stored field", field_name=self._sequence_field)) + self.flush_model([self._sequence_field, "sequence_number", "sequence_prefix"]) + + query = Query(self.env, alias="move", table=SQL.identifier(self._table)) + query.add_where(SQL("journal_id = %s", self.journal_id.id)) + query.add_where(SQL("name != '/'")) + + if self._origin.id: + query.add_where(SQL("id != %s", self._origin.id)) + if with_prefix is not None: + query.add_where(SQL("sequence_prefix = %s", with_prefix)) + query.add_where(SQL("name ~ %s", self._lk_sql_seq_regex())) + + query.order = SQL("sequence_number DESC") + query.limit = 1 + + result = self.env.execute_query(query.select(SQL.identifier(self._sequence_field))) + return result and result[0][0] + + def _sequence_matches_date(self): + """LK sequences never reset, so the standard date check (which + depends on the reset frequency) does not apply.""" + self.ensure_one() + if self._l10n_lk_use_tax_invoice_sequence(): + match = LK_TAX_INVOICE_REGEX.match(self.name or "") + if match: + move_date = fields.Date.to_date(self[self._sequence_date_field]) + if not move_date: + return True + month = LK_MONTH_BY_ABBR.get(match["month_abbr"]) + if not month: + return super()._sequence_matches_date() + year = int(match["year"]) + expected_year = self._truncate_year_to_length(move_date.year, len(match["year"])) + return year == expected_year and month == move_date.month + return super()._sequence_matches_date() + + def _get_starting_sequence(self): + """Initial LK sequence: YYMMM_QQQQ_00000.""" + self.ensure_one() + if not self._l10n_lk_use_tax_invoice_sequence(): + return super()._get_starting_sequence() + move_date = self.date or self.invoice_date or fields.Date.context_today(self) + return f"{move_date.strftime('%y')}{LK_MONTH_ABBR[move_date.month]}_{self.journal_id.code}_00000" + + def _deduce_sequence_number_reset(self, name): + """LK sequences never reset.""" + if self._l10n_lk_use_tax_invoice_sequence() and LK_TAX_INVOICE_REGEX.match(name or ""): + return "never" + return super()._deduce_sequence_number_reset(name) + + def _get_sequence_format_param(self, previous): + """Parse an LK name into format params, extracting year/month/journal_code/seq/suffix.""" + match = LK_TAX_INVOICE_REGEX.match(previous) if isinstance(previous, str) else None + if not self._l10n_lk_use_tax_invoice_sequence() or not match: + return super()._get_sequence_format_param(previous) + month = LK_MONTH_BY_ABBR.get(match["month_abbr"]) + if not month: + return super()._get_sequence_format_param(previous) + return LK_TAX_INVOICE_FORMAT, { + "year": int(match["year"]), + "year_length": len(match["year"]), + "year_end": 0, + "year_end_length": 0, + "month": month, + "month_abbr": match["month_abbr"], + "journal_code": match["journal_code"], + "seq": int(match["seq"]), + "seq_length": len(match["seq"]), + "suffix": match["suffix"] or "", + } + + def _get_next_sequence_format(self): + """Update month/year from the invoice date even though the + sequence never resets, so the date portion stays accurate.""" + format_string, format_values = super()._get_next_sequence_format() + if self._l10n_lk_use_tax_invoice_sequence() and format_string == LK_TAX_INVOICE_FORMAT: + move_date = self.date or self.invoice_date or fields.Date.context_today(self) + format_values["year"] = self._truncate_year_to_length(move_date.year, format_values["year_length"]) + format_values["month"] = move_date.month + format_values["month_abbr"] = LK_MONTH_ABBR[move_date.month] + return format_string, format_values + + def _is_last_from_seq_chain(self): + """LK sequences span months, so the standard prefix comparison + cannot detect whether a newer entry exists in a different month.""" + if not self._l10n_lk_use_tax_invoice_sequence(): + return super()._is_last_from_seq_chain() + query = Query(self.env, alias="move", table=SQL.identifier(self._table)) + query.add_where(SQL("journal_id = %s", self.journal_id.id)) + query.add_where(SQL("name != '/'")) + + if self._origin.id: + query.add_where(SQL("id != %s", self._origin.id)) + query.add_where(SQL("sequence_number > %s", self.sequence_number or 0)) + query.add_where(SQL("name ~ %s", self._lk_sql_seq_regex())) + + query.order = SQL("sequence_number ASC") + query.limit = 1 + + result = self.env.execute_query(query.select(SQL.identifier("id"))) + return not (result and result[0]) + + def _is_end_of_seq_chain(self): + """Normalize LK batch keys to journal_code only, so invoices from + different months but the same journal are grouped together.""" + lk_records = self.filtered(lambda m: m[m._sequence_field] and m._l10n_lk_use_tax_invoice_sequence()) + if not lk_records: + return super()._is_end_of_seq_chain() + + standard_records = self - lk_records + if standard_records and not super(AccountMove, standard_records)._is_end_of_seq_chain(): + return False + + batched = defaultdict(lambda: {"last_rec": self.browse(), "seq_list": []}) + for record in lk_records: + seq_format, format_values = record._get_sequence_format_param(record[record._sequence_field]) + seq = format_values.pop("seq") + batch = batched[seq_format, frozendict({"journal_code": format_values.get("journal_code")})] + batch["seq_list"].append(seq) + if batch["last_rec"].sequence_number <= record.sequence_number: + batch["last_rec"] = record + + for values in batched.values(): + seq_list = values["seq_list"] + if max(seq_list) - min(seq_list) != len(seq_list) - 1: + return False + record = values["last_rec"] + if not record._is_last_from_seq_chain(): + return False + return True diff --git a/addons/l10n_lk_invoice/models/account_resequence.py b/addons/l10n_lk_invoice/models/account_resequence.py new file mode 100644 index 00000000000000..aca6a6131cd703 --- /dev/null +++ b/addons/l10n_lk_invoice/models/account_resequence.py @@ -0,0 +1,76 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +from datetime import date + +from odoo import models + +from odoo.addons.l10n_lk_invoice.models.account_move import ( + LK_MONTH_ABBR, + LK_TAX_INVOICE_FORMAT, + LK_TAX_INVOICE_REGEX, +) + + +class AccountResequenceWizard(models.TransientModel): + _inherit = "account.resequence.wizard" + + def _compute_new_values(self): + """Resequence LK invoices with per-record month/year from their + invoice date, since the standard implementation reuses the month + abbreviation of the previous name.""" + + def _format_entry(entry, seq): + move_date = date.fromisoformat(entry["server-date"]) + return seq_format.format( + **{ + **format_values, + "year": wizard.move_ids[0]._truncate_year_to_length( + move_date.year, + format_values["year_length"], + ), + "month": move_date.month, + "month_abbr": LK_MONTH_ABBR[move_date.month], + "seq": seq, + }, + ) + + def _current_seq(entry): + match = LK_TAX_INVOICE_REGEX.match(entry.get("current_name") or "") + return int(match["seq"]) if match else 0 + + super()._compute_new_values() + + for wizard in self.filtered("first_name"): + seq_format, format_values = wizard.move_ids[0]._get_sequence_format_param( + wizard.first_name, + ) + if seq_format != LK_TAX_INVOICE_FORMAT: + continue + + new_values = json.loads(wizard.new_values) + base_seq = format_values["seq"] + + by_name_entries = sorted( + new_values.values(), + key=lambda e: ( + _current_seq(e), + e["server-date"], + e["current_name"] or "", + e["id"], + ), + ) + formatted_names = [_format_entry(e, base_seq + i) for i, e in enumerate(by_name_entries)] + + for entry, new_name in zip(by_name_entries, formatted_names): + entry["new_by_name"] = new_name + + by_date_entries = sorted( + new_values.values(), + key=lambda e: (e["server-date"], e["current_name"] or "", e["id"]), + ) + formatted_names_by_date = [_format_entry(e, base_seq + i) for i, e in enumerate(by_date_entries)] + for entry, new_name in zip(by_date_entries, formatted_names_by_date): + entry["new_by_date"] = new_name + + wizard.new_values = json.dumps(new_values) diff --git a/addons/l10n_lk_invoice/models/res_company.py b/addons/l10n_lk_invoice/models/res_company.py new file mode 100644 index 00000000000000..2f6e86b1684e8e --- /dev/null +++ b/addons/l10n_lk_invoice/models/res_company.py @@ -0,0 +1,15 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + l10n_lk_vat_registered = fields.Boolean( + string="Sri Lanka: VAT Registered", + help="Indicates if this company is registered for VAT in Sri Lanka. " + "This defaults invoice printout to this partner to tax invoice for taxable supplies.", + related="partner_id.l10n_lk_vat_registered", + readonly=False, + ) diff --git a/addons/l10n_lk_invoice/models/res_partner.py b/addons/l10n_lk_invoice/models/res_partner.py new file mode 100644 index 00000000000000..a47ffdbb0895b7 --- /dev/null +++ b/addons/l10n_lk_invoice/models/res_partner.py @@ -0,0 +1,25 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + l10n_lk_vat_registered = fields.Boolean( + string="Sri Lanka: VAT Registered", + help="Indicates if this partner is registered for VAT in Sri Lanka. " + "This defaults invoice printout to this partner to tax invoice for taxable supplies.", + compute="_compute_l10n_lk_vat_registered", + store=True, + readonly=False, + ) + + @api.depends("vat", "country_id") + def _compute_l10n_lk_vat_registered(self): + for partner in self: + if partner.country_id.code != "LK": + partner.l10n_lk_vat_registered = False + else: + vat_digits = "".join(ch for ch in (partner.vat or "") if ch.isdigit()) + partner.l10n_lk_vat_registered = len(vat_digits) >= 13 and vat_digits[-4:] == "7000" diff --git a/addons/l10n_lk_invoice/tests/__init__.py b/addons/l10n_lk_invoice/tests/__init__.py new file mode 100644 index 00000000000000..b6a7d672b915ae --- /dev/null +++ b/addons/l10n_lk_invoice/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_lk_tax_invoice_sequence diff --git a/addons/l10n_lk_invoice/tests/test_lk_tax_invoice_sequence.py b/addons/l10n_lk_invoice/tests/test_lk_tax_invoice_sequence.py new file mode 100644 index 00000000000000..124b708134a1fa --- /dev/null +++ b/addons/l10n_lk_invoice/tests/test_lk_tax_invoice_sequence.py @@ -0,0 +1,799 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import re + +from odoo.exceptions import UserError +from odoo.tests import Form, tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.l10n_lk_invoice.models.account_move import LK_TAX_INVOICE_MAX_LENGTH + + +@tagged("post_install", "-at_install", "post_install_l10n") +class TestLkTaxInvoiceSequence(AccountTestInvoicingCommon): + @classmethod + @AccountTestInvoicingCommon.setup_country("lk") + def setUpClass(cls): + super().setUpClass() + cls.change_company_country(cls.env.company, cls.env.ref("base.lk")) + cls.env.company.partner_id.write( + { + "vat": "12345678901237000", + "country_id": cls.env.ref("base.lk").id, + }, + ) + cls.sales_journal = cls.company_data["default_journal_sale"] + cls.sales_journal.code = "BRN01" + + cls.lk_exempt_group = cls.env["account.tax.group"].search( + [("country_id.code", "=", "LK"), ("name", "=", "Exempt")], + limit=1, + ) + cls.lk_exempt_tax = cls.env["account.tax"].create( + { + "name": "0% Exempt (test)", + "amount": 0, + "amount_type": "percent", + "tax_group_id": cls.lk_exempt_group.id, + "country_id": cls.env.ref("base.lk").id, + "type_tax_use": "sale", + }, + ) + cls.lk_group_18 = cls.env["account.tax.group"].search( + [("country_id.code", "=", "LK"), ("name", "=", "18%")], + limit=1, + ) + cls.lk_taxable_tax = cls.env["account.tax"].create( + { + "name": "18% (test)", + "amount": 18, + "amount_type": "percent", + "tax_group_id": cls.lk_group_18.id, + "country_id": cls.env.ref("base.lk").id, + "type_tax_use": "sale", + }, + ) + cls.lk_group_zero_rated = cls.env["account.tax.group"].search( + [("country_id.code", "=", "LK"), ("name", "=", "Zero-rated")], + limit=1, + ) + cls.lk_zero_rated_tax = cls.env["account.tax"].create( + { + "name": "0% Zero-rated (test)", + "amount": 0, + "amount_type": "percent", + "tax_group_id": cls.lk_group_zero_rated.id, + "country_id": cls.env.ref("base.lk").id, + "type_tax_use": "sale", + }, + ) + cls.lk_group_wht = cls.env["account.tax.group"].search( + [("country_id.code", "=", "LK"), ("name", "=", "WHT")], + limit=1, + ) + cls.lk_wht_tax = cls.env["account.tax"].create( + { + "name": "5% WHT (test)", + "amount": 5, + "amount_type": "percent", + "tax_group_id": cls.lk_group_wht.id, + "country_id": cls.env.ref("base.lk").id, + "type_tax_use": "sale", + }, + ) + cls.lk_group_ait = cls.env["account.tax.group"].search( + [("country_id.code", "=", "LK"), ("name", "=", "AIT")], + limit=1, + ) + cls.lk_ait_tax = cls.env["account.tax"].create( + { + "name": "2% AIT (test)", + "amount": 2, + "amount_type": "percent", + "tax_group_id": cls.lk_group_ait.id, + "country_id": cls.env.ref("base.lk").id, + "type_tax_use": "sale", + }, + ) + + def _create_lk_invoice(self, invoice_date, post=True, journal=None): + return self._create_invoice( + move_type="out_invoice", + invoice_date=invoice_date, + post=post, + journal_id=journal or self.sales_journal, + ) + + def _open_resequence_wizard(self, invoices, first_name="26MAY_X1_00010"): + wizard = Form( + self.env["account.resequence.wizard"].with_context( + active_ids=invoices.ids, + active_model="account.move", + ), + ) + wizard.first_name = first_name + return wizard + + # ---------------------------------------- + # Sequence Format Generation + # ---------------------------------------- + + def test_sequence_progression(self): + """LK sequences start at 00001, continue across months and years.""" + inv1 = self._create_lk_invoice("2025-12-31") + self.assertEqual(inv1.name, "25DEC_BRN01_00001") + + inv2 = self._create_lk_invoice("2026-01-01") + self.assertEqual(inv2.name, "26JAN_BRN01_00002", "Sequence should continue across years") + + inv3 = self._create_lk_invoice("2026-05-15") + self.assertEqual(inv3.name, "26MAY_BRN01_00003", "Sequence should continue across months") + + inv4 = self._create_lk_invoice("2026-05-20") + self.assertEqual(inv4.name, "26MAY_BRN01_00004", "Sequence should increment within a month") + + def test_all_month_abbreviations(self): + """Verify all 12 months produce correct three-letter abbreviations.""" + expected = [ + "26JAN", + "26FEB", + "26MAR", + "26APR", + "26MAY", + "26JUN", + "26JUL", + "26AUG", + "26SEP", + "26OCT", + "26NOV", + "26DEC", + ] + for month_num in range(1, 13): + invoice = self._create_lk_invoice(f"2026-{month_num:02d}-15") + self.assertTrue( + invoice.name.startswith(expected[month_num - 1]), + f"Month {month_num:02d}: {invoice.name} should start with {expected[month_num - 1]}", + ) + + # ---------------------------------------- + # Journal Code Handling + # ---------------------------------------- + + def test_journal_code_handling(self): + """Journal codes are used as-is in the sequence (case, digits, hyphens).""" + for code, expected in [ + ("lowcode", "26MAY_lowco_00001"), + ("BR24X", "26MAY_BR24X_00001"), + ("BR-NC", "26MAY_BR-NC_00001"), + ]: + journal = self.sales_journal.copy({"code": code}) + invoice = self._create_lk_invoice("2026-05-15", journal=journal) + self.assertEqual(invoice.name, expected) + + max_code = "A" * 5 + journal = self.sales_journal.copy({"code": max_code}) + invoice = self._create_lk_invoice("2026-05-15", journal=journal) + self.assertIn(max_code, invoice.name) + + # ---------------------------------------- + # Starting Sequence and Format Params + # ---------------------------------------- + + def test_get_starting_sequence_format(self): + """Initial LK sequence is YYMMM_JOURNAL_00000.""" + invoice = self._create_lk_invoice("2026-05-15", post=False) + self.assertEqual( + invoice._get_starting_sequence(), + "26MAY_BRN01_00000", + ) + + def test_sequence_format_params(self): + """Verify _get_sequence_format_param / _get_next_sequence_format + return all expected values from the invoice date.""" + invoice = self._create_lk_invoice("2026-05-15") + fmt, vals = invoice._get_sequence_format_param(invoice.name) + expected_keys = { + "year", + "year_length", + "year_end", + "year_end_length", + "month", + "month_abbr", + "journal_code", + "seq", + "seq_length", + "suffix", + } + self.assertEqual( + set(vals.keys()), + expected_keys, + "Should return all format parameters", + ) + self.assertEqual(vals["year"], 26) + self.assertEqual(vals["month"], 5) + self.assertEqual(vals["month_abbr"], "MAY") + self.assertEqual(vals["journal_code"], "BRN01") + self.assertEqual(vals["seq"], 1) + + fmt, vals = invoice._get_next_sequence_format() + self.assertEqual(vals["year"], 26, "Year should be 2-digit from invoice date") + self.assertEqual(vals["month"], 5, "Month should match invoice date") + self.assertEqual(vals["month_abbr"], "MAY", "Month abbr should match invoice date") + formatted = fmt.format(**vals) + self.assertLessEqual(len(formatted), LK_TAX_INVOICE_MAX_LENGTH) + + def test_sequence_format_param_non_lk_falls_back(self): + """Verify _get_sequence_format_param falls back to super for non-LK.""" + self.change_company_country(self.env.company, self.env.ref("base.us")) + us_invoice = self._create_invoice(move_type="out_invoice", invoice_date="2026-05-15", post=True) + _fmt, vals = us_invoice._get_sequence_format_param(us_invoice.name) + self.assertNotIn("month_abbr", vals) + + def test_manual_sequence_change_updates_next_numbers(self): + """After manually renaming an invoice, the next number picks up from there.""" + invoice = self._create_lk_invoice("2026-05-15") + invoice.name = "26MAY_R2_00500" + next_invoice = self._create_lk_invoice("2026-05-16") + self.assertEqual(next_invoice.name, "26MAY_R2_00501") + + # ---------------------------------------- + # Sequence Date Validation + # ---------------------------------------- + + def test_sequence_matches_date(self): + """LK sequence matches its invoice date.""" + invoice = self._create_lk_invoice("2026-05-15") + self.assertTrue(invoice._sequence_matches_date()) + + def test_sequence_matches_date_handles_missing_name(self): + """Verify _sequence_matches_date handles missing or empty names gracefully.""" + for name in (None, ""): + invoice = self.env["account.move"].new( + { + "name": name, + "date": "2026-05-15", + "move_type": "out_invoice", + }, + ) + self.assertIsInstance(invoice._sequence_matches_date(), bool) + + def test_sequence_wrong_month_year_does_not_match(self): + """Sequence with the wrong month or year fails to match.""" + for name in ("26JUN_BRN01_00001", "27MAY_BRN01_00001"): + invoice = self.env["account.move"].new( + { + "name": name, + "date": "2026-05-15", + "move_type": "out_invoice", + }, + ) + self.assertFalse(invoice._sequence_matches_date()) + + # ---------------------------------------- + # Sequence Never Resets + # ---------------------------------------- + + def test_sequence_number_reset_is_never(self): + """LK sequences never reset.""" + invoice = self._create_lk_invoice("2026-05-15") + self.assertEqual( + invoice._deduce_sequence_number_reset(invoice.name), + "never", + ) + + # ---------------------------------------- + # Max Length Enforcement + # ---------------------------------------- + + def test_constrains_l10n_lk_sequence_length(self): + """Verify the max length constraint at the boundary (40 chars) and when exceeded.""" + invoice = self._create_lk_invoice("2026-05-15", post=False) + valid_name = "26MAY_BRN01_0000000000000000000000000000" + self.assertEqual(len(valid_name), 40) + invoice.write({"name": valid_name}) + + invalid_name = "26MAY_BRN01_000000000000000000000000000000000" + with self.assertRaises(UserError): + invoice.write({"name": invalid_name}) + + # ---------------------------------------- + # _get_last_sequence + # ---------------------------------------- + + def test_get_last_sequence(self): + """Verify _get_last_sequence returns the previous LK sequence, + excluding the origin invoice, and handles NewId records.""" + self._create_lk_invoice("2026-05-15") + inv2 = self._create_lk_invoice("2026-05-20") + + self.assertEqual( + inv2._get_last_sequence(), + "26MAY_BRN01_00001", + "Should return the previous LK sequence", + ) + + new_record = self.env["account.move"].new( + { + "move_type": "out_invoice", + "journal_id": self.sales_journal.id, + "company_id": self.env.company.id, + "partner_id": self.partner.id, + "invoice_date": "2026-05-21", + }, + ) + self.assertEqual( + new_record._get_last_sequence(), + "26MAY_BRN01_00002", + "Should return the last posted LK sequence without raising", + ) + + def test_get_last_sequence_non_lk(self): + """Verify _get_last_sequence delegates to super for non-LK.""" + self.change_company_country(self.env.company, self.env.ref("base.us")) + us_invoice = self._create_invoice(move_type="out_invoice", invoice_date="2026-05-15", post=True) + self.assertEqual( + us_invoice.name, + f"{us_invoice.journal_id.code}/2026/00001", + "Non-LK invoices should not use LK sequence format", + ) + + # ---------------------------------------- + # _lk_sql_seq_regex + # ---------------------------------------- + + def test_lk_sql_seq_regex(self): + """Verify the transformed regex is PSQL-safe and still matches valid LK sequences.""" + psql_regex = self.env["account.move"]._lk_sql_seq_regex() + self.assertNotIn( + "?P<", + psql_regex, + "PSQL-safe regex should not have named groups", + ) + self.assertNotIn( + "*?", + psql_regex, + "PSQL-safe regex should not have lazy quantifiers", + ) + self.assertTrue( + re.match(psql_regex, "26MAY_BRN01_00001"), + "Should match valid LK sequence", + ) + self.assertTrue( + re.match(psql_regex, "26JUN_BR24X1_00001"), + "Should match sequence with digits in journal code", + ) + + # ---------------------------------------- + # Non-LK Documents + # ---------------------------------------- + + def test_non_lk_documents_do_not_use_lk_sequence(self): + """Refunds, vendor bills and receipts should not use the LK tax invoice sequence.""" + self.assertFalse( + self.env["account.move"].with_context(default_move_type="out_refund")._l10n_lk_use_tax_invoice_sequence(), + "Refunds should not use LK tax invoice sequence", + ) + self.assertFalse( + self.env["account.move"].with_context(default_move_type="in_invoice")._l10n_lk_use_tax_invoice_sequence(), + "Vendor bills should not use LK tax invoice sequence", + ) + product = self.env["product.product"].create({"name": "Test Product"}) + receipt = self.env["account.move"].create( + { + "move_type": "out_receipt", + "partner_id": self.partner_a.id, + "journal_id": self.sales_journal.id, + "invoice_date": "2026-05-15", + "line_ids": [ + ( + 0, + 0, + { + "product_id": product.id, + "name": "Test Line", + "quantity": 1, + "price_unit": 100, + }, + ), + ], + }, + ) + self.assertFalse( + receipt._l10n_lk_use_tax_invoice_sequence(), + "Receipts should not use LK sequence", + ) + receipt.action_post() + self.assertNotRegex( + r"^\d{2}[A-Z]{3}_[A-Z0-9]+_\d+", + receipt.name or "", + "Receipt name should not match LK sequence pattern", + ) + + # ---------------------------------------- + # Report Name Routing + # ---------------------------------------- + + def test_get_name_invoice_report_lk(self): + """LK invoices use a custom report template.""" + invoice = self._create_lk_invoice("2026-05-15", post=False) + self.assertEqual( + invoice._get_name_invoice_report(), + "l10n_lk_invoice.report_invoice_document", + ) + + def test_get_name_invoice_report_non_lk(self): + """Non-LK invoices use a standard report template.""" + self.change_company_country(self.env.company, self.env.ref("base.us")) + us_invoice = self._create_invoice( + move_type="out_invoice", + invoice_date="2026-05-15", + ) + self.assertEqual( + us_invoice._get_name_invoice_report(), + "account.report_invoice_document", + ) + + # ---------------------------------------- + # VAT Registration Fields + # ---------------------------------------- + + def test_vat_suffix_auto_detection(self): + """l10n_lk_vat_registered is computed from VAT number suffix.""" + company_partner = self.env.company.partner_id + company_partner.vat = "1234567897000" + self.assertTrue(self.env.company.l10n_lk_vat_registered) + + company_partner.vat = "1234567890000" + self.assertFalse(self.env.company.l10n_lk_vat_registered) + + company_partner.vat = "123456789" + self.assertFalse(self.env.company.l10n_lk_vat_registered) + + # ---------------------------------------- + # Tax Invoice Qualification (_l10n_lk_is_tax_invoice_company) + # Controls whether the PDF shows "Tax Invoice" / "Supply Date" etc. + # ---------------------------------------- + + def test_tax_invoice_requires_both_vat_registered(self): + """Both company and partner must be VAT-registered.""" + invoice = self._create_lk_invoice("2026-05-15", post=False) + + self.env.company.l10n_lk_vat_registered = False + invoice.partner_id.l10n_lk_vat_registered = False + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + self.env.company.l10n_lk_vat_registered = True + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.partner_id.l10n_lk_vat_registered = True + self.assertTrue(invoice._l10n_lk_is_tax_invoice_company()) + + def test_non_lk_country_not_tax_invoice(self): + """Non-LK country invoices are not tax invoices.""" + self.change_company_country(self.env.company, self.env.ref("base.us")) + invoice = self._create_invoice_one_line( + invoice_date="2026-05-15", + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id], + ) + self.env.company.l10n_lk_vat_registered = True + invoice.partner_id.l10n_lk_vat_registered = True + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + def test_tax_invoice_requires_taxable_taxes(self): + """A single product line qualifies when 18%/zero-rated, and not + otherwise (no tax, exempt only, mixed exempt/taxable).""" + self.env.company.l10n_lk_vat_registered = True + invoice = self._create_invoice_one_line( + invoice_date="2026-05-15", + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id], + ) + invoice.partner_id.l10n_lk_vat_registered = True + + self.assertTrue(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.invoice_line_ids.tax_ids = self.lk_taxable_tax | self.lk_zero_rated_tax + self.assertTrue(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.invoice_line_ids.tax_ids = self.lk_zero_rated_tax + self.assertTrue(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.invoice_line_ids.tax_ids = self.lk_exempt_tax + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.invoice_line_ids.tax_ids = self.lk_exempt_tax | self.lk_taxable_tax + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.invoice_line_ids.tax_ids = False + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + def test_multi_line_taxable_and_other_lines_not_tax_invoice(self): + """Mixed product lines (taxable + exempt/untaxed) are not tax invoices.""" + self.env.company.l10n_lk_vat_registered = True + invoice = self._create_invoice( + invoice_date="2026-05-15", + invoice_line_ids=[ + self._prepare_invoice_line( + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id], + ), + self._prepare_invoice_line( + product_id=self.product_b.id, + tax_ids=[self.lk_exempt_tax.id], + ), + ], + ) + invoice.partner_id.l10n_lk_vat_registered = True + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + invoice.invoice_line_ids.filtered(lambda line: line.product_id == self.product_b).tax_ids = False + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + def test_wht_ait_taxes_do_not_block_tax_invoice(self): + """WHT/AIT taxes on a line do not disqualify an otherwise taxable + invoice (reviewer cases 1-3).""" + self.env.company.l10n_lk_vat_registered = True + invoice = self._create_invoice( + invoice_date="2026-05-15", + invoice_line_ids=[ + self._prepare_invoice_line( + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id, self.lk_wht_tax.id], + ), + self._prepare_invoice_line( + product_id=self.product_b.id, + tax_ids=[self.lk_taxable_tax.id], + ), + ], + ) + invoice.partner_id.l10n_lk_vat_registered = True + self.assertTrue( + invoice._l10n_lk_is_tax_invoice_company(), + "18% + WHT on one line and 18% on the other is a tax invoice", + ) + + invoice.invoice_line_ids.filtered(lambda line: line.product_id == self.product_b).tax_ids = self.lk_ait_tax + self.assertFalse( + invoice._l10n_lk_is_tax_invoice_company(), + "A line with WHT/AIT only is not a taxable supply (case 1)", + ) + + invoice.invoice_line_ids.filtered(lambda line: line.product_id == self.product_b).tax_ids = self.lk_exempt_tax + self.assertFalse( + invoice._l10n_lk_is_tax_invoice_company(), + "An exempt line is still not a tax invoice (case 3)", + ) + + def test_commercial_partner_vat_status_used_for_tax_invoice(self): + """The customer VAT status comes from the commercial partner, not the + invoice's contact.""" + self.env.company.l10n_lk_vat_registered = True + customer = self.env["res.partner"].create( + { + "name": "LK Customer", + "is_company": True, + "country_id": self.env.ref("base.lk").id, + "vat": "12345678901237000", + }, + ) + self.assertTrue( + customer.l10n_lk_vat_registered, + "LK company with vat ending in 7000 is VAT-registered", + ) + contact = self.env["res.partner"].create( + { + "name": "Contact", + "parent_id": customer.id, + "type": "contact", + }, + ) + contact.l10n_lk_vat_registered = False + self.assertFalse( + contact.l10n_lk_vat_registered, + "The contact itself is not VAT-registered (flag unchecked)", + ) + self.assertTrue( + customer.l10n_lk_vat_registered, + "The commercial partner still is VAT-registered", + ) + invoice = self._create_invoice_one_line( + invoice_date="2026-05-15", + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id], + partner_id=contact.id, + ) + self.assertEqual(invoice.partner_id.commercial_partner_id, customer) + self.assertTrue( + invoice._l10n_lk_is_tax_invoice_company(), + "Child contact without VAT flag still uses commercial partner status", + ) + customer.vat = False + self.assertFalse( + invoice._l10n_lk_is_tax_invoice_company(), + "Unregistered commercial partner means no tax invoice", + ) + + def test_debit_note_is_not_tax_invoice(self): + """Debit notes are not tax invoices in terms of wording.""" + self.env.company.l10n_lk_vat_registered = True + invoice = self._create_invoice_one_line( + invoice_date="2026-05-15", + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id], + ) + invoice.partner_id.l10n_lk_vat_registered = True + self.assertTrue(invoice._l10n_lk_is_tax_invoice_company()) + + if "debit_origin_id" not in self.env["account.move"]._fields: + self.skipTest("account_debit_note module not installed") + invoice.write({"debit_origin_id": invoice.id}) + self.assertFalse(invoice._l10n_lk_is_tax_invoice_company()) + + def test_section_and_note_lines_ignored_by_has_taxable_taxes(self): + """Sections and notes are not counted; an invoice is a tax invoice only + when it has taxable product lines.""" + invoice = self._create_invoice_one_line( + invoice_date="2026-05-15", + product_id=self.product_a.id, + tax_ids=[self.lk_taxable_tax.id], + ) + self.env.company.l10n_lk_vat_registered = True + invoice.partner_id.l10n_lk_vat_registered = True + self.env["account.move.line"].create( + [ + { + "move_id": invoice.id, + "display_type": "line_section", + "name": "Section Header", + }, + { + "move_id": invoice.id, + "display_type": "line_note", + "name": "Note", + }, + ], + ) + self.assertTrue( + invoice._l10n_lk_is_tax_invoice_company(), + "Section/note lines should not prevent a tax invoice", + ) + + invoice.invoice_line_ids.filtered(lambda line: line.display_type == "product").tax_ids = False + self.assertFalse( + invoice._l10n_lk_is_tax_invoice_company(), + "Section/note lines alone should not make a tax invoice", + ) + + # ---------------------------------------- + # Resequence + # ---------------------------------------- + + def test_resequence_updates_month_abbr_on_boundary(self): + """A resequencing spanning two months must use each record's actual month.""" + invoices = self._create_lk_invoice("2026-05-31") + self._create_lk_invoice("2026-06-01") + + resequence_wizard = self._open_resequence_wizard(invoices, "26MAY_X1_00010") + new_values = json.loads(resequence_wizard.new_values) + + self.assertEqual( + new_values[str(invoices[0].id)]["new_by_name"], + "26MAY_X1_00010", + "May invoice should keep May abbreviation", + ) + self.assertEqual( + new_values[str(invoices[1].id)]["new_by_name"], + "26JUN_X1_00011", + "June invoice should have June abbreviation", + ) + + resequence_wizard.save().resequence() + self.assertEqual( + invoices[0].name, + "26MAY_X1_00010", + "First invoice should have May in name", + ) + self.assertEqual( + invoices[1].name, + "26JUN_X1_00011", + "Second invoice should have June in name", + ) + + def test_resequence_preserves_journal_code(self): + """Resequence preserves the journal code in the sequence.""" + journal = self.sales_journal.copy({"code": "BRANCH1"}) + invoices = self._create_lk_invoice("2026-05-15", journal=journal) + invoices += self._create_lk_invoice("2026-05-20", journal=journal) + + resequence_wizard = self._open_resequence_wizard( + invoices, + "26MAY_BRANCH1_00100", + ) + new_values = json.loads(resequence_wizard.new_values) + + self.assertEqual( + new_values[str(invoices[0].id)]["new_by_name"], + "26MAY_BRANCH1_00100", + ) + self.assertEqual( + new_values[str(invoices[1].id)]["new_by_name"], + "26MAY_BRANCH1_00101", + ) + + def test_resequence_by_date_sorting(self): + """By-date view orders by date; earlier date gets lower seq.""" + invoices = self._create_lk_invoice("2026-05-20") + self._create_lk_invoice("2026-05-15") + + resequence_wizard = self._open_resequence_wizard(invoices, "26MAY_X1_00010") + new_values = json.loads(resequence_wizard.new_values) + + self.assertEqual( + new_values[str(invoices[1].id)]["new_by_date"], + "26MAY_X1_00010", + ) + self.assertEqual( + new_values[str(invoices[0].id)]["new_by_date"], + "26MAY_X1_00011", + ) + + def test_resequence_by_date_cross_month_uses_per_record_abbr(self): + """By-date resequence spanning months assigns the correct month abbreviation per record.""" + inv1 = self._create_lk_invoice("2026-05-20") + inv2 = self._create_lk_invoice("2026-05-15") + inv3 = self._create_lk_invoice("2026-06-15") + invoices = inv1 + inv2 + inv3 + + resequence_wizard = self._open_resequence_wizard(invoices, "26MAY_X1_00010") + new_values = json.loads(resequence_wizard.new_values) + + self.assertEqual( + new_values[str(inv2.id)]["new_by_date"], + "26MAY_X1_00010", + ) + self.assertEqual( + new_values[str(inv1.id)]["new_by_date"], + "26MAY_X1_00011", + ) + self.assertEqual( + new_values[str(inv3.id)]["new_by_date"], + "26JUN_X1_00012", + ) + + def test_resequence_draft_without_name_no_crash(self): + """Resequencing with draft invoices without names does not crash.""" + lk_invoice = self._create_lk_invoice("2026-05-15") + draft = self._create_lk_invoice("2026-05-20", post=False) + draft.name = False + + resequence_wizard = self._open_resequence_wizard( + lk_invoice + draft, + "26MAY_X1_00010", + ) + new_values = json.loads(resequence_wizard.new_values) + + self.assertEqual( + new_values[str(draft.id)]["new_by_name"], + "26MAY_X1_00010", + ) + self.assertEqual( + new_values[str(lk_invoice.id)]["new_by_name"], + "26MAY_X1_00011", + ) + + # ---------------------------------------- + # Sequence Chain Integrity + # ---------------------------------------- + + def test_sequence_chain_integrity(self): + """Verify last/end-of-chain detection across months and journals.""" + journal2 = self.sales_journal.copy({"code": "BANK2"}) + inv1 = self._create_lk_invoice("2026-05-15") + inv2 = self._create_lk_invoice("2026-06-15") + inv3 = self._create_lk_invoice("2026-06-20") + inv4 = self._create_lk_invoice("2026-05-15", journal=journal2) + self.assertTrue(inv3._is_last_from_seq_chain()) + self.assertFalse(inv1._is_last_from_seq_chain()) + self.assertTrue((inv2 + inv3 + inv4)._is_end_of_seq_chain()) + self.assertFalse((inv1 + inv3)._is_end_of_seq_chain()) + self.assertFalse((inv1 + inv2)._is_end_of_seq_chain()) diff --git a/addons/l10n_lk_invoice/views/report_invoice.xml b/addons/l10n_lk_invoice/views/report_invoice.xml new file mode 100644 index 00000000000000..63f1becc7074b6 --- /dev/null +++ b/addons/l10n_lk_invoice/views/report_invoice.xml @@ -0,0 +1,72 @@ + + + + + + + + Commercial Invoice + account.move + qweb-pdf + l10n_lk_invoice.report_commercial_invoice + l10n_lk_invoice.report_commercial_invoice + + report + [('country_code', '=', 'LK'), ('journal_id.type', '=', 'sale')] + + + + + diff --git a/addons/l10n_lk_invoice/views/res_company_views.xml b/addons/l10n_lk_invoice/views/res_company_views.xml new file mode 100644 index 00000000000000..22e90e7beb15a2 --- /dev/null +++ b/addons/l10n_lk_invoice/views/res_company_views.xml @@ -0,0 +1,13 @@ + + + + res.company.form.l10n_lk_vat_registered + res.company + + + + + + + + diff --git a/addons/l10n_lk_invoice/views/res_partner_views.xml b/addons/l10n_lk_invoice/views/res_partner_views.xml new file mode 100644 index 00000000000000..ca57dd51e8c565 --- /dev/null +++ b/addons/l10n_lk_invoice/views/res_partner_views.xml @@ -0,0 +1,14 @@ + + + + res.partner.form.l10n_lk_vat_registered + res.partner + + + + + + + + + From 10f8e2665ca72c37e925fd9c0ade6c4b1400cb9c Mon Sep 17 00:00:00 2001 From: "Robin Lejeune (role)" Date: Mon, 17 Aug 2026 13:43:26 +0200 Subject: [PATCH 086/205] [FIX] website: invalidate cached page after cookies update Initially with [commit 958b41c4], when cookies were denied (the page is cached a 1st time), then accepted (the page cache must be invalidated), cached pages would be computed again. This behavior was lost with [6c8a90ec], since which website pages are cached more aggressively. [commit 958b41c4]: https://github.com/odoo/odoo/commit/958b41c4acec7e1700ca4d6e0b25ee0ad2aac9f1 [6c8a90ec]: https://www.github.com/odoo/odoo/commit/6c8a90ecba45fb99addf1b86fe237fd626fba650 task-6471290 closes odoo/odoo#282737 Signed-off-by: Francois Georis (fge) --- addons/website/models/website_page.py | 8 +++++- addons/website/tests/test_performance.py | 31 +++++++++++++++--------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py index 16178800e30080..204034458fdacb 100644 --- a/addons/website/models/website_page.py +++ b/addons/website/models/website_page.py @@ -352,7 +352,13 @@ def _get_cache_key(self, request): the cache serves the correct version of a page based on specific parameters like user language or currency. """ - return (request.website.id, request.lang.code, request.httprequest.path, request.session.debug) + return ( + request.website.id, + request.lang.code, + request.httprequest.path, + request.session.debug, + request.website._allConsentsGranted(), + ) def _get_response(self, request): """ Returns the response corresponding to the request. diff --git a/addons/website/tests/test_performance.py b/addons/website/tests/test_performance.py index e148537b23dc55..18ce60a0654e32 100644 --- a/addons/website/tests/test_performance.py +++ b/addons/website/tests/test_performance.py @@ -209,8 +209,9 @@ def test_10_perf_sql_queries_page(self): 'orm_signaling_registry': 1, 'ir_attachment': 1, # `_get_serve_attachment` dispatcher fallback + 'website': 1, # Select cookies_bar } - expected_query_count = 2 + expected_query_count = 3 self._check_url_hot_query(self.page.url, expected_query_count, select_tables_perf) self.assertEqual(self._get_url_hot_query(self.page.url), expected_query_count) self.menu.unlink() # page being or not in menu shouldn't add queries @@ -243,6 +244,7 @@ def test_15_perf_sql_queries_page(self): 'orm_signaling_registry': 1, 'ir_attachment': 1, # `_get_serve_attachment` dispatcher fallback + 'website': 1, # Select cookies_bar } if cache else { 'orm_signaling_registry': 1, 'ir_attachment': 1, @@ -256,7 +258,7 @@ def test_15_perf_sql_queries_page(self): 'ir_ui_view': 1, 'res_company': 1, } - expected_query_count = 2 if cache else 8 + expected_query_count = 3 if cache else 8 insert_tables_perf = {} if not readonly_enabled: insert_tables_perf = { @@ -277,8 +279,9 @@ def test_20_perf_sql_queries_homepage(self): with self.subTest(readonly_enabled=readonly_enabled), closing(self.env.cr.savepoint()): select_tables_perf = { 'orm_signaling_registry': 1, + 'website': 1, # Select cookies_bar } - expected_query_count = 1 + expected_query_count = 2 insert_tables_perf = {} if not readonly_enabled: insert_tables_perf = { @@ -320,9 +323,11 @@ def test_30_perf_sql_queries_page_no_layout(self): 'orm_signaling_registry': 1, 'ir_attachment': 1, # `_get_serve_attachment` dispatcher fallback + 'website': 1, # Select cookies_bar } - self._check_url_hot_query(self.page.url, 2, select_tables_perf) - self.assertEqual(self._get_url_hot_query(self.page.url), 2) + expected_query_count = 3 + self._check_url_hot_query(self.page.url, expected_query_count, select_tables_perf) + self.assertEqual(self._get_url_hot_query(self.page.url), expected_query_count) select_tables_perf = { 'orm_signaling_registry': 1, @@ -336,8 +341,9 @@ def test_30_perf_sql_queries_page_no_layout(self): 'ir_ui_view': 1, # Check if `view.track` to track visitor or not } - self._check_url_hot_query(self.page.url, 5, select_tables_perf, nocache=True) - self.assertEqual(self._get_url_hot_query(self.page.url, nocache=True), 5) + expected_query_count = 5 + self._check_url_hot_query(self.page.url, expected_query_count, select_tables_perf, nocache=True) + self.assertEqual(self._get_url_hot_query(self.page.url, nocache=True), expected_query_count) def test_40_perf_sql_queries_page_multi_level_menu(self): # menu structure should not impact SQL requests @@ -353,9 +359,11 @@ def test_40_perf_sql_queries_page_multi_level_menu(self): 'orm_signaling_registry': 1, 'ir_attachment': 1, # `_get_serve_attachment` dispatcher fallback + 'website': 1, # Select cookies_bar } - self._check_url_hot_query(self.page.url, 2, select_tables_perf) - self.assertEqual(self._get_url_hot_query(self.page.url), 2) + expected_query_count = 3 + self._check_url_hot_query(self.page.url, expected_query_count, select_tables_perf) + self.assertEqual(self._get_url_hot_query(self.page.url), expected_query_count) select_tables_perf = { 'orm_signaling_registry': 1, @@ -371,8 +379,9 @@ def test_40_perf_sql_queries_page_multi_level_menu(self): # layout content (company name, logo) 'res_company': 1, } - self._check_url_hot_query(self.page.url, 8, select_tables_perf, nocache=True) - self.assertEqual(self._get_url_hot_query(self.page.url, nocache=True), 8) + expected_query_count = 8 + self._check_url_hot_query(self.page.url, expected_query_count, select_tables_perf, nocache=True) + self.assertEqual(self._get_url_hot_query(self.page.url, nocache=True), expected_query_count) @tagged('-at_install', 'post_install') class TestWebsitePerformancePost(UtilPerf): From e407ac3d5cf051a0e5d157d11c089d94c4b4f2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Lef=C3=A8vre=20=28lul=29?= Date: Tue, 18 Aug 2026 11:10:41 +0200 Subject: [PATCH 087/205] [FIX] orm: prefetch batch on inverse one2many update Steps to reproduce: - with a user with "Sign / User: Own Templates" access rights - go to Sign / Templates - click on a template to open it => reading `sign.item.role.item_ids.template_id` triggers the computation of the related field `sign.item.template_id`, whose inverse `sign.template.sign_item_ids` carries a domain. When applying the domain, the sign.item's fields need to be fetched but they are fetched with one query per sign.item instead of a single batched one. task-6478942 closes odoo/odoo#282994 Signed-off-by: Raphael Collet --- .../test_orm/models/test_performance.py | 6 +++++ .../addons/test_orm/tests/test_performance.py | 23 +++++++++++++++++++ odoo/orm/fields.py | 3 ++- odoo/orm/fields_numeric.py | 3 ++- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/odoo/addons/test_orm/models/test_performance.py b/odoo/addons/test_orm/models/test_performance.py index 06f6494c490633..a5145955407a2a 100644 --- a/odoo/addons/test_orm/models/test_performance.py +++ b/odoo/addons/test_orm/models/test_performance.py @@ -19,6 +19,10 @@ class Test_PerformanceBase(models.Model): total = fields.Integer(compute="_total", store=True) tag_ids = fields.Many2many('test_performance.tag') + # domain forces the ORM to read a field on the lines when updating the + # cache of this one2many + related_line_ids = fields.One2many('test_performance.line', 'related_base_id', domain=[('value', '>=', 0)]) + @api.depends('value') def _value_pc(self): for record in self: @@ -53,6 +57,8 @@ class Test_PerformanceLine(models.Model): base_id = fields.Many2one('test_performance.base', required=True, ondelete='cascade') value = fields.Integer() + related_base_id = fields.Many2one('test_performance.base', related='base_id', string="Related base") + _line_uniq = models.UniqueIndex('(base_id, value)', "base_id and value should be unique") diff --git a/odoo/addons/test_orm/tests/test_performance.py b/odoo/addons/test_orm/tests/test_performance.py index 74f628116a3fb1..67906d7e87581c 100644 --- a/odoo/addons/test_orm/tests/test_performance.py +++ b/odoo/addons/test_orm/tests/test_performance.py @@ -647,6 +647,29 @@ def test_prefetch_new(self): for line in record.line_ids: line.value + @warmup + def test_prefetch_related_many2one_inverse(self): + """Reading ``base.line_ids.related_base_id`` triggers the computation + of ``line.related_base_id``, which updates the cache of the inverse + one2many ``base.related_line_ids``. + """ + base = self.env['test_performance.base'].create({ + 'line_ids': [Command.create({'value': index}) for index in range(10)], + }) + self.env.invalidate_all() + + with self.assertQueryCount(2): + # one query to fetch line_ids, with their field base_id + lines = base.line_ids + # One query to fetch field `value` on lines. The computation itself + # does not need to fetch anything. However, the assignment of + # field 'related_base_id' in the compute method must adapt its + # inverse field 'related_line_ids'. As the latter has domain + # [('value', '>=', 0)], it performs line.filtered_domain() to + # determine whether line satisfies the domain, which should + # prefetch field 'value' on all lines at once. + lines.mapped('related_base_id') + @tagged('bacon_and_eggs') class TestIrPropertyOptimizations(TransactionCase): diff --git a/odoo/orm/fields.py b/odoo/orm/fields.py index 1a0e41eae9d14c..c5101a770890dd 100644 --- a/odoo/orm/fields.py +++ b/odoo/orm/fields.py @@ -1579,11 +1579,12 @@ def _filter_not_equal(self, records: ModelType, cache_value: typing.Any) -> Mode either not in cache, or different from ``cache_value``. """ field_cache = self._get_cache(records.env) - return records.browse( + ids_to_update = tuple( record_id for record_id in records._ids if field_cache.get(record_id, SENTINEL) != cache_value ) + return records.__class__(records.env, ids_to_update, records._prefetch_ids) def _to_prefetch(self, record: ModelType) -> ModelType: """ Return a recordset including ``record`` to prefetch the field. """ diff --git a/odoo/orm/fields_numeric.py b/odoo/orm/fields_numeric.py index 2b66211fd0af9f..45a60bc31ace7d 100644 --- a/odoo/orm/fields_numeric.py +++ b/odoo/orm/fields_numeric.py @@ -302,7 +302,7 @@ def _filter_not_equal(self, records: BaseModel, cache_value: typing.Any) -> Base env = records.env field_cache = self._get_cache(env) currency_field = records._fields[self.get_currency_field(records)] - return records.browse( + ids_to_update = tuple( record_id for record_id, record_sudo in zip( records._ids, records.sudo().with_context(prefetch_fields=False) @@ -313,3 +313,4 @@ def _filter_not_equal(self, records: BaseModel, cache_value: typing.Any) -> Base and currency.with_env(env).round(value) == cache_value ) ) + return records.__class__(records.env, ids_to_update, records._prefetch_ids) From c6ca782b7511ceddabddd11a5eefec2e8d8267fc Mon Sep 17 00:00:00 2001 From: Ayush Modi Date: Tue, 11 Aug 2026 19:31:28 +0530 Subject: [PATCH 088/205] [FIX] account: handle non-positive days in payment terms Steps to reproduce: - Install 'Accounting' module - Payment Terms > Create NEW - Add a new Due Term line with "Days end of month on the" and a negative amount of days(eg: -1) Traceback: ValueError: day is out of range for month When 'days_next_month' is set to a negative value, it is passed directly to 'relativedelta' as the 'day' value. Since a negative value is not a valid day of the month, the due-date computation raises a 'ValueError'. Use the end of the month for the calculation when 'days_next_month' is non-positive. This prevents the traceback while computing the payment term and allows the proper validation error to be raised when the record is saved. opw-6453640 closes odoo/odoo#281904 Signed-off-by: John Laterre (jol) --- addons/account/models/account_payment_term.py | 4 ++-- addons/account/tests/test_payment_term.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/addons/account/models/account_payment_term.py b/addons/account/models/account_payment_term.py index 91fc23d392978d..dc117b53435b88 100644 --- a/addons/account/models/account_payment_term.py +++ b/addons/account/models/account_payment_term.py @@ -320,7 +320,7 @@ def _get_due_date(self, date_ref): except ValueError: days_next_month = 1 - if not days_next_month: + if days_next_month <= 0: return date_utils.end_of(due_date + relativedelta(days=self.nb_days), 'month') return due_date + relativedelta(days=self.nb_days) + relativedelta(months=1, day=days_next_month) @@ -329,7 +329,7 @@ def _get_due_date(self, date_ref): @api.constrains('days_next_month') def _check_valid_char_value(self): for record in self: - if record.days_next_month and record.days_next_month.isnumeric(): + if record.days_next_month and record.days_next_month.removeprefix('-').isnumeric(): if not (0 <= int(record.days_next_month) <= 31): raise ValidationError(_('The days added must be between 0 and 31.')) else: diff --git a/addons/account/tests/test_payment_term.py b/addons/account/tests/test_payment_term.py index 9669e9fea07162..e7ac82fe9a7869 100644 --- a/addons/account/tests/test_payment_term.py +++ b/addons/account/tests/test_payment_term.py @@ -683,3 +683,22 @@ def test_payment_term_multi_company(self): 'company_id': other_company.id }) self.assertFalse(invoice.invoice_payment_term_id) + + def test_payment_term_rejects_negative_day_of_next_month(self): + payment_term = self.env['account.payment.term'].new({ + 'name': 'Invalid next-month day', + 'line_ids': [Command.create({ + 'value': 'percent', + 'value_amount': 100, + 'nb_days': 0, + 'delay_type': 'days_end_of_month_on_the', + 'days_next_month': -1, + })], + }) + + # Accessing the computed field triggers the due-date computation. + payment_term.example_preview + + vals = payment_term._convert_to_write(payment_term._cache) + with self.assertRaisesRegex(ValidationError, 'The days added must be between 0 and 31.'): + self.env['account.payment.term'].create(vals) From a6a581bfd8c974a815adf943f982dcb3475486c5 Mon Sep 17 00:00:00 2001 From: elhayyany Date: Thu, 16 Jul 2026 09:05:28 +0000 Subject: [PATCH 089/205] [FIX] account: wrong account suggested for product-less lines and account search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_get_most_frequent_accounts_for_partner` filtered suggestions to income/expense using `get_inbound_types` and `get_outbound_types`, classify by cash-flow direction, not by income vs expense. This grouped `in_refund` with `out_invoice` and `out_refund` with `in_invoice`, so a Vendor Credit Note could suggest an income account and a Customer Credit Note an expense account. This function backs two things: the default account on a product-less invoice/credit note line, and the "Suggested" account in the account field's search. The bug hit both, and also made the suggestion inconsistent with `name_search`'s own filter (`_get_name_search_account_types`), so a suggested account could vanish as soon as you started typing. This commit filters using `_get_name_search_account_types` instead, so both code paths agree on which accounts are valid for a given move type. opw-6373124 closes odoo/odoo#278750 X-original-commit: 6f3fc83d805c0363402ad711d4005ec0d62a6bb9 Signed-off-by: Sven Führ (svfu) --- addons/account/models/account_account.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/addons/account/models/account_account.py b/addons/account/models/account_account.py index 352c0e16150d51..e2f92303636494 100644 --- a/addons/account/models/account_account.py +++ b/addons/account/models/account_account.py @@ -745,10 +745,8 @@ def _get_most_frequent_accounts_for_partner(self, company_id, partner_id, move_t ('account_id.active', '=', True), ('date', '>=', fields.Date.add(fields.Date.today(), days=-365 * 2)), ] - if move_type in self.env['account.move'].get_inbound_types(include_receipts=True): - domain.append(('account_id.internal_group', '=', 'income')) - elif move_type in self.env['account.move'].get_outbound_types(include_receipts=True): - domain.append(('account_id.internal_group', '=', 'expense')) + if allowed_account_types := self._get_name_search_account_types(move_type): + domain.append(('account_id.account_type', 'in', allowed_account_types)) query = self.env['account.move.line']._search(domain, bypass_access=True) if not filter_never_user_accounts: From 272b8f84a5f4c6530676a098416164c9af2d8f19 Mon Sep 17 00:00:00 2001 From: Yash Pathak Date: Fri, 14 Aug 2026 14:14:07 +0000 Subject: [PATCH 090/205] [FIX] base, hr: include state and street2 in partner postal addresses _get_all_addr() feeds the postal address block of generated pain.001 payment files, but does not return the partner's state nor the second street line. The beneficiary state/province and street complement (suite, unit, ...) therefore never appear in the generated file, even when they are set on the partner, and there is no way to fix it from the record. Some North American banks reject wire transfers whose beneficiary address lacks the state/province, so those payments fail regardless of how complete the vendor record is. Return the state code and street2 alongside the other address components, from the partner for the base implementation and from the employee private address for the hr one, so the payment engine can write them in the PstlAdr block. Steps to reproduce: - Install Accounting and enable a generic ISO 20022 payment method on a bank journal - Create a vendor located in the US or Canada with a complete address, including the state and a second street line - Register a vendor payment, add it to a batch and generate the pain.001 file - The creditor PstlAdr has no state/province, and its street line only carries the first street field: the street2 part is dropped closes odoo/odoo#282649 X-original-commit: d511cc94a7529940e7877850469dbc07d0a76b9d Related: odoo/enterprise#128043 Signed-off-by: Krzysztof Magusiak (krma) --- addons/hr/models/res_partner.py | 2 ++ odoo/addons/base/models/res_partner.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/addons/hr/models/res_partner.py b/addons/hr/models/res_partner.py index 3856a7a3713926..e8d7445351f0ae 100644 --- a/addons/hr/models/res_partner.py +++ b/addons/hr/models/res_partner.py @@ -49,8 +49,10 @@ def _get_all_addr(self): pstl_addr = { 'contact_type': 'employee', 'street': employee_id.private_street, + 'street2': employee_id.private_street2, 'zip': employee_id.private_zip, 'city': employee_id.private_city, + 'state': employee_id.private_state_id.code, 'country': employee_id.private_country_id.code, } return [pstl_addr] + super()._get_all_addr() diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index d87c858ad55c62..502616ef7cb343 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -1247,8 +1247,10 @@ def _get_all_addr(self): return [{ 'contact_type': self.street, 'street': self.street, + 'street2': self.street2, 'zip': self.zip, 'city': self.city, + 'state': self.state_id.code, 'country': self.country_id.code, }] From 42701be87ae49f88f550ef6569604ea89dc94eec Mon Sep 17 00:00:00 2001 From: pkri-odoo Date: Thu, 2 Jul 2026 11:18:03 +0000 Subject: [PATCH 091/205] [FIX] account_edi_ubl_cii: incorrect buyer reference in invoice xml **Steps to reproduce:** * Set up a French company and configure Peppol E-invoicing. * Install `account_edi_ubl_cii` module. * Create a company partner (customer) and set a Reference value on the company contact under User->Settings -> Sales and Purchase. * Create a child contact under that company and set a different Reference value. * Create an invoice using the child contact as the invoice partner and confirm the invoice. * Send it via Peppol. **Observed Behaviour:** The BuyerReference in the generated XML contains the reference of the parent (commercial partner) Instead of the child contact used on the invoice. **Cause:** The buyer reference was taken from the commercial partner instead of the invoice partner. **Fix:** Update the condition to use the invoice partner's reference when available; Otherwise, fall back on the commercial partner's reference. opw - 6330649 closes odoo/odoo#283131 X-original-commit: 2e78f84f5807000de470e80d37736e38eb096459 Signed-off-by: Wala Gauthier (gawa) Signed-off-by: Krishna Pathak (pkri) --- .../models/account_edi_ubl_pint.py | 2 +- .../tests/test_ubl_export_bis3_be.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/addons/account_edi_ubl_cii/models/account_edi_ubl_pint.py b/addons/account_edi_ubl_cii/models/account_edi_ubl_pint.py index 0bc539c291c2b9..1a0cde9bfa509e 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_ubl_pint.py +++ b/addons/account_edi_ubl_cii/models/account_edi_ubl_pint.py @@ -108,7 +108,7 @@ def _ubl_add_buyer_reference_node(self, vals): super()._ubl_add_buyer_reference_node(vals) customer = vals['customer'] - if customer_ref := customer.commercial_partner_id.ref: + if customer_ref := customer.ref or customer.commercial_partner_id.ref: vals['document_node']['cbc:BuyerReference']['_text'] = customer_ref def _ubl_add_billing_reference_nodes(self, vals): diff --git a/addons/account_edi_ubl_cii/tests/test_ubl_export_bis3_be.py b/addons/account_edi_ubl_cii/tests/test_ubl_export_bis3_be.py index 446e622d382b05..3091b77c40e518 100644 --- a/addons/account_edi_ubl_cii/tests/test_ubl_export_bis3_be.py +++ b/addons/account_edi_ubl_cii/tests/test_ubl_export_bis3_be.py @@ -36,6 +36,27 @@ def test_invoice_item_description_name(self): self._generate_invoice_ubl_file(invoice) self._assert_invoice_ubl_file(invoice, 'test_invoice_item_description_name') + def test_invoice_buyer_reference_uses_partner_ref(self): + tax_21 = self.percent_tax(21.0) + product = self._create_product(lst_price=100.0, taxes_id=tax_21) + + customer_company = self.partner_be + customer_contact = self._create_partner(name='Customer contact 1', parent_id=customer_company.id, country_code='BE', ref='CONTACT-REF') + customer_company.ref = 'PARENT-REF' + + invoice = self._create_invoice_one_line( + product_id=product, + partner_id=customer_contact, + post=True, + ) + + self._generate_invoice_ubl_file(invoice) + + xml_tree = etree.fromstring(invoice.ubl_cii_xml_id.raw) + buyer_reference = xml_tree.find('.//{*}BuyerReference') + self.assertIsNotNone(buyer_reference) + self.assertEqual(buyer_reference.text, customer_contact.ref) + def test_invoice_payee_financial_account(self): bank_kbc = self.env['res.bank'].create({ 'name': 'KBC', From a7f55fbf30d459deec8c4f9e9edec008867a6458 Mon Sep 17 00:00:00 2001 From: Camila Vives Date: Thu, 13 Aug 2026 13:47:16 +0000 Subject: [PATCH 092/205] [FIX] account: point archive error to a reachable list of draft entries When archiving a journal that still has draft entries, the raised error tells the user to click the 'Journal Entries' smart button of the journal form and to filter on 'Draft' entries. Those instructions cannot be followed: - the smart button opens `action_account_moves_all_a`, which is named "Journal Items" and targets `account.move.line`, not `account.move`; - that action defaults to `search_default_posted`, so no draft record shows up; - draft entries with no line at all, such as the ones created through the incoming mail alias of a journal, have no `account.move.line` and stay invisible in that view even once the filter is switched; - the action menu of a move line list offers no way to post or delete the entries, and the action sets `create: 0`. The filter is also labelled "Unposted", not "Draft". The user is left with an empty list and concludes the error is wrong, while the draft entries do exist. Point to Accounting > Accounting > Journal Entries instead, where they are listed and can be posted or deleted. Also rename that smart button to "Journal Items", so its label matches the action it opens and no longer suggests it lists journal entries. closes odoo/odoo#283326 X-original-commit: d765c7b73d376997730435053689415ceec6b5ae Signed-off-by: Florian Gilbert (flg) --- addons/account/models/account_journal.py | 4 ++-- addons/account/views/account_journal_views.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index 9febd043f572b4..12fc5fa42ffc14 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -694,8 +694,8 @@ def _check_auto_post_draft_entries(self): if pending_moves: raise ValidationError(_("You can not archive a journal containing draft journal entries.\n\n" "To proceed:\n" - "1/ click on the top-right button 'Journal Entries' from this journal form\n" - "2/ then filter on 'Draft' entries\n" + "1/ go to Accounting > Accounting > Journal Entries\n" + "2/ filter on this journal and on 'Unposted' entries\n" "3/ select them all and post or delete them through the action menu")) @api.constrains('type', 'incoming_einvoice_notification_email') diff --git a/addons/account/views/account_journal_views.xml b/addons/account/views/account_journal_views.xml index a3454ab78e68d0..647f9e62608c4b 100644 --- a/addons/account/views/account_journal_views.xml +++ b/addons/account/views/account_journal_views.xml @@ -36,7 +36,7 @@ name="%(action_account_moves_all_a)d" icon="fa-book" context="{'search_default_journal_id':id}">
- Journal Entries + Journal Items
From 0a3d49fb5b90a86b2b841dc66e648cd2a562cc6f Mon Sep 17 00:00:00 2001 From: MaximeNoirhomme Date: Thu, 13 Aug 2026 11:41:54 +0200 Subject: [PATCH 093/205] [FIX] stock: relocate a quant stored in an already reserved package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Issue** If a move's package is already reserved, relocating that move raises an AccessError. **Steps to reproduce** - Activate the "Packages" feature in Inventory settings. - Activate track localization in settings - Create a new storable product. - Using an inventory adjustment, add some quantity of that product in Stock, in a new package X. - Create a sales order for that product and confirm it, so the quantity in Stock gets reserved. - Go to Inventory > Reporting > Locations. - Select the quant and try to relocate it, e.g. to WH/Input. -> Raises an AccessError: "Failed to write field stock.package.picking_ids" **Cause** Relocating a quant creates a move: https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/addons/stock/models/stock_quant.py#L1531 which creates a new move line without a `picking_id`: https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/addons/stock/models/stock_quant.py#L1276-L1287 Since both move lines (the new one and the one linked to the SO delivery) share the same `result_package_id`, in `_compute_picking_ids`, both move lines are grouped: https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/addons/stock/models/stock_package.py#L174-L176 Thus, two "pickings" end up associated with the package: the SO's, and `None`. While setting those pickings on the package, it tries to access them: https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/addons/stock/models/stock_package.py#L182 https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/odoo/orm/fields_relational.py#L1497-L1503 And since `self` isn't just `None`, this check won't be skipped: https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/odoo/orm/models.py#L4152 This eventually raises an AccessError since `None` gets filtered out by `filtered_domain`: https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/odoo/orm/models.py#L4154-L4156 https://github.com/odoo/odoo/blob/b9eb72eb1d3be841396cd5dcce827ec88ed9ee31/odoo/orm/fields_relational.py#L1504-L1505 opw-6427070 closes odoo/odoo#282209 Signed-off-by: Stéphane Diez (snd) --- addons/stock/models/stock_package.py | 2 +- addons/stock/tests/test_quant.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/addons/stock/models/stock_package.py b/addons/stock/models/stock_package.py index 2f29a93c6a6080..d9e2c351a6b71f 100644 --- a/addons/stock/models/stock_package.py +++ b/addons/stock/models/stock_package.py @@ -172,7 +172,7 @@ def _compute_package_info(self): def _compute_picking_ids(self): children_by_dest_pack, all_pack_ids = self._get_all_children_package_dest_ids() groups = self.env['stock.move.line']._read_group( - domain=[('state', 'not in', ['done', 'cancel']), ('result_package_id', 'in', all_pack_ids)], + domain=[('state', 'not in', ['done', 'cancel']), ('result_package_id', 'in', all_pack_ids), ('picking_id', '!=', False)], groupby=['result_package_id'], aggregates=['picking_id:array_agg']) pickings_by_package = {package.id: picking_ids for package, picking_ids in groups} diff --git a/addons/stock/tests/test_quant.py b/addons/stock/tests/test_quant.py index 8eba41c0852959..03934c6a3ffe16 100644 --- a/addons/stock/tests/test_quant.py +++ b/addons/stock/tests/test_quant.py @@ -1107,6 +1107,38 @@ def _get_relocate_wizard(quant_ids): with self.assertRaises(UserError): _get_relocate_wizard(quants_bab_AB) + def test_relocate_reserved_entire_package(self): + """Ensure that a package reserved for a move can still be relocated. + - The package is used by a confirmed delivery. + - The package is relocated to 'WH/stock/shelf 1'. + """ + package = self.env['stock.package'].create({'name': 'PACKX'}) + self.env['stock.quant']._update_available_quantity(self.productA, self.stock_location, 10, package_id=package) + quant = self.env['stock.quant'].search([('product_id', '=', self.productA.id)]) + + delivery = self.env['stock.picking'].create({ + 'picking_type_id': self.ref('stock.picking_type_out'), + 'location_id': self.stock_location.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'move_ids': [Command.create({ + 'product_id': self.productA.id, + 'location_id': self.stock_location.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'product_uom_qty': 10, + })], + }) + delivery.action_confirm() + self.assertEqual(delivery.move_ids.state, 'assigned') + self.assertEqual(delivery.move_line_ids.result_package_id, package) + + relocate_wizard = Form.from_action(self.env, quant.action_stock_quant_relocate()) + relocate_wizard.dest_location_id = self.shelf_1 + relocate_wizard.save().with_user(self.user_stock_manager).action_relocate_quants() + + relocated_quant = self.env['stock.quant'].search([('product_id', '=', self.productA.id)]) + self.assertEqual(relocated_quant.location_id, self.shelf_1) + self.assertEqual(package.picking_ids, delivery) + def test_inventory_adjustment_package(self): """ With the changes implemented in _get_inventory_move_values(), we want to make sure that it correctly writes the package and destination package for inventory adjustments in _apply_inventory(). """ From 3b79e2bff1afde91ebfd24e932f445e47533566a Mon Sep 17 00:00:00 2001 From: mjvi-odoo Date: Tue, 4 Aug 2026 16:00:50 +0530 Subject: [PATCH 094/205] [FIX] sale_timesheet: handle list values in customer SOL domain - Avoid converting list values in `_get_last_sol_of_customer_domain` to an invalid domain structure when computing the last sale order line of a customer. - Fix by using `str(domain)` as the cache key instead of the domain itself, while still passing the original `domain` to `search()`. This keeps the per-domain caching behavior intact and works for any domain, regardless of whether it contains list values. task-6425335 closes odoo/odoo#281897 Signed-off-by: Maxime de Neuville (mane) --- addons/sale_timesheet/models/project_task.py | 9 +++---- .../sale_timesheet/tests/test_sale_service.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/addons/sale_timesheet/models/project_task.py b/addons/sale_timesheet/models/project_task.py index 1bd3af2c39c1b6..dcad06341b54c1 100644 --- a/addons/sale_timesheet/models/project_task.py +++ b/addons/sale_timesheet/models/project_task.py @@ -61,13 +61,14 @@ def _search_remaining_hours_so(self, operator, value): def _compute_last_sol_of_customer(self): sol_per_domain = dict() for task in self: - domain = tuple(task._get_last_sol_of_customer_domain()) + domain = task._get_last_sol_of_customer_domain() if not domain: task.last_sol_of_customer = False continue - if domain not in sol_per_domain: - sol_per_domain[domain] = self.env['sale.order.line'].search(domain, limit=1) - task.last_sol_of_customer = sol_per_domain[domain] + domain_str = str(domain) + if domain_str not in sol_per_domain: + sol_per_domain[domain_str] = self.env['sale.order.line'].search(domain, limit=1) + task.last_sol_of_customer = sol_per_domain[domain_str] def _inverse_partner_id(self): super()._inverse_partner_id() diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 98d133a060f074..c33591b843ad98 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -996,3 +996,27 @@ def test_service_product_uom_default(self): product_form.service_policy = 'delivered_timesheet' product = product_form.save() self.assertEqual(product.uom_id, uom_day, "time UoM default was not respected") + + def test_compute_last_sol_of_customer_with_list_domain(self): + """Domain has a list leaf; used to raise TypeError: unhashable type: 'list' as a dict key. + """ + self.product_delivery_timesheet1.service_policy = 'ordered_prepaid' + order = self.sale_order + + sol = self.env['sale.order.line'].create({ + 'order_id': order.id, + 'product_id': self.product_delivery_timesheet1.id, + }) + order.action_confirm() + + task = self.env['project.task'].create({ + 'name': 'Task 1', + 'project_id': self.project_task_rate.id, + 'partner_id': self.partner_a.id, + 'sale_line_id': sol.id, + }) + self.assertEqual( + task.last_sol_of_customer, + sol, + "last_sol_of_customer should match the expected sale order line.", + ) From d529da06448dcde43b7ada5e0d02ccf39d6bb5d2 Mon Sep 17 00:00:00 2001 From: "Walid (wasa)" Date: Fri, 21 Aug 2026 10:06:39 +0200 Subject: [PATCH 095/205] [FIX] html_editor: keep font with background color when creating a list Problem: When creating a list from a text selection that has a background color applied (wrapped in a ``), the `` tag was unwrapped and its background color was lost on the created list item. Cause: `insertListAfter` unwrapped the `` element regardless of whether it contained a `background-color` style or other attributes. Solution: In `insertListAfter`, strip the `color` style from the font element to set `li.style.color`, and keep the `` tag if it still has attributes (such as `background-color`). opw-6481665 Part-of: odoo/odoo#283158 Signed-off-by: David Monjoie (dmo) --- .../html_editor/static/src/main/list/utils.js | 7 ++++-- .../static/tests/list/toggle_ul.test.js | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/addons/html_editor/static/src/main/list/utils.js b/addons/html_editor/static/src/main/list/utils.js index fc4d38fa7e1d92..6683a7dedf7aa9 100644 --- a/addons/html_editor/static/src/main/list/utils.js +++ b/addons/html_editor/static/src/main/list/utils.js @@ -1,4 +1,4 @@ -import { unwrapContents } from "@html_editor/utils/dom"; +import { removeStyle, unwrapContents } from "@html_editor/utils/dom"; import { closestElement, firstLeaf, lastLeaf } from "@html_editor/utils/dom_traversal"; import { getFontSizeOrClass } from "@html_editor/utils/formatting"; @@ -22,7 +22,10 @@ export function insertListAfter(document, afterNode, mode, content = []) { const lastClosestFont = closestElement(lastLeafNode, "font"); if (firstClosestFont && lastClosestFont && firstClosestFont === lastClosestFont) { li.style.color = firstClosestFont.style.color; - unwrapContents(firstClosestFont); + removeStyle(firstClosestFont, "color"); + if (!firstClosestFont.hasAttributes()) { + unwrapContents(firstClosestFont); + } } const firstClosestSpan = closestElement(firstLeafNode, "span"); const lastClosestSpan = closestElement(lastLeafNode, "span"); diff --git a/addons/html_editor/static/tests/list/toggle_ul.test.js b/addons/html_editor/static/tests/list/toggle_ul.test.js index 4a13c7c050fd08..1462e644fb62d9 100644 --- a/addons/html_editor/static/tests/list/toggle_ul.test.js +++ b/addons/html_editor/static/tests/list/toggle_ul.test.js @@ -24,6 +24,30 @@ describe("Range collapsed", () => { }); }); + test("should preserve color and background color when creating a list", async () => { + await testEditor({ + contentBefore: `

[a]

`, + stepFunction: toggleUnorderedList, + contentAfter: `
  • [a]
`, + }); + }); + + test("should preserve color creating a list (1)", async () => { + await testEditor({ + contentBefore: `

[a]

`, + stepFunction: toggleUnorderedList, + contentAfter: `
  • [a]
`, + }); + }); + + test("should preserve color creating a list (2)", async () => { + await testEditor({ + contentBefore: `

[a]

`, + stepFunction: toggleUnorderedList, + contentAfter: `
  • [a]
`, + }); + }); + test("should turn a paragraph into a list", async () => { await testEditor({ contentBefore: "

ab[]cd

", From 3706b769ceadff14a2a1bc68ad5d7f5201b9dbee Mon Sep 17 00:00:00 2001 From: "Walid (wasa)" Date: Fri, 21 Aug 2026 10:06:42 +0200 Subject: [PATCH 096/205] [FIX] html_editor: allow removing background color from list item Problem: When clearing format/color on a list item that has `background-color` on `li.style`, the background color was not removed from the list item. Cause: `ListPlugin`'s color handler only removed `color` style from `li` nodes when clearing colors, ignoring `backgroundColor`. Solution: In `ListPlugin`, check for and remove `background-color` style from `li` elements when clearing formatting/color. opw-6481665 closes odoo/odoo#283158 Signed-off-by: David Monjoie (dmo) --- addons/html_editor/static/src/main/list/list_plugin.js | 5 ++++- addons/html_editor/static/tests/list/color_list.test.js | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/addons/html_editor/static/src/main/list/list_plugin.js b/addons/html_editor/static/src/main/list/list_plugin.js index 07927db1afe6aa..8cdeebef22ed39 100644 --- a/addons/html_editor/static/src/main/list/list_plugin.js +++ b/addons/html_editor/static/src/main/list/list_plugin.js @@ -1128,7 +1128,7 @@ export class ListPlugin extends Plugin { const listItems = new Set( targetedNodes.map((n) => closestElement(n, "li")).filter(Boolean) ); - if (!listItems.size || mode !== "color" || isColorGradient(color)) { + if (!listItems.size || (mode !== "color" && color) || isColorGradient(color)) { return; } const cursors = this.dependencies.selection.preserveSelection(); @@ -1152,6 +1152,9 @@ export class ListPlugin extends Plugin { if (node.style.color) { removeStyle(node, "color"); } + if (node.style.backgroundColor) { + removeStyle(node, "background-color"); + } } if (color) { diff --git a/addons/html_editor/static/tests/list/color_list.test.js b/addons/html_editor/static/tests/list/color_list.test.js index 363e93b6c5341c..f2b1ac246569c4 100644 --- a/addons/html_editor/static/tests/list/color_list.test.js +++ b/addons/html_editor/static/tests/list/color_list.test.js @@ -375,3 +375,11 @@ test("should apply gradient color style only on font inside list item", async () '
  1. [abc]
  2. def
', }); }); + +test("should be able to remove background-color from list item", async () => { + await testEditor({ + contentBefore: `
  • [a]
`, + stepFunction: (editor) => execCommand(editor, "removeFormat"), + contentAfter: `
  • [a]
`, + }); +}); From 34e6cb8f28003f84b1bb7b48268414ccc48c05bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9na=20Nshimiyimana?= Date: Wed, 19 Aug 2026 14:43:28 +0200 Subject: [PATCH 097/205] [FIX] sale: make quotation and order sentences translatable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quotation and pro forma email templates inserted either "quotation" or "order" into shared translatable text. In French, for example, "devis" is masculine while "commande" is feminine, so the surrounding articles and adjectives cannot agree with both terms. Define a complete sentence for each document state so translators can translate the surrounding grammar independently. opw-6445304 closes odoo/odoo#283229 Signed-off-by: Séna Serge Nshimiyimana (sesn) --- addons/sale/data/mail_template_data.xml | 26 ++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/addons/sale/data/mail_template_data.xml b/addons/sale/data/mail_template_data.xml index 16f3afc58bacb4..ad9e06dea38256 100644 --- a/addons/sale/data/mail_template_data.xml +++ b/addons/sale/data/mail_template_data.xml @@ -12,14 +12,22 @@

- Hello,

- Your quotation - - (with reference: S00052 ) + + Your quotation + + (with reference: S00052 ) + + amounting in $ 10.00 is ready for review. + + + Your order + + (with reference: S00052 ) + + amounting in $ 10.00 is ready for review. - amounting in $ 10.00 is ready for review.
@@ -63,10 +71,14 @@

- Hello,

- Your Pro forma invoice for quotation S00052 + + Your Pro forma invoice for quotation S00052 + + + Your Pro forma invoice for order S00052 + (with reference: ) From bb8a4bdb11d8226001c413d843a5a6f45f1189fd Mon Sep 17 00:00:00 2001 From: pkri-odoo Date: Mon, 27 Jul 2026 10:38:50 +0000 Subject: [PATCH 098/205] [FIX] l10n_fr_pdp: check SIREN number for B2C partner **Steps to reproduce:** - Install module `l10n_fr_pdp` and configure French e-Invoicing. - Create a customer has a valid SIREN/SIRET (company_registry) but no VAT number. - Create an invoice for the customer and confirm the invoice. - Check the available sending methods. **Observed Behavior:** The French E-Invoicing option is disabled because the customer is identified as a B2C partner when no VAT number is set. **Cause**: The B2C detection relies on the partner's VAT number instead of its SIREN/SIRET. As a result, French companies without a VAT number but with a valid SIREN are classified as B2C. **Fix**: Determine whether a partner is B2C based on the presence of a valid SIREN/SIRET (derived from `company_registry`) instead of the VAT number. This correctly i dentifies French business partners that are eligible for French e-Invoicing even when they do not have a VAT number configured. opw-6357756 closes odoo/odoo#283357 X-original-commit: d4722e3aa48529d99cfadf9b1b1ea2689b11aba7 Signed-off-by: Wala Gauthier (gawa) --- addons/l10n_fr_pdp/models/account_edi_xml_ubl_21_fr.py | 4 +--- addons/l10n_fr_pdp/models/res_partner.py | 2 +- addons/l10n_fr_pdp/wizard/account_move_send_wizard.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/addons/l10n_fr_pdp/models/account_edi_xml_ubl_21_fr.py b/addons/l10n_fr_pdp/models/account_edi_xml_ubl_21_fr.py index 773b3b5f222b63..f7efa08bddc8b0 100644 --- a/addons/l10n_fr_pdp/models/account_edi_xml_ubl_21_fr.py +++ b/addons/l10n_fr_pdp/models/account_edi_xml_ubl_21_fr.py @@ -30,9 +30,7 @@ def _export_invoice_constraints(self, invoice, vals): constraints[f"ubl_21_fr_{partner_type}_pdp_identifier_required"] = self.env._("The following partner's PDP identifier is missing: %s", commercial_partner.display_name) id_type, id_value = commercial_partner._l10n_fr_pdp_get_base_identifier() if not id_type or not id_value: - constraints[f"ubl_21_fr_{partner_type}_siret_required"] = self.env._("The following partner's SIREN or SIRET is missing: %s", commercial_partner.display_name) - if not commercial_partner.vat or commercial_partner.vat == '/': - constraints[f"ubl_21_fr_{partner_type}_vat_required"] = self.env._("The following partner's VAT is missing: %s", commercial_partner.display_name) + constraints[f"ubl_21_fr_{partner_type}_identifier_required"] = self.env._("The following partner's SIREN or SIRET is missing: %s", commercial_partner.display_name) if vals['document_type'] == 'credit_note' and not (invoice.reversed_entry_id.name or invoice.reversed_entry_id.invoice_date): constraints[f"ubl_21_fr_{partner_type}_refund_invoice_reference"] = self.env._("The original journal entry's name or issue date are missing: %s", vals['invoice'].name) diff --git a/addons/l10n_fr_pdp/models/res_partner.py b/addons/l10n_fr_pdp/models/res_partner.py index 0b5bdf1a530ea4..0ad96aee623399 100644 --- a/addons/l10n_fr_pdp/models/res_partner.py +++ b/addons/l10n_fr_pdp/models/res_partner.py @@ -77,7 +77,7 @@ def _check_pdp_send_ubl_21_fr(self): def _l10n_fr_pdp_is_b2c(self): self.ensure_one() - return self.vat == '/' or not self.vat + return not self._l10n_fr_pdp_get_siren() def _l10n_fr_pdp_get_siren(self): self.ensure_one() diff --git a/addons/l10n_fr_pdp/wizard/account_move_send_wizard.py b/addons/l10n_fr_pdp/wizard/account_move_send_wizard.py index 9f1d184e234b74..c9ccee19d8258d 100644 --- a/addons/l10n_fr_pdp/wizard/account_move_send_wizard.py +++ b/addons/l10n_fr_pdp/wizard/account_move_send_wizard.py @@ -24,7 +24,7 @@ def _get_peppol_checkbox_addendum_disable_reason(self): verification_display_state_map = dict(pdp_partner._fields['pdp_verification_display_state']._description_selection(self.env)) reason = None if pdp_partner._l10n_fr_pdp_is_b2c(): - reason = self.env._("no VAT") + reason = self.env._("No Siren/Siret") if not partner_is_valid: reason = verification_display_state_map[pdp_partner.pdp_verification_display_state] if self.move_id.peppol_is_sent: From 0f7c36bdc078c44bbe61211b3745bb12a4679e72 Mon Sep 17 00:00:00 2001 From: "Robin Engels (roen)" Date: Fri, 24 Jul 2026 10:29:59 +0000 Subject: [PATCH 099/205] [FIX] hr_timesheet: sync project and task When modifying project_id on a timesheet through mass edit/rpc or anything that is not triggering `onChange`. The task_id would not be reset if it doesnt' belong to the new project set on the timesheet. Steps to reproduce: ------------------- * Install studio for easier reproducing of the issue * Open the timesheet list view * Open studio and activate the mass edit on the view * Modify the project_id on multiple records > Observation: The task_id stays the same even if they do not belong to the new set project Why the fix: ------------ Instead of relying only on the onChange we add an inverse to the project_id that will reset the task when needed. opw-6259149 closes odoo/odoo#281559 X-original-commit: b83c9ce6019c6fe14eca60572583dd887f6abe40 Signed-off-by: Xavier Bol (xbo) Signed-off-by: Robin Engels (roen) --- addons/hr_timesheet/models/hr_timesheet.py | 7 ++++++- addons/hr_timesheet/tests/test_timesheet.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 274139254c6d33..f0fa9e843f6807 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -66,7 +66,7 @@ def _domain_employee_id(self): parent_task_id = fields.Many2one('project.task', related='task_id.parent_id', store=True, index='btree_not_null') project_id = fields.Many2one( 'project.project', 'Project', domain=_domain_project_id, index=True, - compute='_compute_project_id', store=True, readonly=False) + compute='_compute_project_id', inverse='_inverse_project_id', store=True, readonly=False) user_id = fields.Many2one(compute='_compute_user_id', store=True, readonly=False) employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id, context={'active_test': False}, index=True, help="Define an 'hourly cost' on the employee to track the cost of their time.") @@ -142,6 +142,11 @@ def _compute_project_id(self): continue line.project_id = line.task_id.project_id + def _inverse_project_id(self): + for line in self: + if line.task_id.project_id != line.project_id: + line.sudo().task_id = False + @api.depends('project_id') def _compute_task_id(self): self.filtered(lambda t: not t.project_id).task_id = False diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index e0afae9af13cdb..39203a3191035e 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -1087,3 +1087,12 @@ def test_is_project_overtime_filter(self): self.project, self.env['project.project'].search([('is_project_overtime', '=', True)]) ) + + def test_task_reset_on_project_change(self): + """ Changing the project_id of a timesheet should reset its task_id + if the task doesn't belong to the new project""" + + self.assertTrue(self.timesheet.task_id) + self.timesheet.write({'project_id': self.project_customer.id}) + self.assertFalse(self.timesheet.task_id) + self.assertEqual(self.timesheet.project_id, self.project_customer) From 287fa9bf0360cdafb7e46bf72af5512bed8ab3c4 Mon Sep 17 00:00:00 2001 From: thle-odoo Date: Thu, 6 Aug 2026 08:33:09 +0000 Subject: [PATCH 100/205] [FIX] auth_signup: ensure `signup_type` A `signup_type` is required to generate a token. Task-6452339 closes odoo/odoo#283343 X-original-commit: 6873cd9058ff51470c856c0d4a8d3ef3b56447e0 Signed-off-by: Walravens Mathieu (wama) Signed-off-by: Thomas Lefebvre (thle) --- addons/auth_signup/models/res_partner.py | 3 +-- addons/auth_signup/tests/test_auth_signup.py | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index d4cf1ad68821e8..d60137f885b3f3 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -55,8 +55,7 @@ def _get_signup_url_for_action(self, url=None, action=None, view_type=None, menu signup_type = self.env.context.get('signup_force_type_in_url', partner.sudo().signup_type or '') if signup_type: route = 'reset_password' if signup_type == 'reset' else signup_type - - query['token'] = partner.sudo()._generate_signup_token() + query['token'] = partner.sudo()._generate_signup_token() if url: query['redirect'] = url diff --git a/addons/auth_signup/tests/test_auth_signup.py b/addons/auth_signup/tests/test_auth_signup.py index 6dc9e5d990ce0b..8611d63fd61336 100644 --- a/addons/auth_signup/tests/test_auth_signup.py +++ b/addons/auth_signup/tests/test_auth_signup.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit import odoo from odoo import http @@ -101,6 +102,12 @@ def test_compute_signup_url(self): with self.assertRaises(AccessError): partner.with_user(user.id)._get_signup_url() + # Assert no token generated if no signup type + partner.signup_cancel() + signup_url = partner._get_signup_url() + signup_url_params = parse_qs(urlsplit(signup_url).query) + self.assertFalse(signup_url_params.get('token')) + def test_copy_multiple_users(self): users = self.env['res.users'].create([ {'login': 'testuser1', 'name': 'Test User 1', 'email': 'test1@odoo.com'}, From 604caaa171157541baeb12c0e218913c6fed6d29 Mon Sep 17 00:00:00 2001 From: bhna-odoo Date: Mon, 20 Jul 2026 06:02:41 +0000 Subject: [PATCH 101/205] [FIX] l10n_account_withholding_tax: added fallback value for currency Currently, an error occurs when user tries to pay on a vendor bill and removes the currency. Steps to replicate: - Install `l10n_account_withholding_tax`and activate multiple currencies. - Open Invoicing > Vendors > Bills and create a new bill and add a vendor and bill date. - Add a product and tax `2% WTH`. - From the Cog menu > Click Pay > Remove the Currency. Error: ``` File '/home/odoo/src/odoo/saas-19.4/addons/l10n_account_withholding_tax/models/account_withholding_line.py', line 208, in _compute_original_amounts line.original_base_amount = line_curr.round(base_amount * rate) File '/home/odoo/src/odoo/saas-19.4/odoo/addons/base/models/res_currency.py', line 264, in round self.ensure_one() File '/home/odoo/src/odoo/saas-19.4/odoo/orm/models.py', line 5342, in ensure_one raise ValueError('Expected singleton: %s' % self) ValueError: Expected singleton: res.currency() ``` Cause: - As the user removed currency, the `comodel_currency_id`is received as false. - Later when we call `round()` on the empty res.currency recordset causes this error to occur. Solution: - Added the company currency as a fallback value when `currency_id` is removed by user, since `currency_id` is a required field user will need to select a currency when saving. sentry-7616890592 closes odoo/odoo#283157 X-original-commit: 80161376fc15cf667ee95c82bb3256665092f3f3 Signed-off-by: Wala Gauthier (gawa) Signed-off-by: Bhavya Ashesh Nanavati (bhna) --- .../models/account_withholding_line.py | 6 +++--- .../tests/test_account_withholding_flows.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/addons/l10n_account_withholding_tax/models/account_withholding_line.py b/addons/l10n_account_withholding_tax/models/account_withholding_line.py index cd0d0f97df78b5..d00a28781a4641 100644 --- a/addons/l10n_account_withholding_tax/models/account_withholding_line.py +++ b/addons/l10n_account_withholding_tax/models/account_withholding_line.py @@ -163,7 +163,7 @@ def _compute_original_amounts(self): company = line.company_id date = line.comodel_date comp_curr = line.comodel_company_currency_id - line_curr = line.comodel_currency_id + line_curr = line.comodel_currency_id or comp_curr if not source_curr: rate = 1.0 base_amount = line.base_amount @@ -214,7 +214,7 @@ def _compute_base_amount(self): support installments, early payment discounts,... """ for line in self: - line_curr = line.comodel_currency_id + line_curr = line.comodel_currency_id or line.comodel_company_currency_id if line.source_currency_id: percentage_paid_factor = line.comodel_percentage_paid_factor line.base_amount = line_curr.round(line.original_base_amount * percentage_paid_factor) @@ -226,7 +226,7 @@ def _compute_amount(self): a ratio calculated from the current base amount and the original base amount. """ for line in self: - line_curr = line.comodel_currency_id + line_curr = line.comodel_currency_id or line.comodel_company_currency_id if line.original_base_amount: line.amount = line_curr.round(line.original_tax_amount * line.base_amount / line.original_base_amount) else: diff --git a/addons/l10n_account_withholding_tax/tests/test_account_withholding_flows.py b/addons/l10n_account_withholding_tax/tests/test_account_withholding_flows.py index 416f66e3be82b2..2e81441f523119 100644 --- a/addons/l10n_account_withholding_tax/tests/test_account_withholding_flows.py +++ b/addons/l10n_account_withholding_tax/tests/test_account_withholding_flows.py @@ -1149,3 +1149,21 @@ def test_tax_repartition_on_refund(self): {"balance": 1000.0, "tax_tag_ids": []}, {"balance": -1000.0, "tax_tag_ids": base_tag.ids}, ]) + + def test_payment_register_with_empty_currency(self): + withholding_tax = self.percent_tax( + -1, + is_withholding_tax_on_payment=True, + withholding_sequence_id=self.withholding_sequence.id + ) + + invoice = self._create_invoice_one_line(tax_ids=withholding_tax, price_unit=1000.0) + + payment_register = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=invoice.ids)\ + .create({}) + + payment_register_form = Form(payment_register) + payment_register_form.currency_id = self.env['res.currency'] + + self.assertEqual(payment_register.withholding_line_ids.original_base_amount, 1000.0) From d9f4b452b358d3efb07e761c684ce2746ad55f70 Mon Sep 17 00:00:00 2001 From: "Majed Alhanash (malh)" Date: Thu, 23 Jul 2026 09:06:40 +0000 Subject: [PATCH 102/205] [FIX] pos_sale: fix sale order invoicing after down payment refund The following commit resets qty_invoiced to zero on sale order lines paid by a POS order when that order is refunded. https://github.com/odoo/odoo/commit/ac39aa4f68dfc77011c39e468e3f60e0338a3c69 However, it does not handle the sale order line created for a refunded POS down payment. That line keeps `qty_invoiced` = -1, which causes the refunded amount to be included again when settling or invoicing the sale order. Steps to reproduce: - Create a sale order. - Pay a down payment through the POS. - Refund the down payment order from the POS. - Settle the remaining amount from the POS or invoice the sale order from the backend. Result: - The generated invoice includes the sale order total plus the refunded down payment. - Sale order `amount_invoiced` will be the down payment amount. Fix: - Delete the refunded downpayment to match the sale flow. - Include refunded down payments in the amount_invoiced computation. opw-6378891 closes odoo/odoo#283308 X-original-commit: 8b99fc60167746efad28fd2e9d221238266dc3dd Signed-off-by: Adrien Guilliams (adgu) Signed-off-by: Majed Alhanash (malh) --- addons/pos_sale/models/pos_order.py | 12 +++++++++++- addons/pos_sale/models/sale_order.py | 6 +++++- addons/pos_sale/tests/test_pos_sale_flow.py | 14 ++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/addons/pos_sale/models/pos_order.py b/addons/pos_sale/models/pos_order.py index e6a876e6021964..c8683b25fcf064 100644 --- a/addons/pos_sale/models/pos_order.py +++ b/addons/pos_sale/models/pos_order.py @@ -84,7 +84,17 @@ def sync_from_ui(self, orders): line not in used_pos_lines and line.product_id == pos_order.config_id.down_payment_product_id )) - so_x_pos_order_lines = downpayment_pos_order_lines\ + downpayment_refund_lines = downpayment_pos_order_lines.filtered('refunded_orderline_id') + new_downpayment_lines = downpayment_pos_order_lines - downpayment_refund_lines + + for refund_line in downpayment_refund_lines: + original_sale_line = refund_line.refunded_orderline_id.sale_order_line_id + if original_sale_line: + original_sale_line.price_unit = sum( + original_sale_line.pos_order_line_ids.mapped('price_unit') + ) - refund_line.price_unit + + so_x_pos_order_lines = new_downpayment_lines\ .grouped(lambda l: l.sale_order_origin_id or l.refunded_orderline_id.sale_order_origin_id) sale_orders = self.env['sale.order'] for sale_order, pos_order_lines in so_x_pos_order_lines.items(): diff --git a/addons/pos_sale/models/sale_order.py b/addons/pos_sale/models/sale_order.py index bb73e314d60f3b..491cc5a2dede72 100644 --- a/addons/pos_sale/models/sale_order.py +++ b/addons/pos_sale/models/sale_order.py @@ -100,7 +100,11 @@ def _compute_amount_invoiced(self): if order.invoice_status == 'invoiced': continue # We need to account for the downpayment paid in POS with and without invoice - order_amount = sum(order.sudo().pos_order_line_ids.filtered(lambda pol: pol.order_id.state in ['paid', 'done', 'invoiced'] and pol.sale_order_line_id.is_downpayment).mapped('price_subtotal_incl')) + order_lines = order.sudo().pos_order_line_ids.filtered(lambda pol: pol.sale_order_line_id.is_downpayment) + pos_lines = order_lines | order_lines.refund_orderline_ids + order_amount = sum(pos_lines.filtered( + lambda pol: pol.order_id.state in ['paid', 'done', 'invoiced'] + ).mapped(lambda line: line.price_subtotal_incl * line.qty)) order.amount_invoiced += order_amount def _prepare_down_payment_line_values_from_base_line(self, base_line): diff --git a/addons/pos_sale/tests/test_pos_sale_flow.py b/addons/pos_sale/tests/test_pos_sale_flow.py index 5b32419382a681..63df9f33a0e304 100644 --- a/addons/pos_sale/tests/test_pos_sale_flow.py +++ b/addons/pos_sale/tests/test_pos_sale_flow.py @@ -200,9 +200,19 @@ def test_downpayment_refund(self): }) self.main_pos_config.open_ui() self.start_pos_tour('PosRefundDownpayment', login="accountman") - self.assertEqual(len(sale_order.order_line), 4) + self.assertEqual(len(sale_order.order_line), 3) self.assertEqual(sale_order.order_line[2].qty_invoiced, 0) - self.assertEqual(sale_order.order_line[3].qty_invoiced, -1) + self.assertEqual(sale_order.order_line[2].price_unit, 0) + self.assertEqual(sale_order.amount_invoiced, 0) + payment = self.env['sale.advance.payment.inv'].with_context( + active_model='sale.order', + active_ids=sale_order.ids, + active_id=sale_order.id, + ).create({ + 'advance_payment_method': 'delivered', + }) + payment.create_invoices() + self.assertEqual(sale_order.invoice_ids.amount_untaxed, 100) def test_settle_order_unreserve_order_lines(self): #create a product category that use the closest location for the removal strategy From 04ae01f5fce21978d955c3cd52cfdba29196eb42 Mon Sep 17 00:00:00 2001 From: Jitendra Prajapat Date: Fri, 21 Nov 2025 13:35:21 +0000 Subject: [PATCH 103/205] [FIX] pos_self_order: send receipt email with attachment for paid orders Before this commit: ================== - Emails were sent from `_send_self_order_receipt` backend calls during order/payment processing. - Receipt image generation was not possible from the backend, so `fullTicketImage` and `basicTicketImage` were hardcoded to `false`. - This caused all emails to be sent without any receipt attachment, even for paid orders. After this commit: ================== - Added controller `/pos-self-order/send_self_order_receipt` as the single entry point to handle sending receipt emails with generated images. - Unpaid orders continue to send a normal email without attachment. - Paid/done orders now send an email with the receipt attachment. - `_send_self_order_receipt()` is converted into a parameterless hook to preserve compatibility with `pos_blackbox_be` while preventing duplicate email delivery. Task-5353350 closes odoo/odoo#281782 X-original-commit: f532c5a673d84276d3c073cd23e4b903be344365 Signed-off-by: David Monnom (moda) Signed-off-by: Jitendra Kumar Prajapat (jipr) --- .../tests/test_self_order_fake_payment.py | 21 +++++++++ addons/pos_self_order/controllers/orders.py | 18 ++++++++ addons/pos_self_order/models/pos_order.py | 15 ++----- .../src/app/pages/cart_page/cart_page.js | 2 + .../src/app/services/self_order_service.js | 45 +++++++++++++++++++ .../tests/test_takeaway_preset_mail.py | 1 + 6 files changed, 91 insertions(+), 11 deletions(-) diff --git a/addons/pos_online_payment_self_order/tests/test_self_order_fake_payment.py b/addons/pos_online_payment_self_order/tests/test_self_order_fake_payment.py index 4dbb2d6500a7b6..ddc15ab9d70b9d 100644 --- a/addons/pos_online_payment_self_order/tests/test_self_order_fake_payment.py +++ b/addons/pos_online_payment_self_order/tests/test_self_order_fake_payment.py @@ -1,3 +1,4 @@ +import json import odoo.tests from odoo import Command from odoo.addons.mail.tests.common import MailCase @@ -87,6 +88,15 @@ def test_online_payment_kiosk_no_confirmation_page(self): @odoo.tests.tagged("post_install", "-at_install") class TestSelfOrderFakePaymentMail(MailCase, TestSelfOrderMobile): + def make_request_to_controller(self, url, params): + response = self.url_open(url, json.dumps({'jsonrpc': '2.0', 'params': params}), + method='POST', + headers={ + 'Content-Type': 'application/json', + } + ) + return response.json().get('result') + def test_online_payment_mobile_sends_mail_after_payment(self): self.pos_config.write({ 'self_ordering_mode': 'mobile', @@ -124,6 +134,17 @@ def test_online_payment_mobile_sends_mail_after_payment(self): order = self.env['pos.order'].browse(order.id) self.assertEqual(order.state, 'paid') + + # Call the controller to send receipt email + with self.mock_mail_gateway(): + self.make_request_to_controller('/pos-self-order/send_self_order_receipt', { + 'access_token': self.pos_config.access_token, + 'order_id': order.id, + 'order_access_token': order.access_token, + 'fullTicketImage': False, + 'basicTicketImage': False, + }) + self.assertEqual(len(self._new_mails), 1) self.assertEqual(self._new_mails.email_to, order.email) self.assertIn('receipt', (self._new_mails.subject or '').lower()) diff --git a/addons/pos_self_order/controllers/orders.py b/addons/pos_self_order/controllers/orders.py index 052e458606cd4e..cd76ff42fcb486 100644 --- a/addons/pos_self_order/controllers/orders.py +++ b/addons/pos_self_order/controllers/orders.py @@ -92,6 +92,24 @@ def remove_order(self, access_token, order_id, order_access_token): pos_order.remove_from_ui([pos_order.id]) + @http.route('/pos-self-order/send_self_order_receipt', auth='public', type='jsonrpc', website=True) + def send_self_order_receipt(self, access_token, order_id, order_access_token, fullTicketImage=None, basicTicketImage=None): + pos_config = self._verify_pos_config(access_token) + pos_order = pos_config.env['pos.order'].browse(order_id) + + if not pos_order.exists() or not consteq(pos_order.access_token, order_access_token): + raise MissingError(self.env._("Your order does not exist or has been removed")) + + if not pos_order.email or not pos_order.preset_id.mail_template_id: + return + + # Only send receipt attachment for paid/done orders; draft/unpaid get normal email without attachment + if pos_order.state not in ('paid', 'done'): + pos_order.action_send_self_order_receipt(pos_order.email, pos_order.preset_id.mail_template_id.id, False, False) + return + + pos_order.action_send_self_order_receipt(pos_order.email, pos_order.preset_id.mail_template_id.id, fullTicketImage, basicTicketImage) + @http.route('/pos-self-order/get-user-data', auth='public', type='jsonrpc', website=True) def get_orders_by_access_token(self, access_token, order_access_tokens, table_identifier=None): pos_config = self._verify_pos_config(access_token) diff --git a/addons/pos_self_order/models/pos_order.py b/addons/pos_self_order/models/pos_order.py index 7bcd2d602f8729..20a7c3f723560a 100644 --- a/addons/pos_self_order/models/pos_order.py +++ b/addons/pos_self_order/models/pos_order.py @@ -82,18 +82,11 @@ def _send_notification(self, order_ids): config.notify_synchronisation(config.current_session_id.id, self.env.context.get('device_identifier', 0)) config._notify('ORDER_STATE_CHANGED', {}) + # TODO: remove in master def _send_self_order_receipt(self): + """Hook for receipt processing extensions such as the blackbox module.""" self.ensure_one() - if ( - self.state not in ('paid', 'done') - or not self.email - or not self.preset_id.mail_template_id - ): - return - try: - self.action_send_self_order_receipt(self.email, self.preset_id.mail_template_id.id, False, False) - except UserError as e: - _logger.warning("Error while sending email: %s", e.args[0]) + return def action_send_self_order_receipt(self, email, mail_template_id, ticket_image, basic_image): self.ensure_one() @@ -102,7 +95,7 @@ def action_send_self_order_receipt(self, email, mail_template_id, ticket_image, if not mail_template: raise UserError(_("The mail template with xmlid %s has been deleted.", mail_template_id)) email_values = {'email_to': email} - if self.state == 'paid' and ticket_image: + if self.state in ('paid', 'done') and ticket_image: email_values['attachment_ids'] = self._get_mail_attachments(self.name, ticket_image, basic_image) mail_template.send_mail(self.id, force_send=True, email_values=email_values) diff --git a/addons/pos_self_order/static/src/app/pages/cart_page/cart_page.js b/addons/pos_self_order/static/src/app/pages/cart_page/cart_page.js index ad26eddf5ba4a9..786ef66f11d598 100644 --- a/addons/pos_self_order/static/src/app/pages/cart_page/cart_page.js +++ b/addons/pos_self_order/static/src/app/pages/cart_page/cart_page.js @@ -148,6 +148,7 @@ export class CartPage extends Component { } } + // TODO: remove in master generateTicketImage = async (basicReceipt = false) => await this.renderer.toJpeg( OrderReceipt, @@ -158,6 +159,7 @@ export class CartPage extends Component { { addClass: "pos-receipt-print p-3" } ); + // TODO: remove in master async _sendReceiptToCustomer({ action, destination, mail_template_id }) { const order = this.selfOrder.currentOrder; const fullTicketImage = await this.generateTicketImage(); diff --git a/addons/pos_self_order/static/src/app/services/self_order_service.js b/addons/pos_self_order/static/src/app/services/self_order_service.js index 7d34438d15ec5f..8eb45899d0bf15 100644 --- a/addons/pos_self_order/static/src/app/services/self_order_service.js +++ b/addons/pos_self_order/static/src/app/services/self_order_service.js @@ -314,6 +314,12 @@ export class SelfOrder extends Reactive { throw new Error("No access token provided for confirmation page"); } + // If the order uses a preset with a mail template, send the receipt to the customer via email + const order = this.models["pos.order"].find((o) => o.access_token === access_token); + if (order.preset_id?.raw?.mail_template_id) { + await this.sendReceiptToCustomer(order); + } + this.router.navigate("confirmation", { orderAccessToken: access_token || this.currentOrder.access_token, screenMode: screen_mode, @@ -816,6 +822,11 @@ export class SelfOrder extends Reactive { openOrder.recomputeChanges(); } this.data.debouncedSynchronizeLocalDataInIndexedDB(); + const order = result["pos.order"]?.[0]; + // If the order is paid and uses a preset with a mail template, send the receipt to the customer via email with attachment + if (order?.state === "paid" && order.preset_id?.raw?.mail_template_id) { + this.sendReceiptToCustomer(order); + } } catch (error) { this.handleErrorNotification( error, @@ -1012,6 +1023,40 @@ export class SelfOrder extends Reactive { } return "none"; } + + async generateTicketImage(order, basicReceipt = false) { + return await this.renderer.toJpeg( + OrderReceipt, + { + order: order, + basic_receipt: basicReceipt, + }, + { addClass: "pos-receipt-print p-3" } + ); + } + + async sendReceiptToCustomer(order) { + if (!order?.preset_id?.raw?.mail_template_id) { + return; + } + const fullTicketImage = ["paid", "done"].includes(order.state) + ? await this.generateTicketImage(order) + : null; + const basicTicketImage = this.config.basic_receipt + ? await this.generateTicketImage(order, true) + : null; + try { + await rpc("/pos-self-order/send_self_order_receipt", { + access_token: this.access_token, + order_id: order.id, + order_access_token: order.access_token, + fullTicketImage, + basicTicketImage, + }); + } catch (error) { + this.handleErrorNotification(error); + } + } } export const selfOrderService = { diff --git a/addons/pos_self_order/tests/test_takeaway_preset_mail.py b/addons/pos_self_order/tests/test_takeaway_preset_mail.py index 13c0c2ae54c6d8..4b0b1709245fea 100644 --- a/addons/pos_self_order/tests/test_takeaway_preset_mail.py +++ b/addons/pos_self_order/tests/test_takeaway_preset_mail.py @@ -22,3 +22,4 @@ def test_preset_takeaway_email_tour(self): # Message is posted and mail is sent on time self.assertEqual(len(self._new_mails), 1) self.assertEqual(self._new_mails.subject, "Your BarTest receipt") + self.assertEqual(len(self._new_mails.attachment_ids), 1) From 68b1618e05dd94c135b367d90d5d905f1c0b4e93 Mon Sep 17 00:00:00 2001 From: Mohamed-moali Date: Tue, 18 Aug 2026 16:12:30 +0200 Subject: [PATCH 104/205] [IMP] hr_recruitment: adding domains for hr.job In order to maintain proper job listings and make sure all working schedules are valid, working schedule domain is now depeding on the job listing company. closes odoo/odoo#282993 Task: 6408914 Related: odoo/enterprise#128241 Signed-off-by: Abdelrahman Mahmoud (amah) --- addons/hr/models/hr_job.py | 2 +- addons/hr_recruitment/models/hr_applicant.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/addons/hr/models/hr_job.py b/addons/hr/models/hr_job.py index 9068ed87f79a7c..5909f97613a702 100644 --- a/addons/hr/models/hr_job.py +++ b/addons/hr/models/hr_job.py @@ -36,7 +36,7 @@ class HrJob(models.Model): # TODO (master): remove the field `allowed_user_ids`. allowed_user_ids = fields.Many2many('res.users', compute='_compute_allowed_user_ids', readonly=True) department_id = fields.Many2one('hr.department', string='Department', check_company=True, tracking=True, index='btree_not_null') - company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, tracking=True) + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, tracking=True, domain=lambda self: [('id', 'in', self.env.companies.ids)]) contract_type_id = fields.Many2one('hr.contract.type', string='Employment Type', tracking=True) _name_company_uniq = models.Constraint( diff --git a/addons/hr_recruitment/models/hr_applicant.py b/addons/hr_recruitment/models/hr_applicant.py index b7be350a8bb395..e71648734fab24 100644 --- a/addons/hr_recruitment/models/hr_applicant.py +++ b/addons/hr_recruitment/models/hr_applicant.py @@ -82,7 +82,7 @@ class HrApplicant(models.Model): last_stage_id = fields.Many2one('hr.recruitment.stage', "Last Stage", help="Stage of the applicant before being in the current stage. Used for lost cases analysis.") categ_ids = fields.Many2many('hr.applicant.category', string="Tags") - company_id = fields.Many2one('res.company', "Company", compute='_compute_company', store=True, readonly=False, tracking=True) + company_id = fields.Many2one('res.company', "Company", compute='_compute_company', store=True, readonly=False, tracking=True, domain=lambda self: [('id', 'in', self.env.companies.ids)]) user_id = fields.Many2one( 'res.users', "Recruiter", compute='_compute_user', domain="[('share', '=', False), ('company_ids', 'in', company_id)]", tracking=True, store=True, readonly=False) @@ -555,17 +555,17 @@ def _read_group_stage_ids(self, stages, domain): stage_ids = stages.sudo()._search(search_domain, order=stages._order) return stages.browse(stage_ids) - @api.depends('job_id', 'department_id') + @api.depends('job_id', 'department_id', 'job_id.company_id') def _compute_company(self): for applicant in self: company_id = False - if applicant.department_id: + if applicant.department_id.company_id == applicant.job_id.company_id: company_id = applicant.department_id.company_id.id if not company_id and applicant.job_id: company_id = applicant.job_id.company_id.id applicant.company_id = company_id or self.env.company.id - @api.depends('job_id') + @api.depends('job_id', 'job_id.department_id') def _compute_department(self): for applicant in self: applicant.department_id = applicant.job_id.department_id.id From ee8ed1839befa2b5304faf081d7dc932904847b8 Mon Sep 17 00:00:00 2001 From: MaximeNoirhomme Date: Tue, 4 Aug 2026 16:24:57 +0200 Subject: [PATCH 105/205] [FIX] sale_mrp_margin, stock_account: only consider out move for so cost **Issue** Cost of SO might be wrong with MTO+Manufacture product **Steps to reproduce** - Activate margin in settings - Create 2 products: - product A: MTO + Manufacture, 1 qty onHand, standard price to 10 with avco valuation - product B - Create a BOM with the product B as comp - create and confirm a SO for 1 unit of product A - Go to the associated MO and produce all - confirm the delivery linked to the SO -> The cost on the SO is 6.25, while the standard price remains 7.5 **Cause** While confirming the MO, it linked the producing move (which has a unit value of 5), to the SO: https://github.com/odoo/odoo/blob/a6f22922a97399265c04855b5d9b18ac7ac609a0/addons/sale_mrp/models/mrp_production.py#L41-L48 While confirming the delivery, it triggers `_compute_purchase_price` since the picking state changes: https://github.com/odoo/odoo/blob/a6f22922a97399265c04855b5d9b18ac7ac609a0/addons/sale_stock_margin/models/sale_order_line.py#L10-L11 which will eventually takes all the moves links to the sale order to compute the price: https://github.com/odoo/odoo/blob/a6f22922a97399265c04855b5d9b18ac7ac609a0/addons/sale_stock_margin/models/sale_order_line.py#L21 https://github.com/odoo/odoo/blob/a6f22922a97399265c04855b5d9b18ac7ac609a0/addons/stock_account/models/stock_move.py#L701-L714 Which gives an averaging between 5 and 7.5 -> 6.25. Indeed, the unit value of the out move is the standard price: https://github.com/odoo/odoo/blob/8387c28423e8208e53939da3ef5a074c072d33a6/addons/stock_account/models/stock_move.py#L353 opw-6418948 closes odoo/odoo#280712 Signed-off-by: Quentin Wolfs (quwo) --- .../tests/test_sale_mrp_flow.py | 51 +++++++++++++++++++ addons/stock_account/models/stock_move.py | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/addons/sale_mrp_margin/tests/test_sale_mrp_flow.py b/addons/sale_mrp_margin/tests/test_sale_mrp_flow.py index 684d4f973f24d9..6d7e76e7d89f67 100644 --- a/addons/sale_mrp_margin/tests/test_sale_mrp_flow.py +++ b/addons/sale_mrp_margin/tests/test_sale_mrp_flow.py @@ -279,3 +279,54 @@ def test_dropshipped_kit_margin(self): {'product_id': self.kit_1.id, 'purchase_price': 180}, {'product_id': self.kit_3.id, 'purchase_price': 240}, ]) + + def test_avco_mto_manufacture_delivery_keeps_cost_stable(self): + """ Ensures that manufacturing moves are ignored when computing the sales order cost, + and only the outgoing delivery move is taken into account + + MO move: unit value $5 + SO move: unit value $7.5 + + The cost should be $7.5 and not $(7.5 + 5)/2 + """ + warehouse = self.company_data['default_warehouse'] + route_mto = warehouse.mto_pull_id.route_id + + self.product_category.property_cost_method = 'average' + component = self.component_a + component.categ_id = self.product_category + component.standard_price = 5.0 + self.env['stock.quant']._update_available_quantity(component, warehouse.lot_stock_id, 10.0) + + product_a = self._cls_create_product('Product A', self.uom_unit, routes=[route_mto]) + product_a.categ_id = self.product_category + product_a.standard_price = 10.0 + self.env['stock.quant']._update_available_quantity(product_a, warehouse.lot_stock_id, 1.0) + + self.env['mrp.bom'].create({ + 'product_tmpl_id': product_a.product_tmpl_id.id, + 'product_qty': 1.0, + 'bom_line_ids': [Command.create({'product_id': component.id, 'product_qty': 1.0})], + }) + + so = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'order_line': [Command.create({ + 'product_id': product_a.id, + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + })], + }) + so.action_confirm() + + mo = so.mrp_production_ids + mo.button_mark_done() + self.assertEqual(mo.state, 'done') + self.assertEqual(product_a.standard_price, 7.5) + self.assertEqual(so.order_line.purchase_price, 7.5) + + delivery = so.picking_ids + delivery.button_validate() + self.assertEqual(delivery.state, 'done') + self.assertEqual(product_a.standard_price, 7.5) + self.assertEqual(so.order_line.purchase_price, 7.5) diff --git a/addons/stock_account/models/stock_move.py b/addons/stock_account/models/stock_move.py index cfb1ce7fceaa96..f8f927e43c5c7b 100644 --- a/addons/stock_account/models/stock_move.py +++ b/addons/stock_account/models/stock_move.py @@ -705,7 +705,7 @@ def _get_price_unit_delivery(self): dropship_moves = self.filtered(lambda m: m._is_dropshipped() or m._is_dropshipped_returned()) dropship_quantity = sum(m._get_valued_qty() for m in dropship_moves) dropship_price_unit = dropship_moves._get_price_unit_dropshipped() - regular_moves = self - dropship_moves + regular_moves = (self - dropship_moves).filtered(lambda m: m.is_out) regular_quantity = sum(m._get_valued_qty() for m in regular_moves) regular_price_unit = regular_moves._get_price_unit() total_quantity = dropship_quantity + regular_quantity From a68020959d2aff4da98b978b7cef50f5263bb644 Mon Sep 17 00:00:00 2001 From: defl Date: Mon, 3 Aug 2026 11:39:52 +0200 Subject: [PATCH 106/205] [FIX] html_editor: ignore caption plugin on figure edge cases **Steps to reproduce:** - Install Helpdesk - Create an email with a figure that has no image - Send it to Helpdesk email alias - Open up auto-created ticket from the email - `OwlError` is raised on `CaptionPlugin.addImageCaption` **Issue:** `CaptionPlugin` [1] was designed for `

` elements with a single `` and a single `
` (mainly for editor direct interactions). But the HTML specifications also allow `
` with 0 or more than 1 `` element(s), in which case an error is raised (or some elements are removed). (see `https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/figure`) **Fix:** Ignore such `
` for now as it would require a rework of the plugin. [1] https://github.com/odoo/odoo/commit/b9d112a5800cfe11dc434caa0d335fa3f3db7178 opw-6413422 closes odoo/odoo#279981 Signed-off-by: Florentin Delcourt (defl) --- .../plugins/caption_plugin/caption_plugin.js | 11 +++-- .../html_editor/static/tests/caption.test.js | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js b/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js index 368b2ca04b90a6..a1efb8dfe3369f 100644 --- a/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js +++ b/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js @@ -82,7 +82,8 @@ export class CaptionPlugin extends Plugin { if (root.matches(CAPTION_SPAN_SELECTOR)) { figures = [closestElement(root, "figure")]; } else { - figures = [...root.querySelectorAll("figure")]; + figures = [...root.querySelectorAll("figure")] + .filter(figure => figure.querySelectorAll("img").length === 1); } figures.forEach((figure) => { const captionSpan = figure.querySelector(CAPTION_SPAN_SELECTOR); @@ -117,7 +118,9 @@ export class CaptionPlugin extends Plugin { }; setup() { - for (const figure of this.editable.querySelectorAll("figure")) { + const figures = [...this.editable.querySelectorAll("figure")] + .filter(figure => figure.querySelectorAll("img").length === 1); + for (const figure of figures) { const image = figure.querySelector("img"); figure.before(image); const caption = figure.querySelector("figcaption")?.textContent; @@ -246,7 +249,9 @@ export class CaptionPlugin extends Plugin { } cleanForSave({ root }) { - for (const figure of root.querySelectorAll("figure")) { + const figures = [...root.querySelectorAll("figure")] + .filter(figure => figure.querySelectorAll("img").length === 1); + for (const figure of figures) { figure.removeAttribute("contenteditable"); const image = figure.querySelector("img"); const span = figure.querySelector(CAPTION_SPAN_SELECTOR); diff --git a/addons/html_editor/static/tests/caption.test.js b/addons/html_editor/static/tests/caption.test.js index 1ab3a098789d38..c9a638f4a9cd8d 100644 --- a/addons/html_editor/static/tests/caption.test.js +++ b/addons/html_editor/static/tests/caption.test.js @@ -1710,3 +1710,47 @@ test("pressing Enter inside o_caption_editable should do nothing", async () => { ), }); }); + +test("should ignore figure without image", async () => { + const caption = "Quote"; + await testEditor({ + config: configWithEmbeddedCaption, + contentBefore: unformat( + `
+
+ Random Quote +
+
${caption}
+
` + ), + contentAfter: unformat( + `
+
+ Random Quote +
+
${caption}
+
` + ), + }); +}); + +test("should ignore figure with multiple images", async () => { + const caption = "Two Images"; + await testEditor({ + config: configWithEmbeddedCaption, + contentBefore: unformat( + `
+ First image + Second image +
${caption}
+
` + ), + contentAfter: unformat( + `
+ First image + Second image +
${caption}
+
` + ), + }); +}); From 867e68e856d6e322b1264af54831222c7440a770 Mon Sep 17 00:00:00 2001 From: malb Date: Tue, 21 Apr 2026 14:14:59 +0200 Subject: [PATCH 107/205] [IMP] account: sanitize payment ref Backport of: https://github.com/odoo/odoo/commit/1a737a654e1f51ae4979a95a960c33770cf0746d Before this commit, the "try_auto_reconcile" algorithm was finding moves when there was a perfect match with either the ref of a move line, the move name, the payment reference and now a sanitize version of the payment ref. For example if an invoice had SO12/1234 as the payment reference, if the statement line has a label SO121234 nothing was found. This commit will then add a new non stored computed field to sanitize the payment ref on the invoice level to help those cases task-6119841 closes odoo/odoo#283508 Related: odoo/enterprise#128574 Signed-off-by: Florian Gilbert (flg) --- addons/account/models/account_move.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index a4c5132c3b64c7..2fb7e0e9fba802 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -473,6 +473,11 @@ def _sequence_year_range_monthly_regex(self): tracking=True, compute='_compute_payment_reference', inverse='_inverse_payment_reference', store=True, readonly=False, ) + sanitize_payment_reference = fields.Char( + string="Label sanitize", + compute='_compute_sanitize_payment_reference', + compute_sudo=False, + ) display_qr_code = fields.Boolean( string="Display QR-code", compute='_compute_display_qr_code', @@ -791,6 +796,7 @@ def _sequence_year_range_monthly_regex(self): # used in ._query_has_sequence_holes _made_gaps = models.Index('(journal_id, state, payment_state, move_type, date) WHERE (made_sequence_gap IS TRUE)') _duplicate_bills_idx = models.Index("(ref) WHERE (move_type IN ('in_invoice', 'in_refund'))") + _account_move_sanitize_payment_ref_idx = models.Index("(regexp_replace(COALESCE(payment_reference, ''), '[^a-zA-Z0-9]', '', 'g'))") def _auto_init(self): super()._auto_init() @@ -842,6 +848,11 @@ def _compute_payment_reference(self): move.payment_reference = move._get_invoice_computed_reference() self._inverse_payment_reference() + @api.depends('payment_reference') + def _compute_sanitize_payment_reference(self): + for move in self: + move.sanitize_payment_reference = re.sub(r'[^a-zA-Z0-9]', '', move.payment_reference or '') + def _get_accounting_date_source(self): self.ensure_one() return self.invoice_date or self.date @@ -1344,6 +1355,8 @@ def _field_to_sql(self, alias: str, fname: str, query=None) -> SQL: f"ELSE 'not_sent' " "END" ) + elif fname == 'sanitize_payment_reference': + return SQL("regexp_replace(COALESCE(%s, ''), '[^a-zA-Z0-9]', '', 'g')", super()._field_to_sql(alias, "payment_reference", query)) return super()._field_to_sql(alias, fname, query=query) @api.depends('reconciled_payment_ids') From 0cdbbabf26e80caa2183010b16f2baf7722d544f Mon Sep 17 00:00:00 2001 From: Waleed Elgamal Date: Thu, 30 Jul 2026 12:06:59 +0000 Subject: [PATCH 108/205] [FIX] sale: allow sales user to see Product Catalog three-dot menu **Steps to Reproduce:** 1. Give the logged in user "Sales / User: Own Documents Only" access rights 2. Open the Product Catalog (from a Sales Order line) 3. The three-dot menu on a product card is not visible when you hover over it 4. Change user rights with "Sales Administrator" access rights, the three-dot menu appears as expected **Issue:** The three-dot menu on the Product Catalog kanban card is restricted to the Sales Administrator group, even though the actions it exposes (edit product, availability, etc) are already accessible to regular Sales users through other menus/views. **Why this happens:** The view `product.view.kanban.catalog.inherit.sale` sets the `groups` attribute to `sales_team.group_sale_manager`, restricting the menu behind Administrator rights instead of the base Sales access group opw-6416629 closes odoo/odoo#283090 X-original-commit: 0e861a9c5ab6bab9c44483221358da5745bbf0ff Signed-off-by: Waleed Elgamal (waelg) --- addons/sale/views/product_views.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale/views/product_views.xml b/addons/sale/views/product_views.xml index 57aa30dd428b86..749a9efa6f35c9 100644 --- a/addons/sale/views/product_views.xml +++ b/addons/sale/views/product_views.xml @@ -115,7 +115,7 @@ product.product - sales_team.group_sale_manager + sales_team.group_sale_salesman From 28591709908ea82777318ff1162ef32ce82928f1 Mon Sep 17 00:00:00 2001 From: pkri-odoo Date: Tue, 4 Aug 2026 11:14:43 +0000 Subject: [PATCH 109/205] [FIX] l10n_din5008: ensure DIN5008 address placement for Snailmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps to reproduce - Install l10n_din5008 and accountant. - Enable Snailmail from Accounting → Settings. - Create a German customer (Fiscal Country: Germany). - Create and post a customer invoice using the DIN5008 report layout. - Click Send by Post. - Enable Developer Mode and navigate to Settings → Technical → Email → Snailmail Letters. - Open the generated letter and send it. Current behavior The letter fails to be sent to Pingen with the following error: An error occurred when sending the document by post.Error: The attachment of the letter could not be sent. Please check its content and contact the support if the problem persists. Cause For Snailmail documents, Pingen validates that the recipient address is located within the DIN5008 address window. The current l10n_din5008 report renders additional document information instead of address in address area, preventing the compliance validation to fail. Solution When rendering the report for Snailmail, ensure that only the recipient address is displayed in the DIN5008 address window while suppressing the additional information that would otherwise occupy this area. This preserves the standard DIN5008 layout for regular reports while generating a Snailmail-compliant PDF that passes Pingen’s validation. Reference: https://help.pingen.com/en/fix-and-enhance-letters/issue-with-recipient-address#040201 opw- 6387869 closes odoo/odoo#283409 X-original-commit: 13f3eed0cef541b463d6456fdeefc558dc7ccd52 Signed-off-by: Paolo Gatti (pgi) Signed-off-by: Krishna Pathak (pkri) --- addons/l10n_din5008/report/din5008_report.xml | 20 +++++++++++++++---- .../static/src/scss/report_din5008.scss | 17 ++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/addons/l10n_din5008/report/din5008_report.xml b/addons/l10n_din5008/report/din5008_report.xml index 4c1fd5b842bf80..55aeb7b266f984 100644 --- a/addons/l10n_din5008/report/din5008_report.xml +++ b/addons/l10n_din5008/report/din5008_report.xml @@ -36,11 +36,12 @@
+
- +
@@ -77,18 +78,29 @@

- + + + + + +
- + + +
+ +
+
+ @@ -190,7 +202,7 @@