From c618d6dbbfe4dc736762bf320a30f3e89a996764 Mon Sep 17 00:00:00 2001 From: "Louis (loco)" Date: Wed, 8 Oct 2025 09:07:42 +0200 Subject: [PATCH 001/673] [FIX] html_builder, *: set contenteditable attribute on correct elements *: html_editor, website The goal of this commit is to fix the behavior of the `extra_contenteditable_handlers` resource; its goal is to mark as editable an element that is inside an `.o_not_editable`. The problem is that this was not working as intended as the elements given as argument to the handler were a list that was filtered of the elements that have an `.o_not_editable` ancestor. Note: even if it is in stable, the `force_editable_selector` resource has been renamed as `content_editable_providers` as a provider is needed in some cases. Because it has become a provider, the `force_not_editable_selector` resource has also been renamed and becomes a provider. This choice has been made to maintain consistency throughout the plugin. closes odoo/odoo#233662 X-original-commit: https://github.com/odoo/odoo/commit/13565e0330bbc44eeecbb5d2ecd3acd7b4cdded4 Signed-off-by: Francois Georis (fge) Signed-off-by: Colin Louis (loco) --- .../core/builder_content_editable_plugin.js | 42 +++++++++--- .../background_image_option_plugin.js | 2 +- .../background_shape_option_plugin.js | 2 +- .../src/plugins/date_time_field_plugin.js | 2 +- .../static/src/plugins/image_field_plugin.js | 4 +- .../src/plugins/many2one_option_plugin.js | 2 +- .../src/plugins/monetary_field_plugin.js | 4 +- .../static/tests/contenteditable.test.js | 65 ++++++++++++++++++- .../src/core/content_editable_plugin.js | 35 +++++----- .../static/src/main/separator_plugin.js | 4 +- .../static/src/main/tabulation_plugin.js | 3 +- .../static/tests/content_editable.test.js | 5 +- .../builder/plugins/company_team_plugin.js | 13 ++-- .../plugins/form/form_option_plugin.js | 4 +- .../plugins/options/facebook_option_plugin.js | 2 +- .../plugins/options/parallax_option_plugin.js | 2 +- .../options/social_media_option_plugin.js | 20 ++---- .../options/table_of_content_option_plugin.js | 2 +- ...able_of_content_option_plugin_translate.js | 2 +- .../tests/builder/content_editable.test.js | 13 +++- 20 files changed, 156 insertions(+), 72 deletions(-) diff --git a/addons/html_builder/static/src/core/builder_content_editable_plugin.js b/addons/html_builder/static/src/core/builder_content_editable_plugin.js index 1f328d8d4f353..23659eee47507 100644 --- a/addons/html_builder/static/src/core/builder_content_editable_plugin.js +++ b/addons/html_builder/static/src/core/builder_content_editable_plugin.js @@ -1,21 +1,24 @@ import { Plugin } from "@html_editor/plugin"; +import { selectElements } from "@html_editor/utils/dom_traversal"; import { registry } from "@web/core/registry"; export class BuilderContentEditablePlugin extends Plugin { static id = "builderContentEditablePlugin"; resources = { - force_not_editable_selector: [ + content_not_editable_selectors: [ "section:has(> .o_container_small, > .container, > .container-fluid)", ".o_not_editable", "[data-oe-field='arch']:empty", ], - force_editable_selector: [ + content_editable_selectors: [ "section > .o_container_small", "section > .container", "section > .container-fluid", ".o_editable", ], - filter_contenteditable_handlers: this.filterContentEditable.bind(this), + valid_contenteditable_predicates: this.isValidContentEditable.bind(this), + content_editable_providers: this.getContentEditableEls.bind(this), + content_not_editable_providers: this.getContentNotEditableEls.bind(this), contenteditable_to_remove_selector: "[contenteditable]", }; @@ -23,12 +26,33 @@ export class BuilderContentEditablePlugin extends Plugin { this.editable.setAttribute("contenteditable", false); } - filterContentEditable(contentEditableEls) { - return contentEditableEls.filter( - (el) => - !el.matches("input, [data-oe-readonly]") && - el.closest(".o_editable") && - !el.closest(".o_not_editable") + getContentEditableEls(rootEl) { + const editableSelector = this.getResource("content_editable_selectors").join(","); + return [...selectElements(rootEl, editableSelector)]; + } + + getContentNotEditableEls(rootEl) { + const notEditableSelector = this.getResource("content_not_editable_selectors").join(","); + return [...selectElements(rootEl, notEditableSelector)]; + } + + isValidContentEditable(contentEditableEl) { + // Check if an element is inside a ".o_not_editable" element that is not + // inside a snippet. + const isDescendantOfNotEditableNotSnippet = (el) => { + let notEditableEl = el.closest(".o_not_editable"); + if (!notEditableEl) { + return false; + } + while (notEditableEl.parentElement.closest(".o_not_editable")) { + notEditableEl = notEditableEl.parentElement.closest(".o_not_editable"); + } + return !notEditableEl.closest("[data-snippet]"); + }; + return ( + !contentEditableEl.matches("input, [data-oe-readonly]") && + contentEditableEl.closest(".o_editable") && + !isDescendantOfNotEditableNotSnippet(contentEditableEl) ); } } diff --git a/addons/html_builder/static/src/plugins/background_option/background_image_option_plugin.js b/addons/html_builder/static/src/plugins/background_option/background_image_option_plugin.js index c64ec6e4c7e6a..c3045c1c5cb66 100644 --- a/addons/html_builder/static/src/plugins/background_option/background_image_option_plugin.js +++ b/addons/html_builder/static/src/plugins/background_option/background_image_option_plugin.js @@ -25,7 +25,7 @@ export class BackgroundImageOptionPlugin extends Plugin { ReplaceBgImageAction, DynamicColorAction, }, - force_not_editable_selector: ".o_we_bg_filter", + content_not_editable_selectors: ".o_we_bg_filter", get_target_element_providers: withSequence(5, (el) => el), }; /** diff --git a/addons/html_builder/static/src/plugins/background_option/background_shape_option_plugin.js b/addons/html_builder/static/src/plugins/background_option/background_shape_option_plugin.js index 66f005179b9f5..05a9b27a11c74 100644 --- a/addons/html_builder/static/src/plugins/background_option/background_shape_option_plugin.js +++ b/addons/html_builder/static/src/plugins/background_option/background_shape_option_plugin.js @@ -27,7 +27,7 @@ export class BackgroundShapeOptionPlugin extends Plugin { background_shape_target_providers: withSequence(5, (editingElement) => editingElement.querySelector(":scope > .o_we_bg_filter") ), - force_not_editable_selector: ".o_we_shape", + content_not_editable_selectors: ".o_we_shape", }; static shared = [ "getShapeStyleUrl", diff --git a/addons/html_builder/static/src/plugins/date_time_field_plugin.js b/addons/html_builder/static/src/plugins/date_time_field_plugin.js index 6d94a11ec4858..44909de642eb7 100644 --- a/addons/html_builder/static/src/plugins/date_time_field_plugin.js +++ b/addons/html_builder/static/src/plugins/date_time_field_plugin.js @@ -4,7 +4,7 @@ import { registry } from "@web/core/registry"; class DateTimeFieldPlugin extends Plugin { static id = "dateTimeField"; resources = { - force_not_editable_selector: [ + content_not_editable_selectors: [ "[data-oe-field][data-oe-type=date]", "[data-oe-field][data-oe-type=datetime]", ], diff --git a/addons/html_builder/static/src/plugins/image_field_plugin.js b/addons/html_builder/static/src/plugins/image_field_plugin.js index 80f92ebc65ffe..051567f4e99b0 100644 --- a/addons/html_builder/static/src/plugins/image_field_plugin.js +++ b/addons/html_builder/static/src/plugins/image_field_plugin.js @@ -4,8 +4,8 @@ import { registry } from "@web/core/registry"; export class ImageFieldPlugin extends Plugin { static id = "imageField"; resources = { - force_editable_selector: "[data-oe-field][data-oe-type=image] img", - force_not_editable_selector: "[data-oe-field][data-oe-type=image]", + content_editable_selectors: "[data-oe-field][data-oe-type=image] img", + content_not_editable_selectors: "[data-oe-field][data-oe-type=image]", }; } diff --git a/addons/html_builder/static/src/plugins/many2one_option_plugin.js b/addons/html_builder/static/src/plugins/many2one_option_plugin.js index 746cf7e7247e3..1e1188bc87d3f 100644 --- a/addons/html_builder/static/src/plugins/many2one_option_plugin.js +++ b/addons/html_builder/static/src/plugins/many2one_option_plugin.js @@ -16,7 +16,7 @@ export class Many2OneOptionPlugin extends Plugin { builder_actions: { Many2OneAction, }, - force_not_editable_selector: "[data-oe-field][data-oe-many2one-id]", + content_not_editable_selectors: "[data-oe-field][data-oe-many2one-id]", }; } diff --git a/addons/html_builder/static/src/plugins/monetary_field_plugin.js b/addons/html_builder/static/src/plugins/monetary_field_plugin.js index 600631347977f..fd3b1027b5c0d 100644 --- a/addons/html_builder/static/src/plugins/monetary_field_plugin.js +++ b/addons/html_builder/static/src/plugins/monetary_field_plugin.js @@ -7,8 +7,8 @@ export class MonetaryFieldPlugin extends Plugin { static id = "monetaryField"; static dependencies = ["selection"]; resources = { - force_editable_selector: `${monetarySel} .oe_currency_value`, - force_not_editable_selector: monetarySel, + content_editable_selectors: `${monetarySel} .oe_currency_value`, + content_not_editable_selectors: monetarySel, }; setup() { diff --git a/addons/html_builder/static/tests/contenteditable.test.js b/addons/html_builder/static/tests/contenteditable.test.js index e42721def000f..33adb7308d64e 100644 --- a/addons/html_builder/static/tests/contenteditable.test.js +++ b/addons/html_builder/static/tests/contenteditable.test.js @@ -3,6 +3,7 @@ import { addBuilderPlugin, setupHTMLBuilder, dummyBase64Img, + addBuilderAction, } from "@html_builder/../tests/helpers"; import { Plugin } from "@html_editor/plugin"; import { expect, test, describe } from "@odoo/hoot"; @@ -10,12 +11,13 @@ import { xml } from "@odoo/owl"; import { contains, onRpc } from "@web/../tests/web_test_helpers"; describe.current.tags("desktop"); +import { BuilderAction } from "@html_builder/core/builder_action"; test("Do not set contenteditable to true on elements inside o_not_editable", async () => { class TestPlugin extends Plugin { static id = "testPlugin"; resources = { - force_editable_selector: ".target", + content_editable_selectors: ".target", }; } addBuilderPlugin(TestPlugin); @@ -78,3 +80,64 @@ test("clone of editable media inside not editable area should be editable", asyn await contains(":iframe section:last-of-type img").click(); expect(".options-container[data-container-title='Image']").toBeDisplayed(); }); + +const setupEditable = async (contentEl) => { + class TestPlugin extends Plugin { + static id = "test"; + resources = { + content_editable_selectors: ".target-extra", + }; + } + addBuilderPlugin(TestPlugin); + addBuilderOption({ + selector: ".parent", + template: xml`Custom Action`, + }); + addBuilderOption({ + selector: ".parent", + template: xml`Dummy action`, + }); + addBuilderAction({ + customAction: class extends BuilderAction { + static id = "customAction"; + apply({ editingElement }) { + editingElement.querySelector(".target").classList.add("target-extra"); + } + }, + }); + await setupHTMLBuilder(contentEl); + await contains(":iframe .parent").click(); + // Dummy action to mark the editable as "dirty" + await contains("[data-class-action='dummy']").click(); + await contains("[data-action-id='customAction']").click(); +}; + +test("Set contenteditable attribute to true on element that is descendant of '.o_not_editable' that is itself descendant of a snippet", async () => { + await setupEditable(` +
+
+ Not editable +
+ Target +
+
+
+ `); + expect(":iframe .target-extra").toHaveAttribute("contenteditable", "true"); +}); + +test("Don't set contenteditable attribute to true on element that is inside '.o_not_editable' that is not a descendant of a snippet", async () => { + await setupEditable(` +
+
+
+ Not editable +
+ Target +
+
+
+
+ `); + expect(":iframe .target-extra").not.toHaveAttribute("contenteditable", "true"); +}); diff --git a/addons/html_editor/static/src/core/content_editable_plugin.js b/addons/html_editor/static/src/core/content_editable_plugin.js index a33fab5cc7f5f..c58fe5e5e7844 100644 --- a/addons/html_editor/static/src/core/content_editable_plugin.js +++ b/addons/html_editor/static/src/core/content_editable_plugin.js @@ -7,8 +7,8 @@ import { withSequence } from "@html_editor/utils/resource"; * This plugin is responsible for setting the contenteditable attribute on some * elements. * - * The force_editable_selector and force_not_editable_selector resources allow - * other plugins to easily add editable or non editable elements. + * The content_editable_providers and content_not_editable_providers resources + * allow other plugins to easily add editable or non editable elements. */ export class ContentEditablePlugin extends Plugin { @@ -19,26 +19,21 @@ export class ContentEditablePlugin extends Plugin { }; normalize(root) { - const toDisableSelector = this.getResource("force_not_editable_selector").join(","); - const toDisableEls = toDisableSelector ? [...selectElements(root, toDisableSelector)] : []; - for (const toDisable of toDisableEls) { - toDisable.setAttribute("contenteditable", "false"); + const contentNotEditableEls = []; + for (const fn of this.getResource("content_not_editable_providers")) { + contentNotEditableEls.push(...fn(root)); } - const toEnableSelector = this.getResource("force_editable_selector").join(","); - let filteredContentEditableEls = toEnableSelector - ? [...selectElements(root, toEnableSelector)] - : []; - for (const fn of this.getResource("filter_contenteditable_handlers")) { - filteredContentEditableEls = [...fn(filteredContentEditableEls)]; + for (const contentNotEditableEl of contentNotEditableEls) { + contentNotEditableEl.setAttribute("contenteditable", "false"); } - const extraContentEditableEls = []; - for (const fn of this.getResource("extra_contenteditable_handlers")) { - extraContentEditableEls.push(...fn(filteredContentEditableEls)); + const contentEditableEls = []; + for (const fn of this.getResource("content_editable_providers")) { + contentEditableEls.push(...fn(root)); } - for (const contentEditableEl of [ - ...filteredContentEditableEls, - ...extraContentEditableEls, - ]) { + const filteredContentEditableEls = contentEditableEls.filter((contentEditableEl) => + this.getResource("valid_contenteditable_predicates").every((p) => p(contentEditableEl)) + ); + for (const contentEditableEl of filteredContentEditableEls) { if ( isMediaElement(contentEditableEl) && !contentEditableEl.parentNode.isContentEditable @@ -48,7 +43,7 @@ export class ContentEditablePlugin extends Plugin { } if ( !contentEditableEl.isContentEditable && - !contentEditableEl.matches(toDisableSelector) + !contentNotEditableEls.includes(contentEditableEl) ) { contentEditableEl.setAttribute("contenteditable", true); } diff --git a/addons/html_editor/static/src/main/separator_plugin.js b/addons/html_editor/static/src/main/separator_plugin.js index b22f5e35cc5cf..df604c15a72d9 100644 --- a/addons/html_editor/static/src/main/separator_plugin.js +++ b/addons/html_editor/static/src/main/separator_plugin.js @@ -1,7 +1,7 @@ import { _t } from "@web/core/l10n/translation"; import { Plugin } from "../plugin"; import { closestBlock } from "../utils/blocks"; -import { closestElement, firstLeaf } from "../utils/dom_traversal"; +import { closestElement, firstLeaf, selectElements } from "../utils/dom_traversal"; import { isEmptyBlock, isListItemElement, @@ -30,7 +30,7 @@ export class SeparatorPlugin extends Plugin { categoryId: "structure", commandId: "insertSeparator", }), - force_not_editable_selector: "hr", + content_not_editable_providers: (rootEl) => [...selectElements(rootEl, "hr")], contenteditable_to_remove_selector: "hr[contenteditable]", shorthands: [ { diff --git a/addons/html_editor/static/src/main/tabulation_plugin.js b/addons/html_editor/static/src/main/tabulation_plugin.js index 4dfb1b12d5118..5bd4d411d4d51 100644 --- a/addons/html_editor/static/src/main/tabulation_plugin.js +++ b/addons/html_editor/static/src/main/tabulation_plugin.js @@ -7,6 +7,7 @@ import { getAdjacentPreviousSiblings, closestElement, firstLeaf, + selectElements, } from "@html_editor/utils/dom_traversal"; import { parseHTML } from "@html_editor/utils/html"; import { DIRECTIONS, childNodeIndex } from "@html_editor/utils/position"; @@ -55,7 +56,7 @@ export class TabulationPlugin extends Plugin { { hotkey: "tab", commandId: "tab" }, { hotkey: "shift+tab", commandId: "shiftTab" }, ], - force_not_editable_selector: ".oe-tabs", + content_not_editable_providers: (rootEl) => [...selectElements(rootEl, ".oe-tabs")], contenteditable_to_remove_selector: "span.oe-tabs", /** Handlers */ diff --git a/addons/html_editor/static/tests/content_editable.test.js b/addons/html_editor/static/tests/content_editable.test.js index ee6796c51f81a..3706f06478392 100644 --- a/addons/html_editor/static/tests/content_editable.test.js +++ b/addons/html_editor/static/tests/content_editable.test.js @@ -2,13 +2,14 @@ import { expect, test } from "@odoo/hoot"; import { setupEditor } from "./_helpers/editor"; import { Plugin } from "@html_editor/plugin"; import { MAIN_PLUGINS } from "@html_editor/plugin_sets"; +import { selectElements } from "@html_editor/utils/dom_traversal"; test("set o_editable_media class on contenteditable false media elements", async () => { class TestPlugin extends Plugin { static id = "test"; resources = { - force_not_editable_selector: "i", - force_editable_selector: "i", + content_not_editable_providers: (rootEl) => [...selectElements(rootEl, "i")], + content_editable_providers: (rootEl) => [...selectElements(rootEl, "i")], }; } const Plugins = [...MAIN_PLUGINS, TestPlugin]; diff --git a/addons/website/static/src/builder/plugins/company_team_plugin.js b/addons/website/static/src/builder/plugins/company_team_plugin.js index 962c05552ae07..5c569aec07664 100644 --- a/addons/website/static/src/builder/plugins/company_team_plugin.js +++ b/addons/website/static/src/builder/plugins/company_team_plugin.js @@ -1,21 +1,18 @@ import { Plugin } from "@html_editor/plugin"; import { registry } from "@web/core/registry"; import { isMediaElement } from "@html_editor/utils/dom_info"; +import { selectElements } from "@html_editor/utils/dom_traversal"; class CompanyTeamPlugin extends Plugin { static id = "companyTeam"; resources = { - extra_contenteditable_handlers: this.extraContentEditableHandlers.bind(this), + content_editable_providers: this.getEditableEls.bind(this), }; - extraContentEditableHandlers(filteredContentEditableEls) { + getEditableEls(rootEl) { // To fix db in stable - const extraContentEditableEls = filteredContentEditableEls.flatMap( - (filteredContentEditableEl) => [ - ...filteredContentEditableEl.querySelectorAll(".s_company_team .o_not_editable *"), - ] - ); - return extraContentEditableEls.filter((el) => isMediaElement(el)); + const contentEditableEls = [...selectElements(rootEl, ".s_company_team .o_not_editable *")]; + return contentEditableEls.filter((el) => isMediaElement(el)); } } diff --git a/addons/website/static/src/builder/plugins/form/form_option_plugin.js b/addons/website/static/src/builder/plugins/form/form_option_plugin.js index 8d39229521c2c..5d4e9ce3952d4 100644 --- a/addons/website/static/src/builder/plugins/form/form_option_plugin.js +++ b/addons/website/static/src/builder/plugins/form/form_option_plugin.js @@ -169,8 +169,8 @@ export class FormOptionPlugin extends Plugin { SetRequirementComparatorAction, SetMultipleFilesAction, }, - force_not_editable_selector: ".s_website_form form", - force_editable_selector: [ + content_not_editable_selectors: ".s_website_form form", + content_editable_selectors: [ ".s_website_form_send", ".s_website_form_field_description", ".s_website_form_recaptcha", diff --git a/addons/website/static/src/builder/plugins/options/facebook_option_plugin.js b/addons/website/static/src/builder/plugins/options/facebook_option_plugin.js index 14d707a62a5bd..59de8cff92cfa 100644 --- a/addons/website/static/src/builder/plugins/options/facebook_option_plugin.js +++ b/addons/website/static/src/builder/plugins/options/facebook_option_plugin.js @@ -20,7 +20,7 @@ class FacebookOptionPlugin extends Plugin { CheckFacebookLinkAction, }, normalize_handlers: this.normalize.bind(this), - force_not_editable_selector: ".o_facebook_page", + content_not_editable_selectors: ".o_facebook_page", }; normalize(root) { diff --git a/addons/website/static/src/builder/plugins/options/parallax_option_plugin.js b/addons/website/static/src/builder/plugins/options/parallax_option_plugin.js index 36c204890d2ba..6694bf3e37453 100644 --- a/addons/website/static/src/builder/plugins/options/parallax_option_plugin.js +++ b/addons/website/static/src/builder/plugins/options/parallax_option_plugin.js @@ -14,7 +14,7 @@ class WebsiteParallaxPlugin extends Plugin { SetParallaxTypeAction, }, on_bg_image_hide_handlers: this.onBgImageHide.bind(this), - force_not_editable_selector: ".s_parallax_bg, section.s_parallax > .oe_structure", + content_not_editable_selectors: ".s_parallax_bg, section.s_parallax > .oe_structure", get_target_element_providers: withSequence(1, this.getTargetElement), }; setup() { diff --git a/addons/website/static/src/builder/plugins/options/social_media_option_plugin.js b/addons/website/static/src/builder/plugins/options/social_media_option_plugin.js index e2ba84ee83d8a..e88818b8ffa32 100644 --- a/addons/website/static/src/builder/plugins/options/social_media_option_plugin.js +++ b/addons/website/static/src/builder/plugins/options/social_media_option_plugin.js @@ -159,21 +159,15 @@ class SocialMediaOptionPlugin extends Plugin { }, normalize_handlers: this.normalize.bind(this), save_handlers: this.saveRecordedSocialMedia.bind(this), - extra_contenteditable_handlers: this.extraContentEditableHandlers.bind(this), - force_not_editable_selector: [".s_share"], - force_editable_selector: [".s_share a > i", ".s_share .s_share_title"], + content_not_editable_selectors: [".s_share"], + content_editable_selectors: [ + ".s_share a > i", + ".s_share .s_share_title", + ".s_social_media a > i", + ".s_social_media .s_social_media_title", + ], }; - extraContentEditableHandlers(filteredContentEditableEls) { - // To fix db in stable - // grep: SOCIAL_MEDIA_TITLE_CONTENTEDITABLE - return filteredContentEditableEls.flatMap((filteredContentEditableEl) => [ - ...filteredContentEditableEl.querySelectorAll( - ".s_social_media a > i, .s_social_media .s_social_media_title" - ), - ]); - } - /** The social media's name for which there is an entry in the orm */ async getRecordedSocialMediaNames() { await this.fetchRecordedSocialMedia(); diff --git a/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin.js b/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin.js index 41829b58177ee..63d6421323733 100644 --- a/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin.js +++ b/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin.js @@ -54,7 +54,7 @@ class TableOfContentOptionPlugin extends Plugin { return true; }, is_unremovable_selector: ".s_table_of_content_navbar_wrap, .s_table_of_content_main", - force_not_editable_selector: ".s_table_of_content_navbar", + content_not_editable_selectors: ".s_table_of_content_navbar", }; normalize(root) { diff --git a/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin_translate.js b/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin_translate.js index 613624d84371e..36af8399dc46f 100644 --- a/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin_translate.js +++ b/addons/website/static/src/builder/plugins/options/table_of_content_option_plugin_translate.js @@ -6,7 +6,7 @@ export class TranslateTableOfContentOptionPlugin extends Plugin { resources = { normalize_handlers: this.normalize.bind(this), - force_not_editable_selector: [".s_table_of_content_navbar"], + content_not_editable_selectors: [".s_table_of_content_navbar"], }; normalize(root) { diff --git a/addons/website/static/tests/builder/content_editable.test.js b/addons/website/static/tests/builder/content_editable.test.js index 4a2f5e6718a22..590949c4afbd0 100644 --- a/addons/website/static/tests/builder/content_editable.test.js +++ b/addons/website/static/tests/builder/content_editable.test.js @@ -65,8 +65,8 @@ test("Do not set contenteditable attribute on data-oe-readonly", async () => { class TestPlugin extends Plugin { static id = "testPlugin"; resources = { - force_editable_selector: ".target", - force_not_editable_selector: ".non-editable", + content_editable_selectors: ".target", + content_not_editable_selectors: ".non-editable", }; } addPlugin(TestPlugin); @@ -124,3 +124,12 @@ test("feff on links are cleaned up", async () => { await contains(".o-snippets-top-actions button:contains(Save)").click(); expect.verifySteps(["save"]); }); + +test("Set contenteditable to true on social media title", async () => { + await setupWebsiteBuilder(` +
+

Social Media

+
+ `); + expect(":iframe .s_social_media_title").toHaveAttribute("contenteditable", "true"); +}); From 914a2099e0d4df726f5232ff061b17e9494127f0 Mon Sep 17 00:00:00 2001 From: "Mahdi Alijani (malj)" Date: Wed, 15 Oct 2025 09:57:32 +0000 Subject: [PATCH 002/673] [FIX] mrp: non-deterministic duration fail assertion in backorder test Issue: The test test_mrp_backorder_operations behaves non-deterministically. It may fail at this assertion: https://github.com/odoo/odoo/blob/6e37542b53f9c9ec1baf8d58b1eeca376252186f/addons/mrp/tests/test_backorder.py#L986-L989 Because the duration of done workorder can vary. Cause: The delta time between button_start() and button_finish() https://github.com/odoo/odoo/blob/6e37542b53f9c9ec1baf8d58b1eeca376252186f/addons/mrp/tests/test_backorder.py#L983-L985 button_finish() triggers Productivity._close() https://github.com/odoo/odoo/blob/6e37542b53f9c9ec1baf8d58b1eeca376252186f/addons/mrp/models/mrp_workcenter.py#L572-L576 which sets Productivity.duration to 0 if delta time is zero. Consequently, workorder.duration also becomes zero. When bo_2.button_mark_done() is called, the duration is set to duration_expected (240 in this test), which the assertion only can pass in that case. If delta time isn't zero, then duration will also be non-zero. So it will not set to duration_expected. As a result the assertion will fail. runbot-233247 closes odoo/odoo#233646 X-original-commit: 7669a43b3a4695abe541b4059776601229bd3a97 Signed-off-by: Lancelot Semal (lase) Signed-off-by: Mohammadmahdi Alijani (malj) --- addons/mrp/tests/test_backorder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/mrp/tests/test_backorder.py b/addons/mrp/tests/test_backorder.py index 1985f02ff2add..a871fd6f4370e 100644 --- a/addons/mrp/tests/test_backorder.py +++ b/addons/mrp/tests/test_backorder.py @@ -5,7 +5,7 @@ from odoo import Command from odoo.addons.mrp.tests.common import TestMrpCommon from odoo.tests import tagged, Form -from odoo.tests.common import TransactionCase +from odoo.tests.common import TransactionCase, freeze_time @tagged('at_install', '-post_install') # LEGACY at_install @@ -933,6 +933,7 @@ def setUpClass(cls): cls.bom_finished1.bom_line_ids[0].operation_id = cls.bom_finished1.operation_ids[0].id cls.bom_finished1.bom_line_ids[1].operation_id = cls.bom_finished1.operation_ids[1].id + @freeze_time('2025-10-27 12:00:00') def test_mrp_backorder_operations(self): """ Checks that the operations'data are correclty set on a backorder: From 4b3569e2dee00c4f60dfeae2a9997b98ee37267f Mon Sep 17 00:00:00 2001 From: David Monnom Date: Thu, 2 Oct 2025 11:50:06 +0000 Subject: [PATCH 003/673] [REF] point_of_sale, pos_*: improve tax computation in PoS *: l10n_es_pos, l10n_sa_pos, point_of_sale, pos_discount, pos_loyalty, pos_razorpay, pos_restaurant, pos_self_order Before this commit taxes computation was a mess in PoS, with differents implementations in different places, and some of them not taking into account all the complexity of the tax system (taxes included in price, taxes on taxes, etc.). This commit aims to unify all the tax computation logic in a single place, and to make it more robust and easier to understand. --- What's changes: Rounding methods available in PoS are now: 1) Rounding applied only on cash payments In this case the remaining due is rounded only if there is at least one cash payment line and the remaining due is less than the rounding tolerance. 2) Rounding applied on all payment methods In this case the remaining due is always rounded even if a card payment method is used. The remaining due is rounded if it is less than the rounding tolerance. No payment method is rounded in this case, the whole order is rounded instead. Taxes computation is now done globally on the order, and not on each line. This way we ensure that the total tax amount is always correct, even if there is some rounding issues on the lines. --- Changes in tests: `test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_with_residual_rounding` `test_cash_rounding_up_add_invoice_line_not_only_round_cash_method` Are removed because the tested behavior is not longer present. Now when rounding is enabled with only_round_cash_method=False, the whole order is always rounded. Accounting tests are now principally tested with Hoot instead of tours. --- Developer note: No prices should be calculated manually in the code base. Getters have been created for this purpose in the following files: - `product_template_accounting.js` - `pos_order_accounting.js` - `pos_order_line_accounting.js` In the future, all PRs containing tax calculations must justify them. closes odoo/odoo#233196 Taskid: 5143758 X-original-commit: 9538698f13d5763b49b00f4c06a1a2afc0d6b39e Related: odoo/enterprise#98167 Signed-off-by: David Monnom (moda) --- .../static/src/app/models/pos_order.js | 2 +- .../static/src/xml/OrderReceipt.xml | 2 +- .../static/src/xml/OrderSummary.xml | 2 +- addons/l10n_fr_pos_cert/tests/test_hash.py | 2 +- .../tests/test_myinvois_pos.py | 18 + .../static/src/overrides/models/pos_order.js | 4 +- addons/point_of_sale/models/pos_order.py | 7 +- .../order_display/order_display.xml | 11 +- .../src/app/components/orderline/orderline.js | 115 +++- .../app/components/orderline/orderline.xml | 71 +-- .../product_configurator_popup.js | 2 +- .../customer_display_adapter.js | 13 +- .../src/app/models/account_fiscal_position.js | 29 + .../models/accounting/pos_order_accounting.js | 261 +++++++++ .../accounting/pos_order_line_accounting.js | 165 ++++++ .../accounting/product_template_accounting.js | 163 ++++++ .../static/src/app/models/pos_config.js | 6 + .../static/src/app/models/pos_order.js | 303 ++--------- .../static/src/app/models/pos_order_line.js | 247 +-------- .../static/src/app/models/product_template.js | 187 +------ .../static/src/app/models/utils/tax_utils.js | 86 --- .../screens/payment_screen/payment_screen.js | 12 +- .../screens/payment_screen/payment_screen.xml | 2 +- .../payment_status/payment_status.js | 39 +- .../payment_status/payment_status.xml | 16 +- .../order_summary/order_summary.js | 9 +- .../screens/product_screen/product_screen.js | 6 +- .../receipt_screen/receipt/order_receipt.xml | 14 +- .../screens/receipt_screen/receipt_screen.js | 4 +- .../screens/ticket_screen/ticket_screen.js | 2 +- .../static/src/app/services/pos_store.js | 61 +-- .../static/src/app/utils/numbers.js | 6 - .../src/app/utils/order_payment_validation.js | 24 +- .../static/tests/pos/tours/acceptance_tour.js | 25 - .../tests/pos/tours/payment_screen_tour.js | 63 +-- .../tests/pos/tours/pos_cash_rounding_tour.js | 513 ------------------ .../tests/pos/tours/product_screen_tour.js | 2 +- .../pos/tours/utils/payment_screen_util.js | 4 +- .../pos/tours/utils/receipt_screen_util.js | 13 - .../tests/unit/accounting/discount.test.js | 44 ++ .../tests/unit/accounting/old_tour.test.js | 363 +++++++++++++ .../tests/unit/accounting/price.test.js | 54 ++ .../tests/unit/accounting/rounding.test.js | 328 +++++++++++ .../static/tests/unit/accounting/utils.js | 66 +++ .../tests/unit/components/orderline.test.js | 9 +- .../unit/components/product_screen.test.js | 4 +- .../tests/unit/data/account_tax.data.js | 15 +- .../tests/unit/data/account_tax_group.data.js | 10 + .../tests/unit/data/product_product.data.js | 24 + .../tests/unit/data/product_template.data.js | 70 ++- .../tests/unit/models/pos_category.test.js | 12 +- .../tests/unit/models/pos_order.test.js | 96 ++-- .../tests/unit/models/pos_order_line.test.js | 128 ++--- .../tests/unit/services/pos_service.test.js | 19 +- .../point_of_sale/static/tests/unit/utils.js | 5 + addons/point_of_sale/tests/common.py | 3 +- addons/point_of_sale/tests/test_frontend.py | 60 +- .../tests/test_pos_cash_rounding.py | 294 ---------- .../screens/ticket_screen/ticket_screen.js | 10 +- .../tests/test_taxes_global_discount.py | 2 +- .../screens/product_screen/product_screen.js | 8 - .../manage_giftcard_popup.js | 2 +- .../static/src/app/models/pos_order.js | 61 +-- .../control_buttons/control_buttons.js | 2 +- .../control_buttons/control_buttons.xml | 2 +- .../static/src/app/services/pos_store.js | 2 +- .../tours/pos_loyalty_loyalty_program_tour.js | 2 +- .../tests/unit/services/pos_service.test.js | 28 - .../src/app/utils/order_payment_validation.js | 2 +- .../screens/payment_screen/payment_screen.js | 2 +- .../static/src/app/services/pos_store.js | 28 +- .../app/components/orderline/orderline.xml | 4 +- .../static/src/app/models/pos_order.js | 2 +- .../payment_screen_payment_lines.xml | 2 +- .../order_receipt/order_receipt.xml | 2 +- .../split_bill_screen/split_bill_screen.js | 3 +- .../split_bill_screen/split_bill_screen.xml | 2 +- .../src/app/screens/tip_screen/tip_screen.js | 4 +- .../static/src/app/services/pos_store.js | 2 - .../tests/unit/components/tip_screen.test.js | 2 +- .../static/src/app/services/pos_store.js | 3 +- .../tests/unit/components/orderline.test.js | 2 +- .../static/tests/unit/data/pos_config.data.js | 2 +- .../tests/unit/data/product_product.data.js | 4 +- .../tests/unit/data/product_template.data.js | 2 +- .../tests/unit/models/pos_order_line.test.js | 4 +- .../tests/unit/services/pos_store.test.js | 24 +- addons/pos_sale/tests/test_pos_sale_report.py | 4 +- .../components/order_widget/order_widget.js | 6 +- .../static/src/app/models/pos_order_line.js | 7 +- .../src/app/pages/cart_page/cart_page.xml | 4 +- .../order_history_page/order_history_page.js | 4 +- .../app/pages/product_page/product_page.js | 20 +- .../static/src/app/services/card_utils.js | 20 +- .../src/app/services/self_order_service.js | 21 +- .../unit/services/self_order_service.test.js | 1 - .../screens/payment_screen/payment_screen.js | 2 +- 97 files changed, 2145 insertions(+), 2274 deletions(-) create mode 100644 addons/point_of_sale/static/src/app/models/account_fiscal_position.js create mode 100644 addons/point_of_sale/static/src/app/models/accounting/pos_order_accounting.js create mode 100644 addons/point_of_sale/static/src/app/models/accounting/pos_order_line_accounting.js create mode 100644 addons/point_of_sale/static/src/app/models/accounting/product_template_accounting.js delete mode 100644 addons/point_of_sale/static/src/app/models/utils/tax_utils.js create mode 100644 addons/point_of_sale/static/tests/unit/accounting/discount.test.js create mode 100644 addons/point_of_sale/static/tests/unit/accounting/old_tour.test.js create mode 100644 addons/point_of_sale/static/tests/unit/accounting/price.test.js create mode 100644 addons/point_of_sale/static/tests/unit/accounting/rounding.test.js create mode 100644 addons/point_of_sale/static/tests/unit/accounting/utils.js diff --git a/addons/l10n_es_pos/static/src/app/models/pos_order.js b/addons/l10n_es_pos/static/src/app/models/pos_order.js index 856c4d5a0cb9f..5e47875c15958 100644 --- a/addons/l10n_es_pos/static/src/app/models/pos_order.js +++ b/addons/l10n_es_pos/static/src/app/models/pos_order.js @@ -6,7 +6,7 @@ patch(PosOrder.prototype, { canBeSimplifiedInvoiced() { return ( this.config.is_spanish && - roundCurrency(this.getTotalWithTax(), this.currency) < + roundCurrency(this.priceIncl, this.currency) < this.company.l10n_es_simplified_invoice_limit ); }, diff --git a/addons/l10n_fr_pos_cert/static/src/xml/OrderReceipt.xml b/addons/l10n_fr_pos_cert/static/src/xml/OrderReceipt.xml index b58420770ca7d..ad0a7c6758325 100644 --- a/addons/l10n_fr_pos_cert/static/src/xml/OrderReceipt.xml +++ b/addons/l10n_fr_pos_cert/static/src/xml/OrderReceipt.xml @@ -15,7 +15,7 @@ Old unit price: - / Units + / Units diff --git a/addons/l10n_fr_pos_cert/static/src/xml/OrderSummary.xml b/addons/l10n_fr_pos_cert/static/src/xml/OrderSummary.xml index bf15be86ad673..89e663f3cfb4c 100644 --- a/addons/l10n_fr_pos_cert/static/src/xml/OrderSummary.xml +++ b/addons/l10n_fr_pos_cert/static/src/xml/OrderSummary.xml @@ -7,7 +7,7 @@ Old unit price: - / Units + / Units diff --git a/addons/l10n_fr_pos_cert/tests/test_hash.py b/addons/l10n_fr_pos_cert/tests/test_hash.py index 4ced00ecebe90..215976de6a9d1 100644 --- a/addons/l10n_fr_pos_cert/tests/test_hash.py +++ b/addons/l10n_fr_pos_cert/tests/test_hash.py @@ -44,7 +44,7 @@ def test_hashes_should_be_equal_if_no_alteration(self): paid_order = { 'access_token': False, 'amount_paid': 20, - 'amount_return': 5.0, + 'amount_return': -5.0, 'amount_tax': 0, 'amount_total': 15.0, 'date_order': fields.Datetime.to_string(fields.Datetime.now()), diff --git a/addons/l10n_my_edi_pos/tests/test_myinvois_pos.py b/addons/l10n_my_edi_pos/tests/test_myinvois_pos.py index dcbca6e0b2414..287dbb5a988bc 100644 --- a/addons/l10n_my_edi_pos/tests/test_myinvois_pos.py +++ b/addons/l10n_my_edi_pos/tests/test_myinvois_pos.py @@ -469,6 +469,9 @@ def test_refund_constrains_consolidated_invoice(self): # Fails, the order should be invoiced in such a case with self.assertRaises(UserError): self._create_order({ + 'pos_order_ui_args': { + 'is_refund': True, + }, 'pos_order_lines_ui_args': [ { 'product': self.product_one, @@ -480,6 +483,9 @@ def test_refund_constrains_consolidated_invoice(self): # If it is, it will work self.invoicing_customer.vat = 'EI00000000010' self._create_order({ + 'pos_order_ui_args': { + 'is_refund': True, + }, 'pos_order_lines_ui_args': [ { 'product': self.product_one, @@ -500,6 +506,9 @@ def test_refund_constrains_regular_invoice(self): # Fails, the order should be invoiced in such a case with self.assertRaises(UserError): self._create_order({ + 'pos_order_ui_args': { + 'is_refund': True, + }, 'pos_order_lines_ui_args': [ { 'product': self.product_one, @@ -510,6 +519,9 @@ def test_refund_constrains_regular_invoice(self): }) # If invoicing is checked, it will work. self._create_order({ + 'pos_order_ui_args': { + 'is_refund': True, + }, 'pos_order_lines_ui_args': [ { 'product': self.product_one, @@ -571,6 +583,9 @@ def test_consolidate_invoices_refund_with_customer(self): # We then create the refund for the order with self.with_pos_session(), patch(CONTACT_PROXY_METHOD, new=self._mock_successful_submission): self._create_order({ + 'pos_order_ui_args': { + 'is_refund': True, + }, 'pos_order_lines_ui_args': [ { 'product': self.product_one, @@ -690,6 +705,9 @@ def test_consolidate_invoices_refund_export_xml(self): # We then create the refund for the first_order with self.with_pos_session(), patch(CONTACT_PROXY_METHOD, new=self._mock_successful_submission): self._create_order({ + 'pos_order_ui_args': { + 'is_refund': True, + }, 'pos_order_lines_ui_args': [ { 'product': product_1, diff --git a/addons/l10n_sa_pos/static/src/overrides/models/pos_order.js b/addons/l10n_sa_pos/static/src/overrides/models/pos_order.js index 8f5ed69b6bd7f..121d16e69d2c5 100644 --- a/addons/l10n_sa_pos/static/src/overrides/models/pos_order.js +++ b/addons/l10n_sa_pos/static/src/overrides/models/pos_order.js @@ -16,8 +16,8 @@ patch(PosOrder.prototype, { company.name, company.vat, this.date_order, - this.getTotalWithTax(), - this.getTotalTax() + this.priceIncl, + this.amountTaxes ); const qr_code_svg = new XMLSerializer().serializeToString( codeWriter.write(qr_values, 200, 200) diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 00a38c65f45f4..90e289be84316 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -188,7 +188,7 @@ def _process_payment_lines(self, pos_order, order, pos_session, draft): return_payment_vals = { 'name': _('return'), 'pos_order_id': order.id, - 'amount': -pos_order['amount_return'], + 'amount': pos_order['amount_return'], 'payment_date': fields.Datetime.now(), 'payment_method_id': cash_payment_method.id, 'is_change': True, @@ -209,7 +209,7 @@ def _prepare_tax_base_line_values(self): @api.model def _get_invoice_lines_values(self, line_values, pos_line, move_type): # correct quantity sign based on move type and if line is refund. - is_refund_order = pos_line.order_id.amount_total < 0.0 + is_refund_order = pos_line.order_id.is_refund qty_sign = -1 if ( (move_type == 'out_invoice' and is_refund_order) or (move_type == 'out_refund' and not is_refund_order) @@ -847,8 +847,7 @@ def _prepare_invoice_vals(self): fiscal_position = self.fiscal_position_id pos_config = self.config_id rounding_method = pos_config.rounding_method - amount_total = sum(order.amount_total for order in self) - move_type = 'out_invoice' if amount_total >= 0 else 'out_refund' + move_type = 'out_invoice' if not any(order.is_refund for order in self) else 'out_refund' invoice_payment_term_id = ( self.partner_id.property_payment_term_id.id if self.partner_id.property_payment_term_id and any(p.payment_method_id.type == 'pay_later' for p in self.payment_ids) diff --git a/addons/point_of_sale/static/src/app/components/order_display/order_display.xml b/addons/point_of_sale/static/src/app/components/order_display/order_display.xml index 50861d71c34fc..a36f69bbf2fad 100644 --- a/addons/point_of_sale/static/src/app/components/order_display/order_display.xml +++ b/addons/point_of_sale/static/src/app/components/order_display/order_display.xml @@ -29,18 +29,15 @@ - -
-
+
+
Taxes
- +
- Total - + Total
diff --git a/addons/point_of_sale/static/src/app/components/orderline/orderline.js b/addons/point_of_sale/static/src/app/components/orderline/orderline.js index db2cab3742d87..bc90e16a82523 100644 --- a/addons/point_of_sale/static/src/app/components/orderline/orderline.js +++ b/addons/point_of_sale/static/src/app/components/orderline/orderline.js @@ -27,27 +27,6 @@ export class Orderline extends Component { onLongPress: () => {}, }; - formatCurrency(amount) { - return formatCurrency(amount, this.line.currency.id); - } - - get line() { - return this.props.line; - } - - get taxGroup() { - return [ - ...new Set( - this.line.product_id.taxes_id - ?.map((tax) => tax.tax_group_id.pos_receipt_label) - .filter((label) => label) - ), - ].join(" "); - } - getInternalNotes() { - return JSON.parse(this.line.note || "[]"); - } - setup() { this.root = useRef("root"); if (this.props.mode === "display") { @@ -69,4 +48,98 @@ export class Orderline extends Component { ]); } } + + get line() { + return this.props.line; + } + + get lineContainerClasses() { + return { + selected: this.line.isSelected() && this.props.mode === "display", + ...this.line.getDisplayClasses(), + ...(this.props.class || []), + "border-start": this.props.mode != "receipt" && this.line.combo_parent_id, + "orderline-combo fst-italic ms-4": this.line.combo_parent_id, + "position-relative d-flex align-items-center lh-sm cursor-pointer": true, // Keep all classes here + }; + } + + get lineClasses() { + const line = this.line; + const props = this.props; + if (line.combo_parent_id) { + return props.mode === "receipt" ? "px-2" : "p-2"; + } else { + if (props.mode === "receipt") { + return line.combo_line_ids.length > 0 ? "" : "py-1"; + } else { + return "p-2"; + } + } + } + + get infoListClasses() { + const line = this.line; + const props = this.props; + if (props.mode === "receipt") { + return ""; + } + if (line.customer_note || line.note || line.discount || line.packLotLines?.length) { + return "gap-2 mt-1"; + } + return ""; + } + + /** + * To avoid to much logic in the template, we compute all values here + * and use them in the template. + */ + get lineScreenValues() { + const line = this.line; + + // Prevent rendering if the line is not yet linked to an order + // this can happen during related models connections + if (!line.order_id) { + return {}; + } + + const imageUrl = line.product_id?.getImageUrl(); + const basic = this.props.basic_receipt; + const unitPart = line.getQuantityStr().unitPart; + const decimalPart = line.getQuantityStr().decimalPart; + const decimalPoint = line.getQuantityStr().decimalPoint; + const discount = line.getDiscountStr(); + const mode = this.props.mode; + const attributeStr = line.orderDisplayProductName.attributeString; + const taxGroup = [ + ...new Set( + this.line.product_id.taxes_id + ?.map((tax) => tax.tax_group_id.pos_receipt_label) + .filter((label) => label) + ), + ].join(" "); + const showPrice = + !basic && + line.getQuantityStr() != 1 && + (mode === "receipt" || (line.price_type !== "original" && !line.combo_parent_id)); + const priceUnit = `${line.currencyDisplayPriceUnit} / ${ + line.product_id?.uom_id?.name || "" + }`; + return { + name: mode === "receipt" ? line.full_product_name : line.orderDisplayProductName.name, + attributeString: mode === "display" && attributeStr && `- ${attributeStr}`, + internalNote: mode === "display" && line.note && JSON.parse(this.line.note || "[]"), + isReceipt: mode === "receipt", + isDisplay: mode === "display", + discount: !basic && discount && discount !== "0" && !line.combo_parent_id && discount, + noDiscountPrice: formatCurrency(line.displayPriceNoDiscount, line.currency.id), + displayPriceUnit: showPrice && line.price !== 0 && priceUnit, + unitPart: unitPart, + decimalPart: decimalPart && `${decimalPoint}${decimalPart}`, + productImage: this.props.showImage && imageUrl, + taxGroup: this.props.showTaxGroup && taxGroup, + price: !basic && !line.combo_parent_id && this.line.currencyDisplayPrice, + lotLines: line.product_id.tracking !== "none" && (line.packLotLines || []), + }; + } } diff --git a/addons/point_of_sale/static/src/app/components/orderline/orderline.xml b/addons/point_of_sale/static/src/app/components/orderline/orderline.xml index e4b75adbf8af0..8996360e0261f 100644 --- a/addons/point_of_sale/static/src/app/components/orderline/orderline.xml +++ b/addons/point_of_sale/static/src/app/components/orderline/orderline.xml @@ -1,70 +1,47 @@ -
  • -
    -
    - + +
  • +
    +
    -
    -
    -
    - - - - - - +
    +
    +
    + + + - - - - - -
    - - - - -
    +
    +
    -
    -
    +
    +
    -
      -
    • - - - - / - -
    • -
    • - - - % discount off on - +
        +
      • +
      • + % discount off on
      • -
      • +
      • - +
        -
      • -
        +
      • +
        -
      • +
  • 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 5355fc2ce2089..4dd1d9141b980 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 @@ -238,7 +238,7 @@ export class ProductConfiguratorPopup extends Component { } get title() { - const info = this.props.productTemplate.getProductPriceInfo(this.product, this.pos.company); + const info = this.props.productTemplate.getTaxDetails(); const name = this.props.productTemplate.display_name; const total = this.env.utils.formatCurrency(info?.raw_total_included_currency || 0.0); const taxName = info?.taxes_data[0]?.name || ""; diff --git a/addons/point_of_sale/static/src/app/customer_display/customer_display_adapter.js b/addons/point_of_sale/static/src/app/customer_display/customer_display_adapter.js index 9d904bf1ad7d2..d6f036de8a2bc 100644 --- a/addons/point_of_sale/static/src/app/customer_display/customer_display_adapter.js +++ b/addons/point_of_sale/static/src/app/customer_display/customer_display_adapter.js @@ -34,8 +34,8 @@ export class CustomerDisplayPosAdapter { this.data = { finalized: order.finalized, general_customer_note: order.general_customer_note, - amount: formatCurrency(order.getTotalWithTax() || 0, order.currency), - change: order.getChange() && formatCurrency(order.getChange(), order.currency), + amount: order.currencyDisplayPrice, + change: order.change && formatCurrency(order.change, order.currency), paymentLines: order.payment_ids.map((pl) => this.getPaymentData(pl)), lines: order.lines.map((l) => this.getOrderlineData(l)), qrPaymentData: toRaw(order.getSelectedPaymentline()?.qrPaymentData), @@ -50,16 +50,13 @@ export class CustomerDisplayPosAdapter { customerNote: line.getCustomerNote() || "", internalNote: line.getNote() || "[]", productName: line.getFullProductName(), - price: line.getPriceString(), + price: line.currencyDisplayPrice, qty: line.getQuantityStr().qtyStr, unit: line.product_id.uom_id ? line.product_id.uom_id.name : "", - unitPrice: formatCurrency(line.unitDisplayPrice, line.currency), + unitPrice: line.currencyDisplayPriceUnit, packLotLines: line.packLotLines, comboParent: line.combo_parent_id?.getFullProductName?.() || "", - price_without_discount: formatCurrency( - line.getUnitDisplayPriceBeforeDiscount(), - line.currency - ), + price_without_discount: formatCurrency(line.displayPriceNoDiscount, line.currency), isSelected: line.isSelected(), }; } diff --git a/addons/point_of_sale/static/src/app/models/account_fiscal_position.js b/addons/point_of_sale/static/src/app/models/account_fiscal_position.js new file mode 100644 index 0000000000000..65631c3457a18 --- /dev/null +++ b/addons/point_of_sale/static/src/app/models/account_fiscal_position.js @@ -0,0 +1,29 @@ +import { registry } from "@web/core/registry"; +import { Base } from "./related_models"; + +export class AccountFiscalPosition extends Base { + static pythonModel = "account.fiscal.position"; + + getTaxesAfterFiscalPosition(taxes) { + if (!this.tax_ids?.length) { + return []; + } + + const newTaxIds = []; + for (const tax of taxes) { + if (this.tax_map[tax.id]) { + for (const mapTaxId of this.tax_map[tax.id]) { + newTaxIds.push(mapTaxId); + } + } else { + newTaxIds.push(tax.id); + } + } + + return this.models["account.tax"].filter((tax) => newTaxIds.includes(tax.id)); + } +} + +registry + .category("pos_available_models") + .add(AccountFiscalPosition.pythonModel, AccountFiscalPosition); diff --git a/addons/point_of_sale/static/src/app/models/accounting/pos_order_accounting.js b/addons/point_of_sale/static/src/app/models/accounting/pos_order_accounting.js new file mode 100644 index 0000000000000..7b0d23f9d97f9 --- /dev/null +++ b/addons/point_of_sale/static/src/app/models/accounting/pos_order_accounting.js @@ -0,0 +1,261 @@ +import { Base } from "../related_models"; +import { accountTaxHelpers } from "@account/helpers/account_tax"; +import { logPosMessage } from "../../utils/pretty_console_log"; +import { formatCurrency } from "@web/core/currency"; + +const CONSOLE_COLOR = "#4EFF4D"; + +export class PosOrderAccounting extends Base { + /** + * Currency formatted prices, these getters already handle included/excluded tax configuration. + * They must be used each time a price is displayed to the user. + */ + get currencyDisplayPrice() { + return formatCurrency(this.displayPrice, this.currency.id); + } + + get currencyAmountTaxes() { + return formatCurrency(this.amountTaxes, this.currency.id); + } + + /** + * Display price depending on the tax configuration (included or excluded). + */ + get displayPrice() { + return this.config.iface_tax_included === "total" + ? this.currency.round(this.priceIncl) + : this.currency.round(this.priceExcl); + } + + /** + * Remaining due take into account the rounding of the order, the rounding can be configured + * in two different ways: + * + * 1) Rounding applied only on cash payments + * In this case the remaining due is rounded only if there is at least one cash payment line. + * and the remaining due is less than the rounding tolerance. + * + * 2) Rounding applied on all payment methods + * In this case the remaining due is always rounded even if a card payment method is used. + * The remaining due is rounded if it is less than the rounding tolerance. No payment method + * is rounded in this case, the whole order is rounded instead. + * + * !!! Keep in mind that from 19.0 only one cash payment line can be used in an order !!! + */ + get remainingDue() { + const isNegative = this.totalDue < 0; + const total = this.totalDue; + const remaining = total - this.amountPaid; + + // Amount paid covers the total due + if ((isNegative && remaining >= 0) || (!isNegative && remaining <= 0)) { + return 0; + } + + const tolerance = this.orderIsRounded ? this.config.rounding_method.rounding : 0; + const amount = Math.abs(total - this.amountPaid) <= tolerance ? 0 : Math.abs(remaining); + return isNegative ? this.currency.round(-amount) : this.currency.round(amount); + } + get change() { + const isNegative = this.totalDue < 0; + const roundingSanatizer = this.orderIsRounded ? this.appliedRounding : 0; + const remaining = this.totalDue - this.amountPaid; + + // Amount paid covers the total due + if ((isNegative && remaining <= 0) || (!isNegative && remaining >= 0)) { + return 0; + } + + const total = + Math.abs(this.displayPrice) - + Math.abs(this.amountPaid) + + (isNegative ? -roundingSanatizer : roundingSanatizer); + + const amount = isNegative ? -this.currency.round(total) : this.currency.round(total); + return this.config.cash_rounding ? this.config.rounding_method.round(amount) : amount; + } + get orderIsRounded() { + const cashPm = this.payment_ids.some((p) => p.payment_method_id.is_cash_count); + return this.config.hasGlobalRounding || (cashPm && this.config.hasCashRounding); + } + get appliedRounding() { + const total = this.prices.taxDetails.total_amount_no_rounding; + const isNegative = this.amountPaid > total; + const remaining = total - this.amountPaid; + const tolerance = this.orderIsRounded ? this.config.rounding_method.rounding : 0; + const amount = Math.abs(total - this.amountPaid) <= tolerance ? Math.abs(remaining) : 0; + return isNegative ? this.currency.round(amount) : this.currency.round(-amount); + } + + /** + * Getters are preferred to methods because they are cached. + * These getters must be used each time the order prices are needed. + * + * Do not try to make your own price computation outside these getters. + */ + get prices() { + return this._constructPriceData(); + } + get priceIncl() { + return this.prices.taxDetails.total_amount_no_rounding; + } + get priceExcl() { + return this.prices.taxDetails.base_amount; + } + get totalDue() { + return this.config.hasCashRounding + ? this.currency.round(this.prices.taxDetails.total_amount_no_rounding) + : this.currency.round(this.prices.taxDetails.total_amount); + } + get amountTaxes() { + return this.prices.taxDetails.tax_amount_currency; + } + get orderHasZeroRemaining() { + return this.currency.isZero(this.remainingDue); + } + get amountPaid() { + return this.currency.round( + this.payment_ids.reduce(function (sum, paymentLine) { + // Return lines are created after the sync, should not be taken into account in + // the paid amount otherwise, the change would be wrong. + if (paymentLine.isDone() && paymentLine.name !== "return") { + sum += paymentLine.getAmount(); + } + return sum; + }, 0) + ); + } + get orderSign() { + return this.prices.taxDetails.order_sign; + } + + /** + * Determine if the amount to pay should be rounded depending on the payment method + * and the cash rounding configuration. + * + * Rounding on payment methods happen only when cash_rounding is enabled and + * only_round_cash_method is enabled too. In this case only cash payment methods + * will have a rounded amount to pay. + * + * If only_round_cash_method is disabled, no payment method will have a rounded amount to pay. + * The whole order is rounded instead. + */ + shouldRound(paymentMethod) { + return paymentMethod.is_cash_count && this.config.hasCashRounding; + } + + /** + * Get the amount to pay by default when creating a new payment. + * @param paymentMethod: The payment method of the payment to be created. + * @returns A monetary value. + */ + getDefaultAmountDueToPayIn(paymentMethod) { + const amount = this.shouldRound(paymentMethod) + ? this.config_id.rounding_method.round(this.remainingDue) + : this.remainingDue; + return amount || this.change; + } + + /** + * Since prices are computed on the fly, we need to set them before sending + * the order to the backend. + * + * This method is called when the order is pushed to the backend. + */ + setOrderPrices() { + this.amount_paid = this.amountPaid; // Already rounded by the getter + this.amount_tax = this.amountTaxes; // Already rounded by the getter + this.amount_total = this.currency.round(this.priceIncl); + this.amount_return = this.change; // Already rounded by the getter + this.lines.forEach((line) => { + line.price_subtotal = line.displayPriceUnit; + line.price_subtotal_incl = line.displayPrice; + }); + } + /** + * This method is used when extra options need to be passed to the price computation. + * All these options will finally reach these methods: + * - prepareBaseLineForTaxesComputationExtraValues + * - prepare_base_line_for_taxes_computation (accounting helpers) + * + * For example you can pass a specific set of lines to compute the price of + * only these lines instead of the whole order. + */ + getPriceWithOptions(opts = {}) { + return this._constructPriceData(opts); + } + + /** + * @private + * + * Private method computing all prices and tax details. + * DO NOT USE THIS METHOD OUTSIDE THIS FILE !!! + */ + _constructPriceData(opts = {}) { + const data = this._computeAllPrices(opts); + const noDiscount = this._computeAllPrices({ baseLineOpts: { discount: 0.0 }, ...opts }); + const currency = this.currency; + + for (const key of Object.keys(data.baseLineByLineUuids)) { + const ndData = noDiscount.baseLineByLineUuids[key].tax_details; + const dData = data.baseLineByLineUuids[key].tax_details; + + Object.assign(data.baseLineByLineUuids[key].tax_details, { + discount_amount: currency.round(ndData.total_included - dData.total_included), + no_discount_total_excluded: ndData.total_excluded, + no_discount_total_included: ndData.total_included, + no_discount_total_included_currency: ndData.total_included_currency, + no_discount_total_excluded_currency: ndData.total_excluded_currency, + no_discount_taxes_data: ndData.taxes_data, + no_discount_delta_total_excluded: ndData.delta_total_excluded, + no_discount_delta_total_included: ndData.delta_total_included, + }); + } + + logPosMessage("Accounting", "_constructPriceData", "Recompute allPrices", CONSOLE_COLOR, [ + data, + ]); + return data; + } + + /** + * @private + * + * Private method computing all prices and tax details. + * DO NOT USE THIS METHOD OUTSIDE THIS FILE !!! + */ + _computeAllPrices(opts = {}) { + const currency = this.currency; + const lines = opts.lines || this.lines; + const documentSign = this.isRefund ? -1 : 1; + const company = this.company; + const baseLines = lines.map((l) => + l.getBaseLine({ + quantity: l.qty, + price_unit: l.price_unit, + ...(opts.baseLineOpts || {}), + }) + ); + + accountTaxHelpers.add_tax_details_in_base_lines(baseLines, company); + accountTaxHelpers.round_base_lines_tax_details(baseLines, company); + + // Cash rounding is added only if the document needs to be globaly rounded. + // See cash_rounding and only_round_cash_method config fields. + const cashRounding = this.config.cash_rounding ? this.config.rounding_method : null; + const data = accountTaxHelpers.get_tax_totals_summary(baseLines, currency, company, { + cash_rounding: cashRounding, + }); + const total = data.total_amount_currency - (data.cash_rounding_base_amount_currency || 0.0); + + data.order_sign = documentSign; + data.total_amount_no_rounding = total; + + const baseLineByLineUuids = baseLines.reduce((acc, line) => { + acc[line.record.uuid] = line; + return acc; + }, {}); + + return { taxDetails: data, baseLines: baseLines, baseLineByLineUuids: baseLineByLineUuids }; + } +} diff --git a/addons/point_of_sale/static/src/app/models/accounting/pos_order_line_accounting.js b/addons/point_of_sale/static/src/app/models/accounting/pos_order_line_accounting.js new file mode 100644 index 0000000000000..74794bed162a1 --- /dev/null +++ b/addons/point_of_sale/static/src/app/models/accounting/pos_order_line_accounting.js @@ -0,0 +1,165 @@ +import { formatCurrency } from "@web/core/currency"; +import { Base } from "../related_models"; +import { accountTaxHelpers } from "@account/helpers/account_tax"; +import { _t } from "@web/core/l10n/translation"; + +export class PosOrderlineAccounting extends Base { + /** + * Display price in the currency format, depending on the tax configuration (included or excluded). + * + * All getters in this section are used in XML files, their goal is to be shown in the UI. + */ + get currencyDisplayPrice() { + if (this.combo_parent_id) { + return ""; + } + + if (this.getDiscount() === 100) { + return _t("Free"); + } + + return formatCurrency(this.displayPrice, this.currency.id); + } + get currencyDisplayPriceUnit() { + return formatCurrency(this.displayPriceUnit, this.currency.id); + } + + /** + * Display price depending on the tax configuration (included or excluded). + */ + get displayPrice() { + return !this.combo_line_ids.length + ? this.config.iface_tax_included === "total" + ? this.priceIncl + : this.priceExcl + : this.combo_line_ids.reduce((total, cl) => { + const price = + this.config.iface_tax_included === "total" ? cl.priceIncl : cl.priceExcl; + return total + price; + }, 0); + } + get displayPriceNoDiscount() { + return !this.combo_line_ids.length + ? this.config.iface_tax_included === "total" + ? this.priceInclNoDiscount + : this.priceExclNoDiscount + : this.combo_line_ids.reduce((total, cl) => { + const price = + this.config.iface_tax_included === "total" + ? cl.priceInclNoDiscount + : cl.priceExclNoDiscount; + return total + price; + }, 0); + } + get displayPriceUnit() { + return this.unitPrices.total_excluded * this.order_id.orderSign; + } + + get priceIncl() { + return this.currency.round(this.prices.total_included * this.order_id.orderSign); + } + get priceExcl() { + return this.currency.round(this.prices.total_excluded * this.order_id.orderSign); + } + get priceInclNoDiscount() { + return this.currency.round( + this.prices.no_discount_total_included * this.order_id.orderSign + ); + } + get priceExclNoDiscount() { + return this.currency.round( + this.prices.no_discount_total_excluded * this.order_id.orderSign + ); + } + + /** + * Return all prices details of an orderlines based on the order prices computation. + * This is the preferred way to get prices of an orderline since its rounded globally. + */ + get prices() { + const data = this.order_id.prices.baseLineByLineUuids[this.uuid]; + return data.tax_details; + } + + /** + * Same as "get prices" but the prices are computed as if the quantity was 1. + */ + get unitPrices() { + const data = this.order_id._constructPriceData({ + baseLineOpts: { quantity: 1 }, + }).baseLineByLineUuids[this.uuid]; + return data.tax_details; + } + + get productProductPrice() { + return this.product_id.getPrice( + this.config.pricelist_id, + 1, + this.price_extra, + false, + this.product_id + ); + } + + get comboTotalPrice() { + const allLines = this.getAllLinesInCombo(); + return allLines.reduce((total, line) => total + line.displayPrice, 0); + } + + get comboTotalPriceWithoutTax() { + const allLines = this.getAllLinesInCombo(); + return allLines.reduce((total, line) => total + line.displayPriceUnit, 0); + } + + get taxGroupLabels() { + return this.tax_ids + ?.map((tax) => tax.tax_group_id?.pos_receipt_label) + .filter((label) => label) + .join(" "); + } + + /** + * Prepare extra values for the base line used in taxes computation. + */ + prepareBaseLineForTaxesComputationExtraValues(customValues = {}) { + const order = this.order_id; + const currency = order.config.currency_id; + const extraValues = { currency_id: currency }; + const product = this.getProduct(); + const priceUnit = this.price_unit || 0; + const discount = this.getDiscount(); + const values = { + ...extraValues, + quantity: this.qty, + price_unit: priceUnit, + discount: discount, + tax_ids: this.tax_ids, + product_id: product, + rate: 1.0, + is_refund: this.qty * priceUnit < 0, + ...customValues, + }; + if (order.fiscal_position_id) { + // Recompute taxes based on product and fiscal position. + values.tax_ids = order.fiscal_position_id.getTaxesAfterFiscalPosition( + values.product_id.taxes_id + ); + } + return values; + } + + /** + * Get the base line for taxes computation. + */ + getBaseLine(opts = {}) { + return accountTaxHelpers.prepare_base_line_for_taxes_computation( + this, + this.prepareBaseLineForTaxesComputationExtraValues({ + price_unit: this.productProductPrice, + quantity: this.getQuantity(), + tax_ids: this.tax_ids, + ...opts, + }) + ); + } +} diff --git a/addons/point_of_sale/static/src/app/models/accounting/product_template_accounting.js b/addons/point_of_sale/static/src/app/models/accounting/product_template_accounting.js new file mode 100644 index 0000000000000..c610545c2c9c3 --- /dev/null +++ b/addons/point_of_sale/static/src/app/models/accounting/product_template_accounting.js @@ -0,0 +1,163 @@ +import { roundPrecision } from "@web/core/utils/numbers"; +import { Base } from "../related_models"; +import { accountTaxHelpers } from "@account/helpers/account_tax"; +import { _t } from "@web/core/l10n/translation"; + +export class ProductTemplateAccounting extends Base { + static pythonModel = "product.template"; + + prepareProductBaseLineForTaxesComputationExtraValues(opts = {}) { + const { price = false, pricelist = false, fiscalPosition = false } = opts; + const isVariant = Boolean(this?.product_tmpl_id); + const config = this.models["pos.config"].getFirst(); + const productTemplate = isVariant ? this.product_tmpl_id : this; + const baseP = productTemplate.getPrice(pricelist, 1, 0, false, isVariant ? this : false); + const priceUnit = price || price === 0 ? price : baseP; + const currency = config.currency_id; + + let taxes = this.taxes_id; + + // Fiscal position. + if (fiscalPosition) { + taxes = fiscalPosition.getTaxesAfterFiscalPosition(taxes); + } + + return { + currency_id: currency, + product_id: this, + quantity: 1, + price_unit: priceUnit, + tax_ids: taxes, + ...opts, + }; + } + + // Port of _get_product_price on product.pricelist. + // + // Anything related to UOM can be ignored, the POS will always use + // the default UOM set on the product and the user cannot change + // it. + // + // Pricelist items do not have to be sorted. All + // product.pricelist.item records are loaded with a search_read + // and were automatically sorted based on their _order by the + // ORM. After that they are added in this order to the pricelists. + getPrice( + pricelist, + quantity, + price_extra = 0, + recurring = false, + variant = false, + original_line = false, + related_lines = [] + ) { + // In case of nested pricelists, it is necessary that all pricelists are made available in + // the POS. Display a basic alert to the user in the case where there is a pricelist item + // but we can't load the base pricelist to get the price when calling this method again. + // As this method is also call without pricelist available in the POS, we can't just check + // the absence of pricelist. + if (recurring && !pricelist) { + alert( + _t( + "An error occurred when loading product prices. " + + "Make sure all pricelists are available in the POS." + ) + ); + } + + const product = variant; + const productTmpl = variant.product_tmpl_id || this; + const standardPrice = variant ? variant.standard_price : this.standard_price; + const basePrice = variant ? variant.lst_price : this.list_price; + let price = basePrice + (price_extra || 0); + + if (!pricelist) { + return price; + } + + if (original_line && original_line.isLotTracked()) { + related_lines.push( + ...original_line.order_id.lines.filter( + (line) => line.product_id.product_tmpl_id.id == this.id + ) + ); + quantity = related_lines.reduce((sum, line) => sum + line.getQuantity(), 0); + } + + const tmplRules = (productTmpl.backLink("<-product.pricelist.item.product_tmpl_id") || []) + .filter((rule) => rule.pricelist_id.id === pricelist.id && !rule.product_id) + .sort((a, b) => b.min_quantity - a.min_quantity); + const productRules = (product?.backLink?.("<-product.pricelist.item.product_id") || []) + .filter((rule) => rule.pricelist_id.id === pricelist.id) + .sort((a, b) => b.min_quantity - a.min_quantity); + + const tmplRulesSet = new Set(tmplRules.map((rule) => rule.id)); + const productRulesSet = new Set(productRules.map((rule) => rule.id)); + const generalRulesIds = pricelist.getGeneralRulesIdsByCategories(this.parentCategories); + const rules = this.models["product.pricelist.item"] + .readMany([...productRulesSet, ...tmplRulesSet, ...generalRulesIds]) + .filter((r) => r.min_quantity <= quantity); + + const rule = rules.length && rules[0]; + if (!rule) { + return price; + } + if (rule.base === "pricelist") { + if (rule.base_pricelist_id) { + price = this.getPrice(rule.base_pricelist_id, quantity, 0, true, variant); + } + } else if (rule.base === "standard_price") { + price = standardPrice; + } + + if (rule.compute_price === "fixed") { + price = rule.fixed_price; + } else if (rule.compute_price === "percentage") { + price = price - price * (rule.percent_price / 100); + } else { + var price_limit = price; + price -= price * (rule.price_discount / 100); + if (rule.price_round) { + price = roundPrecision(price, rule.price_round); + } + if (rule.price_surcharge) { + price += rule.price_surcharge; + } + if (rule.price_min_margin) { + price = Math.max(price, price_limit + rule.price_min_margin); + } + if (rule.price_max_margin) { + price = Math.min(price, price_limit + rule.price_max_margin); + } + } + + // This return value has to be rounded with round_di before + // being used further. Note that this cannot happen here, + // because it would cause inconsistencies with the backend for + // pricelist that have base == 'pricelist'. + return price; + } + + getBaseLine(opts = {}) { + const vals = opts.overridedValues || {}; + const { price = false, pricelist = false, fiscalPosition = false } = vals; + + return accountTaxHelpers.prepare_base_line_for_taxes_computation( + {}, + this.prepareProductBaseLineForTaxesComputationExtraValues({ + price, + pricelist, + fiscalPosition, + ...vals, + }) + ); + } + + getTaxDetails(opts = {}) { + const config = this.models["pos.config"].getFirst(); + const baseLine = this.getBaseLine(opts); + accountTaxHelpers.add_tax_details_in_base_line(baseLine, config.company_id); + accountTaxHelpers.round_base_lines_tax_details([baseLine], config.company_id); + return baseLine.tax_details; + } +} diff --git a/addons/point_of_sale/static/src/app/models/pos_config.js b/addons/point_of_sale/static/src/app/models/pos_config.js index e488bb4f21e09..aa74dcb6256a8 100644 --- a/addons/point_of_sale/static/src/app/models/pos_config.js +++ b/addons/point_of_sale/static/src/app/models/pos_config.js @@ -13,6 +13,12 @@ export class PosConfig extends Base { this.uiState = {}; } + get hasCashRounding() { + return this.cash_rounding && this.only_round_cash_method; + } + get hasGlobalRounding() { + return this.cash_rounding && !this.only_round_cash_method; + } get canInvoice() { return Boolean(this.raw.invoice_journal_id); } diff --git a/addons/point_of_sale/static/src/app/models/pos_order.js b/addons/point_of_sale/static/src/app/models/pos_order.js index 69845567e7471..6551acb5f5136 100644 --- a/addons/point_of_sale/static/src/app/models/pos_order.js +++ b/addons/point_of_sale/static/src/app/models/pos_order.js @@ -1,16 +1,14 @@ import { registry } from "@web/core/registry"; -import { Base } from "./related_models"; import { _t } from "@web/core/l10n/translation"; -import { roundCurrency } from "@point_of_sale/app/models/utils/currency"; import { computeComboItems } from "./utils/compute_combo_items"; -import { accountTaxHelpers } from "@account/helpers/account_tax"; import { localization } from "@web/core/l10n/localization"; import { formatDate, serializeDateTime } from "@web/core/l10n/dates"; import { getStrNotes } from "./utils/order_change"; +import { PosOrderAccounting } from "./accounting/pos_order_accounting"; const { DateTime } = luxon; -export class PosOrder extends Base { +export class PosOrder extends PosOrderAccounting { static pythonModel = "pos.order"; setup(vals) { @@ -194,127 +192,12 @@ export class PosOrder extends Base { } } - /** - * Get the details total amounts with and without taxes, the details of taxes per subtotal and per tax group. - * @returns See '_get_tax_totals_summary' in account_tax.py for the full details. - */ - get taxTotals() { - return this.getTaxTotalsOfLines(this.lines); - } - - getTaxTotalsOfLines(lines) { - const currency = this.currency; - const company = this.company; - - // If each line is negative, we assume it's a refund order. - // It's a normal order if it doesn't contain a line (useful for pos_settle_due). - // TODO: Properly differentiate refund orders from normal ones. - const documentSign = this.isRefund ? -1 : 1; - const baseLines = lines.map((line) => - accountTaxHelpers.prepare_base_line_for_taxes_computation( - line, - line.prepareBaseLineForTaxesComputationExtraValues({ - quantity: documentSign * line.qty, - }) - ) - ); - accountTaxHelpers.add_tax_details_in_base_lines(baseLines, company); - accountTaxHelpers.round_base_lines_tax_details(baseLines, company); - - // For the generic 'get_tax_totals_summary', we only support the cash rounding that round the whole document. - const cashRounding = - !this.config.only_round_cash_method && this.config.cash_rounding - ? this.config.rounding_method - : null; - - const taxTotals = accountTaxHelpers.get_tax_totals_summary(baseLines, currency, company, { - cash_rounding: cashRounding, - }); - - taxTotals.order_sign = documentSign; - taxTotals.order_total = - taxTotals.total_amount_currency - (taxTotals.cash_rounding_base_amount_currency || 0.0); - - let order_rounding = 0; - let remaining = taxTotals.order_total; - const validPayments = this.payment_ids.filter((p) => p.isDone() && !p.is_change); - for (const [payment, isLast] of validPayments.map((p, i) => [ - p, - i === validPayments.length - 1, - ])) { - const paymentAmount = documentSign * payment.getAmount(); - if (isLast) { - if (this.config.cash_rounding) { - const roundedRemaining = this.getRoundedRemaining( - this.config.rounding_method, - remaining - ); - if (!this.currency.isZero(paymentAmount - remaining)) { - order_rounding = roundedRemaining - remaining; - } - } - } - remaining -= paymentAmount; - } - - taxTotals.order_rounding = order_rounding; - taxTotals.order_remaining = remaining; - - return taxTotals; - } - - shouldRound(paymentMethod) { - return ( - this.config.cash_rounding && - (!this.config.only_round_cash_method || paymentMethod.is_cash_count) - ); - } - - get orderHasZeroRemaining() { - const { order_remaining, order_rounding } = this.taxTotals; - const remaining_with_rounding = order_remaining + order_rounding; - return this.currency.isZero(remaining_with_rounding); - } - - /** - * Get the amount to pay by default when creating a new payment. - * @param paymentMethod: The payment method of the payment to be created. - * @returns A monetary value. - */ - getDefaultAmountDueToPayIn(paymentMethod) { - const { order_remaining, order_sign } = this.taxTotals; - const amount = this.shouldRound(paymentMethod) - ? this.getRoundedRemaining(this.config.rounding_method, order_remaining) - : order_remaining; - return order_sign * amount; - } - - getRoundedRemaining(roundingMethod, remaining) { - remaining = roundCurrency(remaining, this.currency); - if (this.currency.isZero(remaining)) { - return 0; - } else if (this.currency.isNegative(remaining)) { - return roundingMethod.asymmetricRound(remaining); - } else { - return roundingMethod.round(remaining); - } - } - getCashierName() { return this.user_id?.name?.split(" ").at(0); } canPay() { return this.lines.length; } - recomputeOrderData() { - this.amount_paid = this.getTotalPaid(); - this.amount_tax = this.getTotalTax(); - this.amount_total = this.getTotalWithTax(); - this.amount_return = this.getChange(); - this.lines.forEach((line) => { - line.setLinePrice(); - }); - } get isBooked() { return Boolean(this.uiState.booked || !this.isEmpty() || typeof this.id === "number"); @@ -416,7 +299,7 @@ export class PosOrder extends Base { } else { for (const line of lines) { if (line.getProduct() === tip_product) { - return line.getUnitPrice(); + return line.prices.total_excluded_currency; } } return 0; @@ -548,25 +431,34 @@ export class PosOrder extends Base { /* ---- Payment Lines --- */ addPaymentline(payment_method) { this.assertEditable(); + const existingCash = this.payment_ids.find((pl) => pl.payment_method_id.is_cash_count); + if (this.electronicPaymentInProgress()) { - return false; - } else { - const totalAmountDue = this.getDefaultAmountDueToPayIn(payment_method); - const newPaymentLine = this.models["pos.payment"].create({ - pos_order_id: this, - payment_method_id: payment_method, - }); - this.selectPaymentline(newPaymentLine); - newPaymentLine.setAmount(totalAmountDue); - - if ( - (payment_method.payment_terminal && !this.isRefund) || - payment_method.payment_method_type === "qr_code" - ) { - newPaymentLine.setPaymentStatus("pending"); - } - return newPaymentLine; + return { + status: false, + data: _t("There is already an electronic payment in progress."), + }; } + + if (existingCash && payment_method.is_cash_count) { + return { status: false, data: _t("There is already a cash payment line.") }; + } + + const totalAmountDue = this.getDefaultAmountDueToPayIn(payment_method); + const newPaymentLine = this.models["pos.payment"].create({ + pos_order_id: this, + payment_method_id: payment_method, + }); + this.selectPaymentline(newPaymentLine); + newPaymentLine.setAmount(totalAmountDue); + + if ( + (payment_method.payment_terminal && !this.isRefund) || + payment_method.payment_method_type === "qr_code" + ) { + newPaymentLine.setPaymentStatus("pending"); + } + return { status: true, data: newPaymentLine }; } getPaymentlineByUuid(uuid) { @@ -604,27 +496,6 @@ export class PosOrder extends Base { }); } - getTotalWithTax() { - return this.taxTotals.order_sign * this.taxTotals.order_total; - } - - getTotalWithTaxOfLines(lines) { - const taxTotals = this.getTaxTotalsOfLines(lines); - return taxTotals.order_sign * taxTotals.total_amount_currency; - } - - getTotalWithoutTax() { - const base_amount = - this.taxTotals.base_amount_currency + - (this.taxTotals.cash_rounding_base_amount_currency || 0.0); - return this.taxTotals.order_sign * base_amount; - } - - getTotalWithoutTaxOfLines(lines) { - const taxTotals = this.getTaxTotalsOfLines(lines); - return taxTotals.order_sign * taxTotals.base_amount_currency; - } - _getIgnoredProductIdsTotalDiscount() { return []; } @@ -634,17 +505,16 @@ export class PosOrder extends Base { return this.currency.round( this.lines.reduce((sum, orderLine) => { if (!ignored_product_ids.includes(orderLine.product_id.id)) { - sum += - orderLine.getAllPrices().priceWithTaxBeforeDiscount - - orderLine.getAllPrices().priceWithTax; + const data = orderLine.order_id.prices.baseLineByLineUuids[orderLine.uuid]; + sum += data.tax_details.discount_amount; if ( orderLine.displayDiscountPolicy() === "without_discount" && !(orderLine.price_type === "manual") && orderLine.discount == 0 ) { sum += - (orderLine.getTaxedlstUnitPrice() - - orderLine.getUnitDisplayPriceBeforeDiscount()) * + (orderLine.currencyDisplayPriceUnit - + orderLine.unitPrices.no_discount_total_included) * orderLine.getQuantity(); } } @@ -653,103 +523,8 @@ export class PosOrder extends Base { ); } - getTotalTax() { - return this.taxTotals.order_sign * this.taxTotals.tax_amount_currency; - } - - getTotalPaid() { - return this.currency.round( - this.payment_ids.reduce(function (sum, paymentLine) { - if (paymentLine.isDone()) { - sum += paymentLine.getAmount(); - } - return sum; - }, 0) - ); - } - - getTotalDue() { - return this.taxTotals.order_sign * this.taxTotals.order_total; - } - - getTaxDetails() { - return this.getTaxDetailsOfLines(this.lines); - } - - getTaxDetailsOfLines(lines) { - const taxDetails = {}; - for (const line of lines) { - for (const taxData of line.allPrices.taxesData) { - const taxId = taxData.tax.id; - if (!taxDetails[taxId]) { - taxDetails[taxId] = Object.assign({}, taxData, { - amount: 0.0, - base: 0.0, - tax_percentage: taxData.tax.amount, - }); - } - taxDetails[taxId].base += taxData.base_amount_currency; - taxDetails[taxId].amount += taxData.tax_amount_currency; - } - } - return Object.values(taxDetails); - } - - // TODO: deprecated. Remove it and fix l10n_de_pos_cert accordingly. - getTotalForTaxes(tax_id) { - let total = 0; - - if (!(tax_id instanceof Array)) { - tax_id = [tax_id]; - } - - const tax_set = {}; - - for (var i = 0; i < tax_id.length; i++) { - tax_set[tax_id[i]] = true; - } - - this.lines.forEach((line) => { - var taxes_ids = this.tax_ids || line.getProduct().taxes_id; - for (var i = 0; i < taxes_ids.length; i++) { - if (tax_set[taxes_ids[i]]) { - total += line.getPriceWithTax(); - return; - } - } - }); - - return total; - } - - /** - * Checks whether to show "Remaining" or "Change" in the payment status. - * If the remaining amount is compensated by the rounding, then we show "Remaining". - */ - hasRemainingAmount() { - const { order_remaining } = this.taxTotals; - return this.orderHasZeroRemaining || !this.currency.isNegative(order_remaining); - } - - getChange() { - let { order_sign, order_remaining: remaining } = this.taxTotals; - if (this.config.cash_rounding) { - remaining = this.getRoundedRemaining(this.config.rounding_method, remaining); - } - return -order_sign * remaining; - } - - getDue() { - return this.taxTotals.order_sign * this.currency.round(this.taxTotals.order_remaining); - } - - getRoundingApplied() { - return this.taxTotals.order_sign * (this.taxTotals.order_rounding || 0.0); - } - isPaid() { - const { order_remaining } = this.taxTotals; - return this.orderHasZeroRemaining || this.currency.isNegative(order_remaining); + return this.orderHasZeroRemaining; } isRefundInProcess() { @@ -954,16 +729,8 @@ export class PosOrder extends Base { this.internal_note = note || ""; } - get orderChange() { - return this.getChange(); - } - - get showRounding() { - return !this.currency.isZero(this.taxTotals.order_rounding); - } - get showChange() { - return !this.currency.isZero(this.orderChange) && this.finalized; + return !this.currency.isZero(this.change) && this.finalized; } getOrderData(reprint = false) { diff --git a/addons/point_of_sale/static/src/app/models/pos_order_line.js b/addons/point_of_sale/static/src/app/models/pos_order_line.js index 5e456514fe924..51430f28441e1 100644 --- a/addons/point_of_sale/static/src/app/models/pos_order_line.js +++ b/addons/point_of_sale/static/src/app/models/pos_order_line.js @@ -1,15 +1,12 @@ import { registry } from "@web/core/registry"; import { constructFullProductName, constructAttributeString } from "@point_of_sale/utils"; -import { Base } from "./related_models"; import { parseFloat } from "@web/views/fields/parsers"; import { formatFloat } from "@web/core/utils/numbers"; -import { formatCurrency } from "./utils/currency"; import { _t } from "@web/core/l10n/translation"; import { localization as l10n } from "@web/core/l10n/localization"; -import { getTaxesAfterFiscalPosition } from "@point_of_sale/app/models/utils/tax_utils"; -import { accountTaxHelpers } from "@account/helpers/account_tax"; +import { PosOrderlineAccounting } from "./accounting/pos_order_line_accounting"; -export class PosOrderline extends Base { +export class PosOrderline extends PosOrderlineAccounting { static pythonModel = "pos.order.line"; setup(vals) { @@ -227,17 +224,6 @@ export class PosOrderline extends Base { const disc = Math.min(Math.max(parsed_discount || 0, 0), 100); this.discount = disc; - this.order_id.recomputeOrderData(); - } - - setLinePrice() { - const prices = this.getAllPrices(); - if (this.price_subtotal !== prices.priceWithoutTax) { - this.price_subtotal = prices.priceWithoutTax; - } - if (this.price_subtotal_incl !== prices.priceWithTax) { - this.price_subtotal_incl = prices.priceWithTax; - } } // sets the qty of the product. The qty will be rounded according to the @@ -393,35 +379,6 @@ export class PosOrderline extends Base { }); } - prepareBaseLineForTaxesComputationExtraValues(customValues = {}) { - const order = this.order_id; - const currency = order.config.currency_id; - const extraValues = { currency_id: currency }; - const product = this.getProduct(); - const priceUnit = this.getUnitPrice(); - const discount = this.getDiscount(); - - const values = { - ...extraValues, - quantity: this.qty, - price_unit: priceUnit, - discount: discount, - tax_ids: this.tax_ids, - product_id: product, - rate: 1.0, - is_refund: this.qty * priceUnit < 0, - ...customValues, - }; - if (order.fiscal_position_id) { - values.tax_ids = getTaxesAfterFiscalPosition( - values.tax_ids, - order.fiscal_position_id, - order.models - ); - } - return values; - } - setUnitPrice(price) { const ProductPrice = this.models["decimal.precision"].find( (dp) => dp.name === "Product Price" @@ -434,157 +391,6 @@ export class PosOrderline extends Base { this.price_unit = ProductPrice.round(parsed_price || 0); } - getUnitPrice() { - const ProductPrice = this.models["decimal.precision"].find( - (dp) => dp.name === "Product Price" - ); - return ProductPrice.round(this.price_unit || 0); - } - - get unitDisplayPrice() { - const prices = - this.combo_line_ids.length > 0 - ? this.combo_line_ids.reduce( - (acc, cl) => ({ - priceWithTax: acc.priceWithTax + cl.allUnitPrices.priceWithTax, - priceWithoutTax: acc.priceWithoutTax + cl.allUnitPrices.priceWithoutTax, - }), - { priceWithTax: 0, priceWithoutTax: 0 } - ) - : this.allUnitPrices; - - return this.config.iface_tax_included === "total" - ? prices.priceWithTax - : prices.priceWithoutTax; - } - - getUnitDisplayPriceBeforeDiscount() { - if (this.config.iface_tax_included === "total") { - return this.allUnitPrices.priceWithTaxBeforeDiscount; - } else { - return this.allUnitPrices.priceWithoutTaxBeforeDiscount; - } - } - getBasePrice() { - return this.currency.round( - this.getUnitPrice() * this.getQuantity() * (1 - this.getDiscount() / 100) - ); - } - - getDisplayPrice() { - if (this.config.iface_tax_included === "total") { - return this.getPriceWithTax(); - } else { - return this.getPriceWithoutTax(); - } - } - - getTaxedlstUnitPrice() { - const company = this.company; - const product = this.getProduct(); - const baseLine = accountTaxHelpers.prepare_base_line_for_taxes_computation( - this, - this.prepareBaseLineForTaxesComputationExtraValues({ - price_unit: this.getlstPrice(), - quantity: 1, - tax_ids: product.taxes_id, - }) - ); - accountTaxHelpers.add_tax_details_in_base_line(baseLine, company); - accountTaxHelpers.round_base_lines_tax_details([baseLine], company); - const taxDetails = baseLine.tax_details; - - if (this.config.iface_tax_included === "total") { - return taxDetails.total_included_currency; - } else { - return taxDetails.total_excluded_currency; - } - } - - getPriceWithoutTax() { - return this.allPrices.priceWithoutTax; - } - - getPriceWithTax() { - return this.allPrices.priceWithTax; - } - - getTax() { - return this.allPrices.tax; - } - - getTaxDetails() { - return this.allPrices.taxDetails; - } - - getAllPrices(qty = this.getQuantity()) { - const company = this.company; - const product = this.getProduct(); - const taxes = this.tax_ids || product.taxes_id; - const baseLine = accountTaxHelpers.prepare_base_line_for_taxes_computation( - this, - this.prepareBaseLineForTaxesComputationExtraValues({ - quantity: qty, - tax_ids: taxes, - }) - ); - accountTaxHelpers.add_tax_details_in_base_line(baseLine, company); - accountTaxHelpers.round_base_lines_tax_details([baseLine], company); - - const baseLineNoDiscount = accountTaxHelpers.prepare_base_line_for_taxes_computation( - this, - this.prepareBaseLineForTaxesComputationExtraValues({ - quantity: qty, - tax_ids: taxes, - discount: 0.0, - }) - ); - accountTaxHelpers.add_tax_details_in_base_line(baseLineNoDiscount, company); - accountTaxHelpers.round_base_lines_tax_details([baseLineNoDiscount], company); - - // Tax details. - const taxDetails = {}; - for (const taxData of baseLine.tax_details.taxes_data) { - taxDetails[taxData.tax.id] = { - amount: taxData.tax_amount_currency, - base: taxData.base_amount_currency, - }; - } - - return { - priceWithTax: baseLine.tax_details.total_included_currency, - priceWithoutTax: baseLine.tax_details.total_excluded_currency, - priceWithTaxBeforeDiscount: baseLineNoDiscount.tax_details.total_included_currency, - priceWithoutTaxBeforeDiscount: baseLineNoDiscount.tax_details.total_excluded_currency, - tax: - baseLine.tax_details.total_included_currency - - baseLine.tax_details.total_excluded_currency, - taxDetails: taxDetails, - taxesData: baseLine.tax_details.taxes_data, - }; - } - - computePriceWithTaxBeforeDiscount() { - return this.combo_line_ids.length > 0 - ? // total of all combo lines if it is combo parent - formatCurrency( - this.combo_line_ids.reduce( - (total, cl) => total + cl.allPrices.priceWithTaxBeforeDiscount, - 0 - ), - this.currency - ) - : formatCurrency(this.allPrices.priceWithTaxBeforeDiscount, this.currency); - } - - get allPrices() { - return this.getAllPrices(); - } - - get allUnitPrices() { - return this.getAllPrices(1); - } - displayDiscountPolicy() { // Sales dropped `discount_policy`, and we only show discount if applied pricelist rule // is a percentage discount. However we don't have that information in pos @@ -600,16 +406,6 @@ export class PosOrderline extends Base { return "with_discount"; } - getlstPrice() { - return this.product_id.getPrice( - this.config.pricelist_id, - 1, - this.price_extra, - false, - this.product_id - ); - } - setCustomerNote(note) { this.customer_note = note || ""; } @@ -650,31 +446,6 @@ export class PosOrderline extends Base { return Boolean(this.combo_parent_id || this.combo_line_ids?.length); } - getComboTotalPrice() { - const allLines = this.getAllLinesInCombo(); - return allLines.reduce((total, line) => total + line.allUnitPrices.priceWithTax, 0); - } - getComboTotalPriceWithoutTax() { - const allLines = this.getAllLinesInCombo(); - return allLines.reduce((total, line) => total + line.getBasePrice() / line.qty, 0); - } - - getPriceString() { - return this.getDiscountStr() === "100" - ? // free if the discount is 100 - _t("Free") - : this.combo_line_ids.length > 0 - ? // total of all combo lines if it is combo parent - formatCurrency( - this.combo_line_ids.reduce((total, cl) => total + cl.getDisplayPrice(), 0), - this.currency - ) - : this.combo_parent_id - ? // empty string if it has combo parent - "" - : formatCurrency(this.getDisplayPrice(), this.currency); - } - get packLotLines() { return this.pack_lot_ids.map( (l) => @@ -684,20 +455,6 @@ export class PosOrderline extends Base { ); } - get taxGroupLabels() { - return [ - ...new Set( - getTaxesAfterFiscalPosition( - this.product_id.taxes_id, - this.order_id.fiscal_position_id, - this.models - ) - ?.map((tax) => tax.tax_group_id?.pos_receipt_label) - .filter((label) => label) - ), - ].join(" "); - } - getDiscount() { return this.discount || 0; } diff --git a/addons/point_of_sale/static/src/app/models/product_template.js b/addons/point_of_sale/static/src/app/models/product_template.js index 311e650a58e2f..779c1340a865e 100644 --- a/addons/point_of_sale/static/src/app/models/product_template.js +++ b/addons/point_of_sale/static/src/app/models/product_template.js @@ -1,10 +1,6 @@ import { registry } from "@web/core/registry"; -import { Base } from "./related_models"; -import { _t } from "@web/core/l10n/translation"; -import { roundPrecision } from "@web/core/utils/numbers"; import { markup } from "@odoo/owl"; -import { getTaxesAfterFiscalPosition, getTaxesValues } from "./utils/tax_utils"; -import { accountTaxHelpers } from "@account/helpers/account_tax"; +import { ProductTemplateAccounting } from "./accounting/product_template_accounting"; /** * ProductProduct, shadow of product.product in python. @@ -15,82 +11,9 @@ import { accountTaxHelpers } from "@account/helpers/account_tax"; * Models to load: product.product, uom.uom */ -export class ProductTemplate extends Base { +export class ProductTemplate extends ProductTemplateAccounting { static pythonModel = "product.template"; - prepareProductBaseLineForTaxesComputationExtraValues( - price, - pricelist = false, - fiscalPosition = false - ) { - const config = this.models["pos.config"].getFirst(); - const productTemplate = this instanceof ProductTemplate ? this : this.product_tmpl_id; - const basePrice = this?.lst_price || productTemplate.getPrice(pricelist, 1); - const priceUnit = price || price === 0 ? price : basePrice; - const currency = config.currency_id; - const extraValues = { currency_id: currency }; - - let taxes = this.taxes_id; - - // Fiscal position. - if (fiscalPosition) { - taxes = getTaxesAfterFiscalPosition(taxes, fiscalPosition, this.models); - } - - return { - ...extraValues, - product_id: this, - quantity: 1, - price_unit: priceUnit, - tax_ids: taxes, - }; - } - - getProductPrice(price = false, pricelist = false, fiscalPosition = false) { - const config = this.models["pos.config"].getFirst(); - const baseLine = accountTaxHelpers.prepare_base_line_for_taxes_computation( - {}, - this.prepareProductBaseLineForTaxesComputationExtraValues( - price, - pricelist, - fiscalPosition - ) - ); - accountTaxHelpers.add_tax_details_in_base_line(baseLine, config.company_id); - accountTaxHelpers.round_base_lines_tax_details([baseLine], config.company_id); - - if (config.iface_tax_included === "total") { - return baseLine.tax_details.total_included_currency; - } else { - return baseLine.tax_details.total_excluded_currency; - } - } - getProductPriceInfo(product, company, pricelist = false, fiscalPosition = false) { - if (!product) { - product = this.product_variant_ids[0]; - } - const price = this.getPrice(pricelist, 1, 0, false, product); - - const extraValues = this.prepareProductBaseLineForTaxesComputationExtraValues( - price, - pricelist, - fiscalPosition - ); - - // Taxes computation. - const taxesData = getTaxesValues( - extraValues.tax_ids, - extraValues.price_unit, - extraValues.quantity, - product, - extraValues.product_id, - company, - extraValues.currency_id - ); - - return taxesData; - } - isAllowOnlyOneLot() { return this.tracking === "lot" || !this.uom_id || !this.uom_id.is_pos_groupable; } @@ -157,112 +80,6 @@ export class ProductTemplate extends Base { return current; } - // Port of _get_product_price on product.pricelist. - // - // Anything related to UOM can be ignored, the POS will always use - // the default UOM set on the product and the user cannot change - // it. - // - // Pricelist items do not have to be sorted. All - // product.pricelist.item records are loaded with a search_read - // and were automatically sorted based on their _order by the - // ORM. After that they are added in this order to the pricelists. - getPrice( - pricelist, - quantity, - price_extra = 0, - recurring = false, - variant = false, - original_line = false, - related_lines = [] - ) { - // In case of nested pricelists, it is necessary that all pricelists are made available in - // the POS. Display a basic alert to the user in the case where there is a pricelist item - // but we can't load the base pricelist to get the price when calling this method again. - // As this method is also call without pricelist available in the POS, we can't just check - // the absence of pricelist. - if (recurring && !pricelist) { - alert( - _t( - "An error occurred when loading product prices. " + - "Make sure all pricelists are available in the POS." - ) - ); - } - - const product = variant; - const productTmpl = variant.product_tmpl_id || this; - const standardPrice = variant ? variant.standard_price : this.standard_price; - const basePrice = variant ? variant.lst_price : this.list_price; - let price = basePrice + (price_extra || 0); - - if (!pricelist) { - return price; - } - - if (original_line && original_line.isLotTracked()) { - related_lines.push( - ...original_line.order_id.lines.filter( - (line) => line.product_id.product_tmpl_id.id == this.id - ) - ); - quantity = related_lines.reduce((sum, line) => sum + line.getQuantity(), 0); - } - - const tmplRules = (productTmpl.backLink("<-product.pricelist.item.product_tmpl_id") || []) - .filter((rule) => rule.pricelist_id.id === pricelist.id && !rule.product_id) - .sort((a, b) => b.min_quantity - a.min_quantity); - const productRules = (product?.backLink?.("<-product.pricelist.item.product_id") || []) - .filter((rule) => rule.pricelist_id.id === pricelist.id) - .sort((a, b) => b.min_quantity - a.min_quantity); - - const tmplRulesSet = new Set(tmplRules.map((rule) => rule.id)); - const productRulesSet = new Set(productRules.map((rule) => rule.id)); - const generalRulesIds = pricelist.getGeneralRulesIdsByCategories(this.parentCategories); - const rules = this.models["product.pricelist.item"] - .readMany([...productRulesSet, ...tmplRulesSet, ...generalRulesIds]) - .filter((r) => r.min_quantity <= quantity); - - const rule = rules.length && rules[0]; - if (!rule) { - return price; - } - if (rule.base === "pricelist") { - if (rule.base_pricelist_id) { - price = this.getPrice(rule.base_pricelist_id, quantity, 0, true, variant); - } - } else if (rule.base === "standard_price") { - price = standardPrice; - } - - if (rule.compute_price === "fixed") { - price = rule.fixed_price; - } else if (rule.compute_price === "percentage") { - price = price - price * (rule.percent_price / 100); - } else { - var price_limit = price; - price -= price * (rule.price_discount / 100); - if (rule.price_round) { - price = roundPrecision(price, rule.price_round); - } - if (rule.price_surcharge) { - price += rule.price_surcharge; - } - if (rule.price_min_margin) { - price = Math.max(price, price_limit + rule.price_min_margin); - } - if (rule.price_max_margin) { - price = Math.min(price, price_limit + rule.price_max_margin); - } - } - - // This return value has to be rounded with round_di before - // being used further. Note that this cannot happen here, - // because it would cause inconsistencies with the backend for - // pricelist that have base == 'pricelist'. - return price; - } - getImageUrl() { return ( (this.image_128 && diff --git a/addons/point_of_sale/static/src/app/models/utils/tax_utils.js b/addons/point_of_sale/static/src/app/models/utils/tax_utils.js deleted file mode 100644 index 1a9e6720a83ee..0000000000000 --- a/addons/point_of_sale/static/src/app/models/utils/tax_utils.js +++ /dev/null @@ -1,86 +0,0 @@ -import { accountTaxHelpers } from "@account/helpers/account_tax"; - -/** - * This method will return a new price so that if you apply the taxes the price will remain the same - * For example if the original price is 50. It will compute a new price so that if you apply the tax_ids - * the price would still be 50. - */ -export const computePriceForcePriceInclude = ( - tax_ids, - price, - product, - product_default_values, - company, - currency, - models -) => { - const tax_res = getTaxesValues( - tax_ids, - price, - 1, - product, - product_default_values, - company, - currency, - "total_included" - ); - let new_price = tax_res.total_excluded; - new_price += tax_res.taxes_data - .filter((tax) => models["account.tax"].get(tax.id).price_include) - .reduce((sum, tax) => (sum += tax.tax_amount), 0); - return new_price; -}; - -export const getTaxesValues = ( - taxes, - priceUnit, - quantity, - product, - productDefaultValues, - company, - currency, - special_mode = null -) => { - const baseLine = accountTaxHelpers.prepare_base_line_for_taxes_computation( - {}, - { - product_id: product, - tax_ids: taxes, - price_unit: priceUnit, - quantity: quantity, - currency_id: currency, - special_mode: special_mode, - } - ); - accountTaxHelpers.add_tax_details_in_base_line(baseLine, company); - accountTaxHelpers.round_base_lines_tax_details([baseLine], company); - - const results = baseLine.tax_details; - for (const taxData of results.taxes_data) { - Object.assign(taxData, taxData.tax.raw); - } - return results; -}; - -export const getTaxesAfterFiscalPosition = (taxes, fiscalPosition, models) => { - if (!fiscalPosition) { - return taxes; - } - - if (fiscalPosition.tax_ids?.length == 0) { - return []; - } - - const newTaxIds = []; - for (const tax of taxes) { - if (fiscalPosition.tax_map[tax.id]) { - for (const mapTaxId of fiscalPosition.tax_map[tax.id]) { - newTaxIds.push(mapTaxId); - } - } else { - newTaxIds.push(tax.id); - } - } - - return models["account.tax"].filter((tax) => newTaxIds.includes(tax.id)); -}; diff --git a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js index d2a4866a63480..c3f7fa125c199 100644 --- a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js +++ b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js @@ -146,8 +146,8 @@ export class PaymentScreen extends Component { } // original function: click_paymentmethods const result = this.currentOrder.addPaymentline(paymentMethod); - if (result) { - this.numberBuffer.set(result.amount.toString()); + if (result.status) { + this.numberBuffer.set(result.data.amount.toString()); if ( paymentMethod.use_payment_terminal && !this.isRefundOrder && @@ -188,11 +188,11 @@ export class PaymentScreen extends Component { ); if ( !hasCashPaymentMethod && - amount > this.currentOrder.getDue() + this.selectedPaymentLine.amount + amount > this.currentOrder.remainingDue + this.selectedPaymentLine.amount ) { this.selectedPaymentLine.setAmount(0); - this.numberBuffer.set(this.currentOrder.getDue().toString()); - amount = this.currentOrder.getDue(); + this.numberBuffer.set(this.currentOrder.remainingDue.toString()); + amount = this.currentOrder.remainingDue; this.showMaxValueError(); } if ( @@ -223,7 +223,7 @@ export class PaymentScreen extends Component { } async addTip() { const tip = this.currentOrder.getTip(); - const change = this.currentOrder.getChange(); + const change = Math.abs(this.currentOrder.change); const value = tip === 0 && change > 0 ? change : tip; const newTip = await makeAwaitable(this.dialog, NumberPopup, { title: tip ? _t("Change Tip") : _t("Add Tip"), diff --git a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.xml b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.xml index b23cf21324746..c3d44e5be47b4 100644 --- a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.xml +++ b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.xml @@ -72,7 +72,7 @@ }">
    - +
    diff --git a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.js b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.js index fe571d8c17d78..24464c9ca88dd 100644 --- a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.js +++ b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.js @@ -1,5 +1,6 @@ import { Component } from "@odoo/owl"; import { PriceFormatter } from "@point_of_sale/app/components/price_formatter/price_formatter"; +import { _t } from "@web/core/l10n/translation"; export class PaymentScreenStatus extends Component { static template = "point_of_sale.PaymentScreenStatus"; @@ -8,14 +9,38 @@ export class PaymentScreenStatus extends Component { }; static components = { PriceFormatter }; - get changeText() { - return this.env.utils.formatCurrency(this.props.order.getChange()); + get isComplete() { + return this.isRemaining && this.order.orderHasZeroRemaining; } - get remainingText() { - const { order_remaining, order_sign } = this.props.order.taxTotals; - if (this.props.order.orderHasZeroRemaining) { - return this.env.utils.formatCurrency(0); + + get order() { + return this.props.order; + } + + get isRemaining() { + const isNegative = this.order.totalDue < 0; + const remainingDue = this.order.remainingDue; + + if ((isNegative && remainingDue > 0) || (!isNegative && remainingDue <= 0)) { + return false; + } else { + return true; + } + } + + get statusText() { + if (!this.isRemaining) { + return _t("Change"); + } else { + return _t("Remaining"); + } + } + + get amountText() { + if (!this.isRemaining) { + return this.env.utils.formatCurrency(this.order.change); + } else { + return this.env.utils.formatCurrency(this.order.remainingDue); } - return this.env.utils.formatCurrency(order_sign * order_remaining); } } diff --git a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.xml b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.xml index 82d020d0c5f0a..d52c672523daa 100644 --- a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.xml +++ b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_status/payment_status.xml @@ -5,18 +5,12 @@
    Please select a payment method
    -
    +
    -
    - Remaining - - - -
    -
    - Change - - +
    + + +
    diff --git a/addons/point_of_sale/static/src/app/screens/product_screen/order_summary/order_summary.js b/addons/point_of_sale/static/src/app/screens/product_screen/order_summary/order_summary.js index 702c6931e2217..d1fff9314b0ad 100644 --- a/addons/point_of_sale/static/src/app/screens/product_screen/order_summary/order_summary.js +++ b/addons/point_of_sale/static/src/app/screens/product_screen/order_summary/order_summary.js @@ -158,7 +158,7 @@ export class OrderSummary extends Component { } else if (this.pos.numpadMode === "discount") { buffer = selectedLine.getDiscount() * -1; } else if (this.pos.numpadMode === "price") { - buffer = selectedLine.getUnitPrice() * -1; + buffer = selectedLine.prices.total_excluded_currency * -1; } this.numberBuffer.state.buffer = buffer.toString(); } @@ -211,7 +211,7 @@ export class OrderSummary extends Component { ) { this.numberBuffer.reset(); const inputNumber = await makeAwaitable(this.dialog, NumberPopup, { - startingValue: selectedLine.getUnitPrice(), + startingValue: selectedLine.prices.total_excluded_currency, title: _t("Set the new price"), }); if (inputNumber) { @@ -305,7 +305,7 @@ export class OrderSummary extends Component { current_saved_quantity += line.uiState.savedQuantity; } else if ( line.product_id.id === selectedLine.product_id.id && - line.getUnitPrice() === selectedLine.getUnitPrice() + line.prices.total_excluded_currency === selectedLine.prices.total_excluded_currency ) { current_saved_quantity += line.qty; } @@ -328,7 +328,8 @@ export class OrderSummary extends Component { for (const line of selectedLine.order_id.lines) { if ( line.product_id.id === selectedLine.product_id.id && - line.getUnitPrice() === selectedLine.getUnitPrice() && + line.prices.total_excluded_currency === + selectedLine.prices.total_excluded_currency && line.getQuantity() * sign < 0 && line !== selectedLine ) { diff --git a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js index 22c73d6e5aeb0..fd30fcaf0df9b 100644 --- a/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js +++ b/addons/point_of_sale/static/src/app/screens/product_screen/product_screen.js @@ -194,7 +194,7 @@ export class ProductScreen extends Component { return this.pos.getOrder(); } get total() { - return this.env.utils.formatCurrency(this.currentOrder?.getTotalWithTax() ?? 0); + return this.currentOrder?.currencyDisplayPrice || 0; } get items() { return this.env.utils.formatProductQty( @@ -321,10 +321,6 @@ export class ProductScreen extends Component { this.pos.switchPane(); } - getProductPrice(product) { - return this.pos.getProductPrice(product, false, true); - } - getProductImage(product) { return product.getImageUrl(); } diff --git a/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt/order_receipt.xml b/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt/order_receipt.xml index 08cf15909985c..745095c1b1d97 100644 --- a/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt/order_receipt.xml +++ b/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt/order_receipt.xml @@ -15,12 +15,12 @@ - +
    Subtotal - +
    @@ -42,16 +42,16 @@
    Total - +
    - +
    Rounding - +
    To Pay - +
    @@ -63,7 +63,7 @@
    Change - +
    diff --git a/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt_screen.js b/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt_screen.js index e8ed59dc414ef..789b8889f2ff3 100644 --- a/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt_screen.js +++ b/addons/point_of_sale/static/src/app/screens/receipt_screen/receipt_screen.js @@ -51,12 +51,12 @@ export class ReceiptScreen extends Component { } get orderAmountPlusTip() { const order = this.currentOrder; - const orderTotalAmount = order.getTotalWithTax(); + const orderTotalAmount = order.priceIncl; const tip_product_id = this.pos.config.tip_product_id?.id; const tipLine = order .getOrderlines() .find((line) => tip_product_id && line.product_id.id === tip_product_id); - const tipAmount = tipLine ? tipLine.allPrices.priceWithTax : 0; + const tipAmount = tipLine ? tipLine.prices.total_included : 0; const orderAmountStr = this.env.utils.formatCurrency(orderTotalAmount - tipAmount); if (!tipAmount) { return orderAmountStr; diff --git a/addons/point_of_sale/static/src/app/screens/ticket_screen/ticket_screen.js b/addons/point_of_sale/static/src/app/screens/ticket_screen/ticket_screen.js index aff00f2a53c8d..b29f36ae23428 100644 --- a/addons/point_of_sale/static/src/app/screens/ticket_screen/ticket_screen.js +++ b/addons/point_of_sale/static/src/app/screens/ticket_screen/ticket_screen.js @@ -463,7 +463,7 @@ export class TicketScreen extends Component { return this.pos.getDate(order.date_order); } getTotal(order) { - return this.env.utils.formatCurrency(order.getTotalWithTax()); + return this.env.utils.formatCurrency(order.priceIncl); } getPartner(order) { return order.getPartnerName(); diff --git a/addons/point_of_sale/static/src/app/services/pos_store.js b/addons/point_of_sale/static/src/app/services/pos_store.js index 9cd5577380601..97945edc8d73f 100644 --- a/addons/point_of_sale/static/src/app/services/pos_store.js +++ b/addons/point_of_sale/static/src/app/services/pos_store.js @@ -293,24 +293,6 @@ export class PosStore extends WithLazyGetterTrap { this._storeConnectedCashier(user); } - getProductPrice(product, price = false, formatted = false) { - const order = this.getOrder(); - const fiscalPosition = order.fiscal_position_id || this.config.fiscal_position_id; - const pricelist = order.pricelist_id || this.config.pricelist_id; - const pPrice = product.getProductPrice(price, pricelist, fiscalPosition); - - if (formatted) { - const formattedPrice = this.env.utils.formatCurrency(pPrice); - if (product.to_weight) { - return `${formattedPrice}/${product.uom_id.name}`; - } else { - return formattedPrice; - } - } - - return pPrice; - } - _getConnectedCashier() { const cashier_id = Number(sessionStorage.getItem(`connected_cashier_${this.config.id}`)); if (cashier_id && this.models["res.users"].get(cashier_id)) { @@ -530,7 +512,7 @@ export class PosStore extends WithLazyGetterTrap { body: _t( "%s has a total amount of %s, are you sure you want to delete this order?", order.pos_reference, - this.env.utils.formatCurrency(order.getTotalWithTax()) + this.env.utils.formatCurrency(order.priceIncl) ), }); if (!confirmed) { @@ -818,6 +800,7 @@ export class PosStore extends WithLazyGetterTrap { if (typeof vals.product_tmpl_id == "number") { vals.product_tmpl_id = this.data.models["product.template"].get(vals.product_tmpl_id); } + const productTemplate = vals.product_tmpl_id; const values = { price_type: "price_unit" in vals ? "manual" : "original", @@ -902,7 +885,7 @@ export class PosStore extends WithLazyGetterTrap { this.scale.setProduct( values.product_id, decimalAccuracy, - this.getProductPrice(values.product_id) + values.product_id.getTaxDetails().total_included ); const weight = await this.weighProduct(); if (weight) { @@ -962,9 +945,6 @@ export class PosStore extends WithLazyGetterTrap { }); } - // FIXME: Put this in an effect so that we don't have to call it manually. - order.recomputeOrderData(); - if (configure) { this.numberBuffer.reset(); } @@ -1270,8 +1250,6 @@ export class PosStore extends WithLazyGetterTrap { this.selectPreset(this.config.default_preset_id, order); } - order.recomputeOrderData(); - return order; } addNewOrder(data = {}) { @@ -1404,8 +1382,14 @@ export class PosStore extends WithLazyGetterTrap { }; } - // There for override - async preSyncAllOrders(orders) {} + async preSyncAllOrders(orders) { + // Prices are computed on the fly on the pos.order and pos.order.line model + // we need to set them before sending the orders to the backend + for (const order of orders) { + order.setOrderPrices(); + } + } + postSyncAllOrders(orders) {} async syncAllOrders(options = {}) { const { orderToCreate, orderToUpdate } = this.getPendingOrder(); @@ -1435,11 +1419,6 @@ export class PosStore extends WithLazyGetterTrap { // Add order IDs to the syncing set orders.forEach((order) => this.syncingOrders.add(order.uuid)); - // Re-compute all taxes, prices and other information needed for the backend - for (const order of orders) { - order.recomputeOrderData(); - } - const serializedOrder = orders.map((order) => order.serializeForORM()); const data = await this.data.call("pos.order", "sync_from_ui", [serializedOrder], { context, @@ -1579,14 +1558,14 @@ export class PosStore extends WithLazyGetterTrap { const priceWithoutTax = productInfo["all_prices"]["price_without_tax"]; const margin = priceWithoutTax - productTemplate.standard_price; - const orderPriceWithoutTax = order.getTotalWithoutTax(); + const orderPriceWithoutTax = order.priceExcl; const orderCost = order.getTotalCost(); const orderMargin = orderPriceWithoutTax - orderCost; const orderTaxTotalCurrency = this.env.utils.formatCurrency( - order.taxTotals.order_sign * order.taxTotals.tax_amount_currency + order.prices.taxDetails.order_sign * order.prices.taxDetails.tax_amount_currency ); const orderPriceWithTaxCurrency = this.env.utils.formatCurrency( - order.taxTotals.order_sign * order.taxTotals.total_amount_currency + order.prices.taxDetails.order_sign * order.prices.taxDetails.total_amount_currency ); const taxAmount = this.env.utils.formatCurrency( productInfo.all_prices.tax_details[0]?.amount || 0 @@ -2649,19 +2628,9 @@ export class PosStore extends WithLazyGetterTrap { return this.sortByWordIndex(matches, words); } - getPaymentMethodFmtAmount(pm, order) { - const { cash_rounding, only_round_cash_method } = this.config; const amount = order.getDefaultAmountDueToPayIn(pm); - const fmtAmount = this.env.utils.formatCurrency(amount, true); - if ( - this.currency.isPositive(amount) && - cash_rounding && - !only_round_cash_method && - pm.type === "cash" - ) { - return fmtAmount; - } + return this.env.utils.formatCurrency(amount, true); } getDate(date) { const todayTs = DateTime.now().startOf("day").ts; diff --git a/addons/point_of_sale/static/src/app/utils/numbers.js b/addons/point_of_sale/static/src/app/utils/numbers.js index 32e8c5c52fb23..9e6a2e2877f2d 100644 --- a/addons/point_of_sale/static/src/app/utils/numbers.js +++ b/addons/point_of_sale/static/src/app/utils/numbers.js @@ -70,12 +70,6 @@ export class AbstractNumbers extends Base { return roundPrecision(a, this.precision, this.method); } - /** - * ``` - * asymmetricRound(1.23, { precision: 0.1, method: "UP" }) // 1.3 - * asymmetricRound(-1.23, { precision: 0.1, method: "UP" }) // -1.2 - * ``` - */ asymmetricRound(a) { return roundPrecision( a, diff --git a/addons/point_of_sale/static/src/app/utils/order_payment_validation.js b/addons/point_of_sale/static/src/app/utils/order_payment_validation.js index a714bd56119a1..b225423f195d4 100644 --- a/addons/point_of_sale/static/src/app/utils/order_payment_validation.js +++ b/addons/point_of_sale/static/src/app/utils/order_payment_validation.js @@ -141,7 +141,7 @@ export default class OrderPaymentValidation { } async finalizeValidation() { - if (this.order.isPaidWithCash() || this.order.getChange()) { + if (this.order.isPaidWithCash() || this.order.change) { this.pos.hardwareProxy.openCashbox(); } @@ -239,11 +239,12 @@ export default class OrderPaymentValidation { } checkCashRoundingHasBeenWellApplied() { - const cashRounding = this.pos.config.rounding_method; - if (!cashRounding) { + const useRound = this.pos.config.hasCashRounding; + if (!useRound) { return true; } + const cashRounding = this.pos.config.rounding_method; const order = this.pos.getOrder(); const currency = this.pos.currency; for (const payment of order.payment_ids) { @@ -329,7 +330,7 @@ export default class OrderPaymentValidation { } if ( - !this.pos.currency.isZero(this.order.getTotalWithTax()) && + !this.pos.currency.isZero(this.order.priceIncl) && this.order.payment_ids.length === 0 ) { this.pos.notification.add(_t("Select a payment method to validate the order.")); @@ -342,11 +343,8 @@ export default class OrderPaymentValidation { // The exact amount must be paid if there is no cash payment method defined. if ( - Math.abs( - this.order.getTotalWithTax() - - this.order.getTotalPaid() + - this.order.getRoundingApplied() - ) > 0.00001 + Math.abs(this.order.priceIncl - this.order.amountPaid + this.order.appliedRounding) > + 0.00001 ) { if (!this.pos.models["pos.payment.method"].some((pm) => pm.is_cash_count)) { this.pos.dialog.add(AlertDialog, { @@ -362,19 +360,19 @@ export default class OrderPaymentValidation { // if the change is too large, it's probably an input error, make the user confirm. if ( !isForceValidate && - this.order.getTotalWithTax() > 0 && - this.order.getTotalWithTax() * 1000 < this.order.getTotalPaid() + this.order.priceIncl > 0 && + this.order.priceIncl * 1000 < this.order.amountPaid ) { this.pos.dialog.add(ConfirmationDialog, { title: _t("Please Confirm Large Amount"), body: _t("Are you sure that the customer wants to pay") + " " + - this.pos.env.utils.formatCurrency(this.order.getTotalPaid()) + + this.pos.env.utils.formatCurrency(this.order.amountPaid) + " " + _t("for an order of") + " " + - this.pos.env.utils.formatCurrency(this.order.getTotalWithTax()) + + this.pos.env.utils.formatCurrency(this.order.priceIncl) + " " + _t('? Clicking "Confirm" will validate the payment.'), confirm: () => this.validateOrder(true), diff --git a/addons/point_of_sale/static/tests/pos/tours/acceptance_tour.js b/addons/point_of_sale/static/tests/pos/tours/acceptance_tour.js index f0eb20351ae49..a32ec7aa3d8b1 100644 --- a/addons/point_of_sale/static/tests/pos/tours/acceptance_tour.js +++ b/addons/point_of_sale/static/tests/pos/tours/acceptance_tour.js @@ -5,31 +5,6 @@ import { inLeftSide, waitForLoading } from "@point_of_sale/../tests/pos/tours/ut import * as ProductScreen from "@point_of_sale/../tests/pos/tours/utils/product_screen_util"; import * as PaymentScreen from "@point_of_sale/../tests/pos/tours/utils/payment_screen_util"; import * as Chrome from "@point_of_sale/../tests/pos/tours/utils/chrome_util"; -import * as OfflineUtil from "@point_of_sale/../tests/generic_helpers/offline_util"; - -registry.category("web_tour.tours").add("pos_basic_order_01_multi_payment_and_change", { - steps: () => - [ - waitForLoading(), - Chrome.startPoS(), - OfflineUtil.setOfflineMode(), - ProductScreen.clickDisplayedProduct("Desk Organizer", true, "1", "5.10"), - ProductScreen.clickDisplayedProduct("Desk Organizer", true, "2", "10.20"), - ProductScreen.clickPayButton(), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.enterPaymentLineAmount("Cash", "5", true, { - amount: "5.0", - remaining: "5.20", - }), - PaymentScreen.clickPaymentMethod("Bank", true, { amount: "5.2" }), - PaymentScreen.enterPaymentLineAmount("Bank", "6", true, { - amount: "6.0", - change: "0.80", - }), - OfflineUtil.setOnlineMode(), - ProductScreen.finishOrder(), - ].flat(), -}); registry.category("web_tour.tours").add("pos_basic_order_02_decimal_order_quantity", { steps: () => diff --git a/addons/point_of_sale/static/tests/pos/tours/payment_screen_tour.js b/addons/point_of_sale/static/tests/pos/tours/payment_screen_tour.js index 4aa633ef1d6f2..e713bcaa5c569 100644 --- a/addons/point_of_sale/static/tests/pos/tours/payment_screen_tour.js +++ b/addons/point_of_sale/static/tests/pos/tours/payment_screen_tour.js @@ -145,50 +145,6 @@ registry.category("web_tour.tours").add("PaymentScreenRoundingDown", { ].flat(), }); -registry.category("web_tour.tours").add("PaymentScreenRoundingHalfUp", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - ProductScreen.addOrderline("Product Test 1.20", "1"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("1.20"), - PaymentScreen.clickPaymentMethod("Cash", true, { remaining: "0.0", amount: "1.00" }), - - Chrome.clickOrders(), - Chrome.createFloatingOrder(), - - ProductScreen.addOrderline("Product Test 1.25", "1"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("1.25"), - PaymentScreen.clickPaymentMethod("Cash", true, { remaining: "0.0", amount: "1.50" }), - - Chrome.clickOrders(), - Chrome.createFloatingOrder(), - - ProductScreen.addOrderline("Product Test 1.4", "1"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("1.4"), - PaymentScreen.clickPaymentMethod("Cash", true, { remaining: "0.0", amount: "1.50" }), - - Chrome.clickOrders(), - Chrome.createFloatingOrder(), - - ProductScreen.addOrderline("Product Test 1.20", "1"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("1.20"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.clickNumpad("2"), - PaymentScreen.fillPaymentLineAmountMobile("Cash", "2"), - - PaymentScreen.changeIs("1.0"), - ].flat(), -}); - registry.category("web_tour.tours").add("PaymentScreenTotalDueWithOverPayment", { steps: () => [ @@ -199,7 +155,7 @@ registry.category("web_tour.tours").add("PaymentScreenTotalDueWithOverPayment", PaymentScreen.totalIs("1.98"), PaymentScreen.clickPaymentMethod("Cash"), PaymentScreen.enterPaymentLineAmount("Cash", "5", true, { - change: "3.05", + change: "3", }), ].flat(), }); @@ -221,23 +177,6 @@ registry.category("web_tour.tours").add("InvoiceShipLaterAccessRight", { ].flat(), }); -registry.category("web_tour.tours").add("CashRoundingPayment", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - ProductScreen.addOrderline("Magnetic Board", "1"), - ProductScreen.clickPayButton(), - - // Pay it with exact amount but with incorrect rounding so there should be an error popup. - PaymentScreen.totalIs("1.98"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.enterPaymentLineAmount("Cash", "1.98"), - PaymentScreen.clickValidate(), - Dialog.is(), - ].flat(), -}); - registry.category("web_tour.tours").add("PaymentScreenInvoiceOrder", { steps: () => [ diff --git a/addons/point_of_sale/static/tests/pos/tours/pos_cash_rounding_tour.js b/addons/point_of_sale/static/tests/pos/tours/pos_cash_rounding_tour.js index f3b8158820cf0..64b0972c3ce87 100644 --- a/addons/point_of_sale/static/tests/pos/tours/pos_cash_rounding_tour.js +++ b/addons/point_of_sale/static/tests/pos/tours/pos_cash_rounding_tour.js @@ -6,443 +6,6 @@ import * as ReceiptScreen from "@point_of_sale/../tests/pos/tours/utils/receipt_ import * as TicketScreen from "@point_of_sale/../tests/pos/tours/utils/ticket_screen_util"; import { registry } from "@web/core/registry"; -registry - .category("web_tour.tours") - .add("test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.02"), - ReceiptScreen.receiptToPayAmountIs("15.70"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIs("0.02"), - ReceiptScreen.receiptToPayAmountIs("-15.70"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - }); - -registry - .category("web_tour.tours") - .add( - "test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method_pay_by_bank_and_cash", - { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 8"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "0.68"), - PaymentScreen.remainingIs("15.04"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("0.01"), - ReceiptScreen.receiptToPayAmountIs("15.73"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 8 +/-"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "-0.68"), - PaymentScreen.remainingIs("-15.04"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.01"), - ReceiptScreen.receiptToPayAmountIs("-15.73"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - } - ); - -registry - .category("web_tour.tours") - .add("test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_no_rounding_left", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 7"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "0.67"), - PaymentScreen.remainingIs("15.05"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIsNotThere(), - ReceiptScreen.receiptToPayAmountIsNotThere(), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 7 +/-"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "-0.67"), - PaymentScreen.remainingIs("-15.05"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIsNotThere(), - ReceiptScreen.receiptToPayAmountIsNotThere(), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - }); - -registry - .category("web_tour.tours") - .add( - "test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_with_residual_rounding", - { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 8"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "0.68"), - PaymentScreen.remainingIs("15.04"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.04"), - ReceiptScreen.receiptToPayAmountIs("15.68"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 8 +/-"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "-0.68"), - PaymentScreen.remainingIs("-15.04"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIs("0.04"), - ReceiptScreen.receiptToPayAmountIs("-15.68"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - } - ); - -registry - .category("web_tour.tours") - .add("test_cash_rounding_up_add_invoice_line_not_only_round_cash_method", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 4"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "0.64"), - PaymentScreen.remainingIs("15.08"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("0.02"), - ReceiptScreen.receiptToPayAmountIs("15.74"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 4 +/-"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "-0.64"), - PaymentScreen.remainingIs("-15.08"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.02"), - ReceiptScreen.receiptToPayAmountIs("-15.74"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - }); - -registry - .category("web_tour.tours") - .add("test_cash_rounding_halfup_add_invoice_line_only_round_cash_method", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkRoundingAmountIsNotThere(), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.02"), - ReceiptScreen.receiptToPayAmountIs("15.70"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkRoundingAmountIsNotThere(), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIs("0.02"), - ReceiptScreen.receiptToPayAmountIs("-15.70"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - }); - -registry - .category("web_tour.tours") - .add("test_cash_rounding_halfup_add_invoice_line_only_round_cash_method_pay_by_bank_and_cash", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - // Order. - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkRoundingAmountIsNotThere(), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 8"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "0.68"), - PaymentScreen.remainingIs("15.04"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("0.01"), - ReceiptScreen.receiptToPayAmountIs("15.73"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - - // Refund. - Chrome.clickOrders(), - TicketScreen.selectFilter("Active"), - TicketScreen.selectFilter("Paid"), - TicketScreen.selectOrder("0001"), - TicketScreen.confirmRefund(), - - PaymentScreen.isShown(), - PaymentScreen.clickBack(), - - ProductScreen.checkTaxAmount("-2.05"), - ProductScreen.checkRoundingAmountIsNotThere(), - ProductScreen.checkTotalAmount("-15.72"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("-15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("0 . 6 8 +/-"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "-0.68"), - PaymentScreen.remainingIs("-15.04"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.remainingIs("0.0"), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("-15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.01"), - ReceiptScreen.receiptToPayAmountIs("-15.73"), - ReceiptScreen.receiptChangeAmountIsNotThere(), - ReceiptScreen.clickNextOrder(), - ].flat(), - }); - registry .category("web_tour.tours") .add("test_cash_rounding_halfup_biggest_tax_not_only_round_cash_method", { @@ -684,79 +247,3 @@ registry ReceiptScreen.clickNextOrder(), ].flat(), }); - -registry.category("web_tour.tours").add("test_cash_rounding_with_change", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickNumpad("2 0"), - PaymentScreen.fillPaymentLineAmountMobile("Bank", "20.00"), - PaymentScreen.changeIs("4.30"), - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.02"), - ReceiptScreen.receiptToPayAmountIs("15.70"), - ReceiptScreen.receiptChangeAmountIs("4.30"), - ReceiptScreen.clickNextOrder(), - ].flat(), -}); - -registry.category("web_tour.tours").add("test_cash_rounding_up_with_change", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - ProductScreen.addOrderline("product_a", "1"), - ProductScreen.addOrderline("product_b", "2"), - ProductScreen.clickPayButton(), - PaymentScreen.totalIs("179"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.clickNumpad("2 0 0"), - - PaymentScreen.changeIs("21"), - ].flat(), -}); - -registry.category("web_tour.tours").add("test_cash_rounding_only_cash_method_with_change", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - - ProductScreen.addOrderline("random_product", "1"), - ProductScreen.checkTaxAmount("2.05"), - ProductScreen.checkTotalAmount("15.72"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AAAAAA"), - ProductScreen.clickPayButton(), - - PaymentScreen.totalIs("15.72"), - PaymentScreen.clickPaymentMethod("Cash"), - PaymentScreen.clickNumpad("2 0"), - PaymentScreen.fillPaymentLineAmountMobile("Cash", "20.00"), - PaymentScreen.changeIs("4.30"), - - PaymentScreen.clickInvoiceButton(), - PaymentScreen.clickValidate(), - - ReceiptScreen.receiptAmountTotalIs("15.72"), - ReceiptScreen.receiptRoundingAmountIs("-0.02"), - ReceiptScreen.receiptToPayAmountIs("15.70"), - ReceiptScreen.receiptChangeAmountIs("4.30"), - ReceiptScreen.clickNextOrder(), - ].flat(), -}); diff --git a/addons/point_of_sale/static/tests/pos/tours/product_screen_tour.js b/addons/point_of_sale/static/tests/pos/tours/product_screen_tour.js index f68788d2550f6..f7bef4f28aea6 100644 --- a/addons/point_of_sale/static/tests/pos/tours/product_screen_tour.js +++ b/addons/point_of_sale/static/tests/pos/tours/product_screen_tour.js @@ -352,7 +352,7 @@ registry.category("web_tour.tours").add("ShowTaxExcludedTour", { Dialog.confirm("Open Register"), ProductScreen.clickDisplayedProduct("Test Product", true, "1", "100.0"), - ProductScreen.totalAmountIs("110.0"), + ProductScreen.totalAmountIs("100.0"), // Order total is also displayed excluding tax Chrome.endTour(), ].flat(), }); diff --git a/addons/point_of_sale/static/tests/pos/tours/utils/payment_screen_util.js b/addons/point_of_sale/static/tests/pos/tours/utils/payment_screen_util.js index 00c55fba19b6d..11769c53c618c 100644 --- a/addons/point_of_sale/static/tests/pos/tours/utils/payment_screen_util.js +++ b/addons/point_of_sale/static/tests/pos/tours/utils/payment_screen_util.js @@ -249,7 +249,7 @@ export function changeIs(amount) { return [ { content: `change is ${amount}`, - trigger: `.payment-status-change .amount:contains("${amount}")`, + trigger: `.payment-status-amount .amount:contains("${amount}")`, }, ]; } @@ -269,7 +269,7 @@ export function remainingIs(amount) { return [ { content: `remaining amount is ${amount}`, - trigger: `.payment-status-remaining .amount:contains("${amount}")`, + trigger: `.payment-status-amount .amount:contains("${amount}")`, }, ]; } diff --git a/addons/point_of_sale/static/tests/pos/tours/utils/receipt_screen_util.js b/addons/point_of_sale/static/tests/pos/tours/utils/receipt_screen_util.js index 5de3586bf94df..91fc8fc357007 100644 --- a/addons/point_of_sale/static/tests/pos/tours/utils/receipt_screen_util.js +++ b/addons/point_of_sale/static/tests/pos/tours/utils/receipt_screen_util.js @@ -104,19 +104,6 @@ export function paymentLineContains(paymentMethodName, amount) { }, ]; } -export function receiptRoundingAmountIsNotThere() { - return [ - { - isActive: ["desktop"], // not rendered on mobile - trigger: ".receipt-screen", - run: function () { - if (document.querySelector(".receipt-rounding")) { - throw new Error("A rounding amount has been found in receipt."); - } - }, - }, - ]; -} export function receiptToPayAmountIs(value) { return [ { diff --git a/addons/point_of_sale/static/tests/unit/accounting/discount.test.js b/addons/point_of_sale/static/tests/unit/accounting/discount.test.js new file mode 100644 index 0000000000000..85db30fdd8e10 --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/accounting/discount.test.js @@ -0,0 +1,44 @@ +import { test, expect } from "@odoo/hoot"; +import { expectFormattedPrice, setupPosEnv } from "../utils"; +import { definePosModels } from "../data/generate_model_definitions"; +import { getFilledOrderForPriceCheck } from "./utils"; + +definePosModels(); + +test("Taxes object should contain no discount values", async () => { + const store = await setupPosEnv(); + const order = await getFilledOrderForPriceCheck(store); + order.lines[0].setDiscount(10); + order.lines[1].setDiscount(20); + + const details = order.prices.taxDetails; + const line1 = order.lines[0].prices; + const line2 = order.lines[1].prices; + + // Order prices + expect(details.base_amount).toBe(980); // Base amount is 980 = (1000 - 10%) + (100 - 20%) + expect(details.tax_amount).toBe(257); // Tax amount is 257 = (250 - 10%) + (15 - 20%) + (25 - 20%) + expect(details.total_amount).toBe(1237); // Total amount is 1237 = 980 + 257 + + // Formatted prices + expectFormattedPrice(order.currencyDisplayPrice, "$ 1,237.00"); + expectFormattedPrice(order.currencyAmountTaxes, "$ 257.00"); + expectFormattedPrice(order.lines[0].currencyDisplayPrice, "$ 1,125.00"); + expectFormattedPrice(order.lines[0].currencyDisplayPriceUnit, "$ 900.00"); + expectFormattedPrice(order.lines[1].currencyDisplayPrice, "$ 112.00"); + expectFormattedPrice(order.lines[1].currencyDisplayPriceUnit, "$ 80.00"); + + // First line (25% on 1000) - no discount + expect(line1.no_discount_total_included).toBe(1250); + expect(line1.no_discount_total_excluded).toBe(1000); + expect(line1.no_discount_taxes_data[0].tax_amount).toBe(250); + expect(line1.no_discount_taxes_data[0].tax.amount).toBe(25); + + // Second line (15% + 25% on 100) - no discount + expect(line2.no_discount_total_included).toBe(140); + expect(line2.no_discount_total_excluded).toBe(100); + expect(line2.no_discount_taxes_data[0].tax_amount).toBe(15); + expect(line2.no_discount_taxes_data[0].tax.amount).toBe(15); + expect(line2.no_discount_taxes_data[1].tax_amount).toBe(25); + expect(line2.no_discount_taxes_data[1].tax.amount).toBe(25); +}); diff --git a/addons/point_of_sale/static/tests/unit/accounting/old_tour.test.js b/addons/point_of_sale/static/tests/unit/accounting/old_tour.test.js new file mode 100644 index 0000000000000..2cbf517606f0f --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/accounting/old_tour.test.js @@ -0,0 +1,363 @@ +/** + * This file contains old tour tests related to accounting that were migrated to Hoot. + * These tours were not checking anything on the Python side, so they were simply + * converted to Hoot tests without any additional checks. + */ + +import { test, expect } from "@odoo/hoot"; +import { setupPosEnv } from "../utils"; +import { definePosModels } from "../data/generate_model_definitions"; +import { prepareRoundingVals } from "./utils"; + +definePosModels(); + +test("[Old Tour] pos_basic_order_01_multi_payment_and_change", async () => { + const store = await setupPosEnv(); + const product1 = store.models["product.template"].get(15); + product1.list_price = 5.1; + product1.product_variant_ids[0].lst_price = 5.1; + product1.taxes_id = []; + + const cashPm = store.models["pos.payment.method"].find((pm) => pm.is_cash_count); + const cardPm = store.models["pos.payment.method"].find((pm) => !pm.is_cash_count); + const order = store.addNewOrder(); + order.pricelist_id = false; + + // Add products + await store.addLineToOrder({ product_tmpl_id: product1, qty: 2 }, order); + order.addPaymentline(cashPm); + order.payment_ids[0].setAmount(5); + expect(order.remainingDue).toBe(5.2); + + order.addPaymentline(cardPm); + order.payment_ids[1].setAmount(6); + expect(order.change).toBe(-0.8); +}); + +test("[Old Tour] PaymentScreenRoundingHalfUp", async () => { + const store = await setupPosEnv(); + const product1_2 = store.models["product.template"].get(12); + const product1_25 = store.models["product.template"].get(13); + const product1_40 = store.models["product.template"].get(14); + const { cashPm } = prepareRoundingVals(store, 0.5, "HALF-UP", true); + + product1_2.list_price = 1.2; + product1_2.product_variant_ids[0].lst_price = 1.2; + product1_2.taxes_id = []; + product1_25.list_price = 1.25; + product1_25.product_variant_ids[0].lst_price = 1.25; + product1_25.taxes_id = []; + product1_40.list_price = 1.4; + product1_40.product_variant_ids[0].lst_price = 1.4; + product1_40.taxes_id = []; + + const order = store.addNewOrder(); + order.pricelist_id = false; + await store.addLineToOrder({ product_tmpl_id: product1_2, qty: 1 }, order); + expect(order.totalDue).toBe(1.2); + order.addPaymentline(cashPm); + expect(order.amountPaid).toBe(1.0); + expect(order.appliedRounding).toBe(-0.2); + expect(order.change).toBe(0.0); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + await store.addLineToOrder({ product_tmpl_id: product1_25, qty: 1 }, order2); + expect(order2.totalDue).toBe(1.25); + order2.addPaymentline(cashPm); + expect(order2.amountPaid).toBe(1.5); + expect(order2.appliedRounding).toBe(0.25); + expect(order2.change).toBe(0.0); + + const order3 = store.addNewOrder(); + order3.pricelist_id = false; + await store.addLineToOrder({ product_tmpl_id: product1_40, qty: 1 }, order3); + expect(order3.totalDue).toBe(1.4); + order3.addPaymentline(cashPm); + expect(order3.amountPaid).toBe(1.5); + expect(order3.appliedRounding).toBe(0.1); + expect(order3.change).toBe(0.0); + + const order4 = store.addNewOrder(); + order4.pricelist_id = false; + await store.addLineToOrder({ product_tmpl_id: product1_2, qty: 1 }, order4); + expect(order4.totalDue).toBe(1.2); + order4.addPaymentline(cashPm); + order4.payment_ids[0].setAmount(2); + expect(order4.amountPaid).toBe(2.0); + expect(order4.change).toBe(-1.0); +}); + +const prepareProduct = (store) => { + const product = store.models["product.template"].get(15); + const tax15 = store.models["account.tax"].get(1); + product.list_price = 13.67; + product.product_variant_ids[0].lst_price = 13.67; + product.taxes_id = [tax15]; + return product; +}; + +test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method", async () => { + const store = await setupPosEnv(); + const { cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.7); + order.addPaymentline(cardPm); + expect(order.amountPaid).toBe(15.7); + expect(order.appliedRounding).toBe(-0.02); + expect(order.change).toBe(0.0); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.7); + order2.addPaymentline(cardPm); + expect(order2.amountPaid).toBe(-15.7); + expect(order2.appliedRounding).toBe(0.02); + expect(order2.change).toBe(0.0); +}); + +test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method_pay_by_bank_and_cash", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.7); + order.addPaymentline(cardPm); + order.payment_ids[0].setAmount(0.68); + expect(order.amountPaid).toBe(0.68); + expect(order.remainingDue).toBe(15.02); // Order is rounded globaly so remaining due is rounded + order.addPaymentline(cashPm); + expect(order.payment_ids[1].amount).toBe(15.02); + expect(order.amountPaid).toBe(15.7); + expect(order.appliedRounding).toBe(-0.02); + expect(order.change).toBe(0.0); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order2.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.7); + order2.addPaymentline(cardPm); + order2.payment_ids[0].setAmount(-0.68); + expect(order2.amountPaid).toBe(-0.68); + expect(order2.remainingDue).toBe(-15.02); // Order is rounded globaly so remaining due is rounded + order2.addPaymentline(cashPm); + expect(order2.payment_ids[1].amount).toBe(-15.02); + expect(order2.amountPaid).toBe(-15.7); + expect(order2.appliedRounding).toBe(0.02); + expect(order2.change).toBe(0.0); +}); + +test("[Old Tour] test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_no_rounding_left", async () => { + const store = await setupPosEnv(); + const { cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.7); + order.addPaymentline(cardPm); + order.payment_ids[0].setAmount(0.67); + expect(order.amountPaid).toBe(0.67); + expect(order.remainingDue).toBe(15.03); + order.addPaymentline(cardPm); + expect(order.payment_ids[1].amount).toBe(15.03); + expect(order.amountPaid).toBe(15.7); + expect(order.appliedRounding).toBe(-0.02); + expect(order.change).toBe(0.0); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order2.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.7); + order2.addPaymentline(cardPm); + order2.payment_ids[0].setAmount(-0.67); + expect(order2.amountPaid).toBe(-0.67); + expect(order2.remainingDue).toBe(-15.03); + order2.addPaymentline(cardPm); + expect(order2.payment_ids[1].amount).toBe(-15.03); + expect(order2.amountPaid).toBe(-15.7); + expect(order2.appliedRounding).toBe(0.02); + expect(order2.change).toBe(0.0); +}); + +test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_only_round_cash_method", async () => { + const store = await setupPosEnv(); + const { cashPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.72); + order.addPaymentline(cashPm); + expect(order.amountPaid).toBe(15.7); + expect(order.appliedRounding).toBe(-0.02); + expect(order.change).toBe(0.0); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order2.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.72); + order2.addPaymentline(cashPm); + expect(order2.amountPaid).toBe(-15.7); + expect(order2.appliedRounding).toBe(0.02); + expect(order2.change).toBe(0.0); +}); + +test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_only_round_cash_method_pay_by_bank_and_cash", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.72); + order.addPaymentline(cardPm); + order.payment_ids[0].setAmount(0.68); + expect(order.amountPaid).toBe(0.68); + expect(order.remainingDue).toBe(15.04); + order.addPaymentline(cashPm); + expect(order.payment_ids[1].amount).toBe(15.05); + expect(order.amountPaid).toBe(15.73); + expect(order.appliedRounding).toBe(0.01); + expect(order.change).toBe(0.0); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order2.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.72); + order2.addPaymentline(cardPm); + order2.payment_ids[0].setAmount(-0.68); + expect(order2.amountPaid).toBe(-0.68); + expect(order2.remainingDue).toBe(-15.04); + order2.addPaymentline(cashPm); + expect(order2.payment_ids[1].amount).toBe(-15.05); + expect(order2.amountPaid).toBe(-15.73); + expect(order2.appliedRounding).toBe(-0.01); + expect(order2.change).toBe(0.0); +}); + +test("[Old Tour] test_cash_rounding_with_change", async () => { + const store = await setupPosEnv(); + const { cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.7); + order.addPaymentline(cardPm); + order.payment_ids[0].setAmount(20); + expect(order.amountPaid).toBe(20); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(-4.3); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order2.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.7); + order2.addPaymentline(cardPm); + order2.payment_ids[0].setAmount(-20); + expect(order2.amountPaid).toBe(-20); + expect(order2.appliedRounding).toBe(0); + expect(order2.change).toBe(4.3); +}); + +test("[Old Tour] test_cash_rounding_only_cash_method_with_change", async () => { + const store = await setupPosEnv(); + const { cashPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true); + const product = prepareProduct(store); + const order = store.addNewOrder(); + order.pricelist_id = false; + + await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order); + expect(order.displayPrice).toBe(15.72); + expect(order.priceExcl).toBe(13.67); + expect(order.totalDue).toBe(15.72); + order.addPaymentline(cashPm); + order.payment_ids[0].setAmount(20); + expect(order.amountPaid).toBe(20); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(-4.3); + + const order2 = store.addNewOrder(); + order2.pricelist_id = false; + order2.is_refund = true; + await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2); + expect(order2.displayPrice).toBe(-15.72); + expect(order2.priceExcl).toBe(-13.67); + expect(order2.totalDue).toBe(-15.72); + order2.addPaymentline(cashPm); + order2.payment_ids[0].setAmount(-20); + expect(order2.amountPaid).toBe(-20); + expect(order2.appliedRounding).toBe(0); + expect(order2.change).toBe(4.3); +}); + +test(["[Old Tour] test_cash_rounding_up_with_change"], async () => { + const store = await setupPosEnv(); + const { cashPm } = prepareRoundingVals(store, 1, "UP", true); + const order = store.addNewOrder(); + order.pricelist_id = false; + + const tax = store.models["account.tax"].get(3); + const productA = store.models["product.template"].get(15); + const productB = store.models["product.template"].get(16); + productA.list_price = 95; + productA.product_variant_ids[0].lst_price = 95; + productA.taxes_id = [tax]; + productB.list_price = 42; + productB.product_variant_ids[0].lst_price = 42; + productB.taxes_id = [tax]; + + await store.addLineToOrder({ product_tmpl_id: productA, qty: 1 }, order); + await store.addLineToOrder({ product_tmpl_id: productB, qty: 2 }, order); + + expect(order.displayPrice).toBe(179); + expect(order.totalDue).toBe(179); + order.addPaymentline(cashPm); + order.payment_ids[0].setAmount(200); + expect(order.amountPaid).toBe(200); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(-21); +}); diff --git a/addons/point_of_sale/static/tests/unit/accounting/price.test.js b/addons/point_of_sale/static/tests/unit/accounting/price.test.js new file mode 100644 index 0000000000000..6c23ed5cfdf2f --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/accounting/price.test.js @@ -0,0 +1,54 @@ +import { test, expect } from "@odoo/hoot"; +import { expectFormattedPrice, setupPosEnv } from "../utils"; +import { definePosModels } from "../data/generate_model_definitions"; +import { getFilledOrderForPriceCheck } from "./utils"; + +definePosModels(); + +test("Prices includes", async () => { + const store = await setupPosEnv(); + const order = await getFilledOrderForPriceCheck(store); + const details = order.prices.taxDetails; + const line1 = order.lines[0].prices; + const line2 = order.lines[1].prices; + + // Order prices + expect(details.base_amount).toBe(1100); + expect(details.tax_amount).toBe(290); + expect(details.total_amount).toBe(1390); + + // First line (25% on 1000) + expect(line1.total_included).toBe(1250); + expect(line1.total_excluded).toBe(1000); + expect(line1.taxes_data[0].tax_amount).toBe(250); + expect(line1.taxes_data[0].tax.amount).toBe(25); + + // Second line (15% + 25% on 100) + expect(line2.total_included).toBe(140); + expect(line2.total_excluded).toBe(100); + expect(line2.taxes_data[0].tax_amount).toBe(15); + expect(line2.taxes_data[0].tax.amount).toBe(15); + expect(line2.taxes_data[1].tax_amount).toBe(25); + expect(line2.taxes_data[1].tax.amount).toBe(25); + + // Formatted prices + expectFormattedPrice(order.currencyDisplayPrice, "$ 1,390.00"); + expectFormattedPrice(order.currencyAmountTaxes, "$ 290.00"); + expectFormattedPrice(order.lines[0].currencyDisplayPrice, "$ 1,250.00"); + expectFormattedPrice(order.lines[0].currencyDisplayPriceUnit, "$ 1,000.00"); + expectFormattedPrice(order.lines[1].currencyDisplayPrice, "$ 140.00"); + expectFormattedPrice(order.lines[1].currencyDisplayPriceUnit, "$ 100.00"); +}); + +test("Prices excludes", async () => { + const store = await setupPosEnv(); + store.config.iface_tax_included = "subtotal"; + const order = await getFilledOrderForPriceCheck(store); + + // Formatted prices + expectFormattedPrice(order.currencyDisplayPrice, "$ 1,100.00"); + expectFormattedPrice(order.lines[0].currencyDisplayPrice, "$ 1,000.00"); + expectFormattedPrice(order.lines[0].currencyDisplayPriceUnit, "$ 1,000.00"); + expectFormattedPrice(order.lines[1].currencyDisplayPrice, "$ 100.00"); + expectFormattedPrice(order.lines[1].currencyDisplayPriceUnit, "$ 100.00"); +}); diff --git a/addons/point_of_sale/static/tests/unit/accounting/rounding.test.js b/addons/point_of_sale/static/tests/unit/accounting/rounding.test.js new file mode 100644 index 0000000000000..8c5586e9f854b --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/accounting/rounding.test.js @@ -0,0 +1,328 @@ +import { test, expect } from "@odoo/hoot"; +import { setupPosEnv } from "../utils"; +import { definePosModels } from "../data/generate_model_definitions"; +import { getFilledOrderForPriceCheck, prepareRoundingVals } from "./utils"; + +definePosModels(); + +test("Rounding sale HALF-UP 0.05 (cash only)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(52.54); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(52.55); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.01); + expect(order.change).toBe(0); +}); + +test("Rounding sale HALF-UP 0.05 (all methods)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(52.55); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.01); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(52.55); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.01); + expect(order.change).toBe(0); +}); + +test("Rounding sale UP 10 (cash only)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", true); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(52.54); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(60); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(7.46); + expect(order.change).toBe(0); +}); + +test("Rounding sale UP 10 (all methods)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", false); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(60); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(7.46); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(60); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(7.46); + expect(order.change).toBe(0); +}); + +test("Rounding sale DOWN 1 (cash only)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", true); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(52.54); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(52); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-0.54); + expect(order.change).toBe(0); +}); + +test("Rounding sale DOWN 1 (all methods)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", false); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(52); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-0.54); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(52); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-0.54); + expect(order.change).toBe(0); +}); + +test("Rounding refund HALF-UP 0.05 (cash only)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true); + const order = await getFilledOrderForPriceCheck(store); + + order.is_refund = true; + order.lines.map((line) => line.setQuantity(-line.qty)); + + expect(order.displayPrice).toBe(-52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(-52.54); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(-52.55); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-0.01); + expect(order.change).toBe(0); +}); + +test("Rounding refund HALF-UP 0.05 (all methods)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const order = await getFilledOrderForPriceCheck(store); + + order.is_refund = true; + order.lines.map((line) => line.setQuantity(-line.qty)); + + expect(order.displayPrice).toBe(-52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(-52.55); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-0.01); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(-52.55); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-0.01); + expect(order.change).toBe(0); +}); + +test("Rounding refund UP 10 (cash only)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", true); + const order = await getFilledOrderForPriceCheck(store); + + order.is_refund = true; + order.lines.map((line) => line.setQuantity(-line.qty)); + + expect(order.displayPrice).toBe(-52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(-52.54); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(-60); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-7.46); + expect(order.change).toBe(0); +}); + +test("Rounding refund UP 10 (all methods)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", false); + const order = await getFilledOrderForPriceCheck(store); + + order.is_refund = true; + order.lines.map((line) => line.setQuantity(-line.qty)); + + expect(order.displayPrice).toBe(-52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(-60); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-7.46); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(-60); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(-7.46); + expect(order.change).toBe(0); +}); + +test("Rounding refund DOWN 1 (cash only)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", true); + const order = await getFilledOrderForPriceCheck(store); + + order.is_refund = true; + order.lines.map((line) => line.setQuantity(-line.qty)); + + expect(order.displayPrice).toBe(-52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(-52.54); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(-52); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.54); + expect(order.change).toBe(0); +}); + +test("Rounding refund DOWN 1 (all methods)", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", false); + const order = await getFilledOrderForPriceCheck(store); + + order.is_refund = true; + order.lines.map((line) => line.setQuantity(-line.qty)); + + expect(order.displayPrice).toBe(-52.54); + + order.addPaymentline(cardPm); + expect(order.payment_ids[0].amount).toBe(-52); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.54); + expect(order.change).toBe(0); + order.payment_ids[0].delete(); + expect(order.canBeValidated()).toBe(false); + + order.addPaymentline(cashPm); + expect(order.payment_ids[0].amount).toBe(-52); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.54); + expect(order.change).toBe(0); +}); + +test("Rouding sale HALF-UP 0.05 with two payment method", async () => { + const store = await setupPosEnv(); + const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false); + const order = await getFilledOrderForPriceCheck(store); + + expect(order.displayPrice).toBe(52.54); + + // only_round_cash_method is false so the order due is 52.55 + order.addPaymentline(cardPm); + order.payment_ids[0].setAmount(2.54); + expect(order.payment_ids[0].amount).toBe(2.54); + expect(order.canBeValidated()).toBe(false); + expect(order.remainingDue).toBe(50.01); + order.addPaymentline(cashPm); + + // Cash rounding is not applied on the cash payment line but on the order due + expect(order.payment_ids[1].amount).toBe(50.01); + expect(order.remainingDue).toBe(0); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0.01); + expect(order.change).toBe(0); + + // Set only_round_cash_method to true and check that the order due is now 52.54 + order.config_id.only_round_cash_method = true; + order.payment_ids = []; + order.addPaymentline(cardPm); + order.payment_ids[0].setAmount(2.54); + expect(order.payment_ids[0].amount).toBe(2.54); + expect(order.canBeValidated()).toBe(false); + expect(order.remainingDue).toBe(50); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); + order.addPaymentline(cashPm); + expect(order.payment_ids[1].amount).toBe(50); + expect(order.remainingDue).toBe(0); + expect(order.canBeValidated()).toBe(true); + expect(order.appliedRounding).toBe(0); + expect(order.change).toBe(0); +}); diff --git a/addons/point_of_sale/static/tests/unit/accounting/utils.js b/addons/point_of_sale/static/tests/unit/accounting/utils.js new file mode 100644 index 0000000000000..0e95252b41b01 --- /dev/null +++ b/addons/point_of_sale/static/tests/unit/accounting/utils.js @@ -0,0 +1,66 @@ +const { DateTime } = luxon; + +/** + * We use a dedicated method for the price check because we don't want to use + * getFilledOrder in case of modification of this method breaks the price test. + * + * This method is a copy of getFilledOrder from utils.js + */ +export const getFilledOrderForPriceCheck = async (store, data = {}) => { + const order = store.addNewOrder(data); + // This product1 has a 25% tax with a 100.0 price + // This product2 has a 15% + 25% tax with a 1000.0 price + const product1 = store.models["product.template"].get(15); + const product2 = store.models["product.template"].get(16); + + const date = DateTime.now(); + order.write_date = date; + order.create_date = date; + order.pricelist_id = false; + + await store.addLineToOrder( + { + product_tmpl_id: product1, + qty: 1, + write_date: date, + create_date: date, + }, + order + ); + await store.addLineToOrder( + { + product_tmpl_id: product2, + qty: 1, + write_date: date, + create_date: date, + }, + order + ); + store.addPendingOrder([order.id]); + return order; +}; + +export const prepareRoundingVals = (store, roundingAmount, roundingMethod, onlyCash = true) => { + const config = store.config; + const product1 = store.models["product.template"].get(15); + const product2 = store.models["product.template"].get(16); + const cashPm = store.models["pos.payment.method"].find((pm) => pm.is_cash_count); + const cardPm = store.models["pos.payment.method"].find((pm) => !pm.is_cash_count); + + // Changes prices to have a non rounded change + product1.list_price = 15.73; + product2.list_price = 23.49; + product1.product_variant_ids[0].lst_price = 15.73; + product2.product_variant_ids[0].lst_price = 23.49; + + config.cash_rounding = true; + config.only_round_cash_method = onlyCash; + config.rounding_method = store.models["account.cash.rounding"].create({ + name: "roudning", + rounding: roundingAmount, + rounding_method: roundingMethod, + strategy: "add_invoice_line", + }); + + return { config, cashPm, cardPm }; +}; diff --git a/addons/point_of_sale/static/tests/unit/components/orderline.test.js b/addons/point_of_sale/static/tests/unit/components/orderline.test.js index a9b10645a5aaa..f13e2892b18f3 100644 --- a/addons/point_of_sale/static/tests/unit/components/orderline.test.js +++ b/addons/point_of_sale/static/tests/unit/components/orderline.test.js @@ -1,7 +1,7 @@ import { test, expect } from "@odoo/hoot"; import { mountWithCleanup } from "@web/../tests/web_test_helpers"; import { Orderline } from "@point_of_sale/app/components/orderline/orderline"; -import { setupPosEnv } from "../utils"; +import { expectFormattedPrice, setupPosEnv } from "../utils"; import { definePosModels } from "../data/generate_model_definitions"; definePosModels(); @@ -22,11 +22,10 @@ test("orderline.js", async () => { const comp = await mountWithCleanup(Orderline, { props: { line }, }); - + const lineData = comp.lineScreenValues; expect(comp.line.id).toEqual(line.id); - expect(comp.taxGroup).toBeEmpty(); - expect(comp.formatCurrency(comp.line.price_subtotal_incl)).toBe("$\u00a010.35"); - expect(comp.getInternalNotes()).toEqual([ + expectFormattedPrice(comp.line.currencyDisplayPrice, "$ 10.35"); + expect(lineData.internalNote).toEqual([ { text: "Test 1", colorIndex: 0, diff --git a/addons/point_of_sale/static/tests/unit/components/product_screen.test.js b/addons/point_of_sale/static/tests/unit/components/product_screen.test.js index 36b6f7d740da4..5c0733fa94c64 100644 --- a/addons/point_of_sale/static/tests/unit/components/product_screen.test.js +++ b/addons/point_of_sale/static/tests/unit/components/product_screen.test.js @@ -13,7 +13,7 @@ test("_getProductByBarcode", async () => { const comp = await mountWithCleanup(ProductScreen, { props: { orderUuid: order.uuid } }); await comp.addProductToOrder(store.models["product.template"].get(5)); - expect(order.amount_total).toBe(3.45); + expect(order.displayPrice).toBe(3.45); expect(comp.total).toBe("$\u00a03.45"); expect(comp.items).toBe("1"); @@ -31,7 +31,7 @@ test("fastValidate", async () => { }); await productScreen.addProductToOrder(store.models["product.template"].get(5)); - expect(order.amount_total).toBe(3.45); + expect(order.displayPrice).toBe(3.45); expect(productScreen.total).toBe("$\u00a03.45"); expect(productScreen.items).toBe("1"); diff --git a/addons/point_of_sale/static/tests/unit/data/account_tax.data.js b/addons/point_of_sale/static/tests/unit/data/account_tax.data.js index a218623531238..f9989c2868d9c 100644 --- a/addons/point_of_sale/static/tests/unit/data/account_tax.data.js +++ b/addons/point_of_sale/static/tests/unit/data/account_tax.data.js @@ -48,7 +48,20 @@ export class AccountTax extends models.ServerModel { amount: 25.0, company_id: 250, sequence: 1, - tax_group_id: 1, + tax_group_id: 3, + }, + { + id: 3, + name: "tax incl", + type_tax_use: "sale", + amount_type: "percent", + amount: 7, + price_include_override: "tax_included", + include_base_amount: true, + has_negative_factor: true, + company_id: 250, + is_base_affected: true, + tax_group_id: 4, }, ]; } diff --git a/addons/point_of_sale/static/tests/unit/data/account_tax_group.data.js b/addons/point_of_sale/static/tests/unit/data/account_tax_group.data.js index d17dfd496f72d..923614cc47556 100644 --- a/addons/point_of_sale/static/tests/unit/data/account_tax_group.data.js +++ b/addons/point_of_sale/static/tests/unit/data/account_tax_group.data.js @@ -18,5 +18,15 @@ export class AccountTaxGroup extends models.ServerModel { name: "Tax 0%", pos_receipt_label: false, }, + { + id: 3, + name: "Tax 25%", + pos_receipt_label: false, + }, + { + id: 4, + name: "No group", + pos_receipt_label: false, + }, ]; } diff --git a/addons/point_of_sale/static/tests/unit/data/product_product.data.js b/addons/point_of_sale/static/tests/unit/data/product_product.data.js index 177997fbf8d31..b9d23b89c7156 100644 --- a/addons/point_of_sale/static/tests/unit/data/product_product.data.js +++ b/addons/point_of_sale/static/tests/unit/data/product_product.data.js @@ -154,5 +154,29 @@ export class ProductProduct extends models.ServerModel { product_template_attribute_value_ids: [], product_template_variant_value_ids: [], }, + { + id: 15, + product_tmpl_id: 15, + lst_price: 1000, + standard_price: 0, + display_name: "Accounting Test Product 1", + product_tag_ids: [], + barcode: false, + default_code: false, + product_template_attribute_value_ids: [], + product_template_variant_value_ids: [], + }, + { + id: 16, + product_tmpl_id: 16, + lst_price: 100, + standard_price: 0, + display_name: "Accounting Test Product 2", + product_tag_ids: [], + barcode: false, + default_code: false, + product_template_attribute_value_ids: [], + product_template_variant_value_ids: [], + }, ]; } diff --git a/addons/point_of_sale/static/tests/unit/data/product_template.data.js b/addons/point_of_sale/static/tests/unit/data/product_template.data.js index c95920e8f970e..3b702f1d7ac6e 100644 --- a/addons/point_of_sale/static/tests/unit/data/product_template.data.js +++ b/addons/point_of_sale/static/tests/unit/data/product_template.data.js @@ -369,7 +369,7 @@ export class ProductTemplate extends models.ServerModel { attribute_line_ids: [], active: true, image_128: false, - product_variant_ids: [12], + product_variant_ids: [13], public_description: false, pos_optional_product_ids: [], sequence: 1, @@ -402,7 +402,73 @@ export class ProductTemplate extends models.ServerModel { attribute_line_ids: [], active: true, image_128: false, - product_variant_ids: [12], + product_variant_ids: [14], + public_description: false, + pos_optional_product_ids: [], + sequence: 1, + product_tag_ids: [], + }, + { + id: 15, + display_name: "Accounting Test Product 1", + standard_price: 0, + categ_id: false, + pos_categ_ids: [], + taxes_id: [2], + barcode: false, + name: "Accounting Test Product 1", + list_price: 1000, + is_favorite: false, + default_code: false, + to_weight: false, + uom_id: 1, + description_sale: false, + description: false, + tracking: "none", + type: "consu", + service_tracking: "no", + is_storable: false, + write_date: "2025-07-03 13:04:14", + color: 0, + pos_sequence: 5, + available_in_pos: true, + attribute_line_ids: [], + active: true, + image_128: false, + product_variant_ids: [15], + public_description: false, + pos_optional_product_ids: [], + sequence: 1, + product_tag_ids: [], + }, + { + id: 16, + display_name: "Accounting Test Product 2", + standard_price: 0, + categ_id: false, + pos_categ_ids: [], + taxes_id: [1, 2], + barcode: false, + name: "Accounting Test Product 2", + list_price: 100, + is_favorite: false, + default_code: false, + to_weight: false, + uom_id: 1, + description_sale: false, + description: false, + tracking: "none", + type: "consu", + service_tracking: "no", + is_storable: false, + write_date: "2025-07-03 13:04:14", + color: 0, + pos_sequence: 5, + available_in_pos: true, + attribute_line_ids: [], + active: true, + image_128: false, + product_variant_ids: [16], public_description: false, pos_optional_product_ids: [], sequence: 1, diff --git a/addons/point_of_sale/static/tests/unit/models/pos_category.test.js b/addons/point_of_sale/static/tests/unit/models/pos_category.test.js index 833c7e8eaf2aa..dbc0cb5c87141 100644 --- a/addons/point_of_sale/static/tests/unit/models/pos_category.test.js +++ b/addons/point_of_sale/static/tests/unit/models/pos_category.test.js @@ -8,11 +8,7 @@ test("getAllChildren", async () => { const store = await setupPosEnv(); const category = store.models["pos.category"].get(3); const children = category.getAllChildren(); - expect(children).toEqual([ - store.models["pos.category"].get(3), - store.models["pos.category"].get(4), - store.models["pos.category"].get(5), - ]); + expect(children.map((c) => c.id).sort()).toEqual([3, 4, 5]); }); test("get allParents", async () => { @@ -26,9 +22,5 @@ test("get associatedProducts", async () => { const store = await setupPosEnv(); const category = store.models["pos.category"].get(3); const associatedProducts = category.associatedProducts; - expect(associatedProducts).toEqual([ - store.models["product.template"].get(14), - store.models["product.template"].get(12), - store.models["product.template"].get(13), - ]); + expect(associatedProducts.map((p) => p.id).sort()).toEqual([12, 13, 14]); }); diff --git a/addons/point_of_sale/static/tests/unit/models/pos_order.test.js b/addons/point_of_sale/static/tests/unit/models/pos_order.test.js index 2e68526adce56..facda0f18b898 100644 --- a/addons/point_of_sale/static/tests/unit/models/pos_order.test.js +++ b/addons/point_of_sale/static/tests/unit/models/pos_order.test.js @@ -50,49 +50,6 @@ test("setPreset", async () => { expect(order.fiscal_position_id).toBe(inPreset.fiscal_position_id); }); -test("getTaxTotalsOfLines", async () => { - const store = await setupPosEnv(); - const order = store.addNewOrder(); - const product = store.models["product.template"].get(5); - const product2 = store.models["product.template"].get(6); - - await store.addLineToOrder( - { - product_tmpl_id: product, - qty: 1, - }, - order - ); - await store.addLineToOrder( - { - product_tmpl_id: product2, - qty: 1, - }, - order - ); - - // With pricelist prices are at 3 each - const taxTotalsWPricelist = order.getTaxTotalsOfLines(order.lines); - expect(taxTotalsWPricelist.base_amount).toBe(6); - expect(taxTotalsWPricelist.total_amount).toBe(7.2); - expect(taxTotalsWPricelist.tax_amount_currency).toBe(1.2); - expect(taxTotalsWPricelist.subtotals[0].tax_groups[0].involved_tax_ids).toEqual([ - product.taxes_id[0].id, - product2.taxes_id[0].id, - ]); - - // Without pricelist prices are at 100 each - order.setPricelist(null); - const taxTotals = order.getTaxTotalsOfLines(order.lines); - expect(taxTotals.base_amount).toBe(200); - expect(taxTotals.total_amount).toBe(240); // Tax of 15% and 25% on 100 each - expect(taxTotals.tax_amount_currency).toBe(40); - expect(taxTotals.subtotals[0].tax_groups[0].involved_tax_ids).toEqual([ - product.taxes_id[0].id, - product2.taxes_id[0].id, - ]); -}); - test("updateLastOrderChange", async () => { const store = await setupPosEnv(); const order = await getFilledOrder(store); @@ -124,8 +81,8 @@ test("addPaymentline", async () => { const cashPaymentMethod = store.models["pos.payment.method"].get(1); // Test that the payment line is correctly created const result = order.addPaymentline(cashPaymentMethod); - expect(result.payment_method_id.id).toBe(cashPaymentMethod.id); - expect(result.amount).toBe(17.85); + expect(result.data.payment_method_id.id).toBe(cashPaymentMethod.id); + expect(result.data.amount).toBe(17.85); }); test("getTotalDiscount", async () => { @@ -133,7 +90,7 @@ test("getTotalDiscount", async () => { const order = await getFilledOrder(store); const discount = order.getTotalDiscount(); expect(discount).toBe(0); - const taxTotals = order.getTaxTotalsOfLines(order.lines); + const taxTotals = order.prices.taxDetails; expect(taxTotals.base_amount).toBe(15); expect(taxTotals.total_amount).toBe(17.85); expect(taxTotals.tax_amount_currency).toBe(2.85); @@ -144,7 +101,7 @@ test("getTotalDiscount", async () => { line1.setDiscount(20); line2.setDiscount(50); expect(order.getTotalDiscount()).toBe(5.82); - const taxTotalsWDiscount = order.getTaxTotalsOfLines(order.lines); + const taxTotalsWDiscount = order.prices.taxDetails; expect(taxTotalsWDiscount.base_amount).toBe(10.2); expect(taxTotalsWDiscount.total_amount).toBe(12.03); expect(taxTotalsWDiscount.tax_amount_currency).toBe(1.83); @@ -268,3 +225,48 @@ test("setShippingDate and getShippingDate with Luxon", async () => { order.setShippingDate(null); expect(order.getShippingDate()).toBeEmpty(); }); + +test("[get prices] check prices and taxes", async () => { + const store = await setupPosEnv(); + const order = await getFilledOrder(store); + const data = order.prices; + + // Check taxes on order base_amount is 15 with 15% taxes + const orderTaxes = data.taxDetails; + expect(orderTaxes.base_amount).toBe(15.0); + expect(orderTaxes.total_amount).toBe(17.85); + expect(orderTaxes.tax_amount).toBe(2.85); + + // Order prices data also return the prices of all lines + // Check first line with a price_unit of 3 and 3 qty + const line1Data = data.baseLineByLineUuids[order.lines[0].uuid].tax_details; + expect(line1Data.total_excluded).toBe(9.0); + expect(line1Data.total_included).toBe(10.35); + expect(line1Data.taxes_data[0].tax_amount).toBe(1.35); + + // Check second line with a price_unit of 3 and 2 qty + const line2Data = data.baseLineByLineUuids[order.lines[1].uuid].tax_details; + expect(line2Data.total_excluded).toBe(6.0); + expect(line2Data.total_included).toBe(7.5); + expect(line2Data.taxes_data[0].tax_amount).toBe(1.5); + + // Check with a discount on first line of 30% + order.lines[0].setDiscount(30); + const dataWDiscount = order.prices; + const orderTaxesWDiscount = dataWDiscount.taxDetails; + expect(orderTaxesWDiscount.base_amount).toBe(12.3); + expect(orderTaxesWDiscount.total_amount).toBe(14.75); + expect(orderTaxesWDiscount.tax_amount).toBe(2.45); + + // Check first line with a price_unit of 3, 3 qty and 30% discount + const line1DataWDiscount = dataWDiscount.baseLineByLineUuids[order.lines[0].uuid].tax_details; + expect(line1DataWDiscount.total_excluded).toBe(6.3); + expect(line1DataWDiscount.total_included).toBe(7.25); + expect(line1DataWDiscount.taxes_data[0].tax_amount).toBe(0.95); + expect(line1DataWDiscount.discount_amount).toBe(3.1); + + // No discount values should still represent the line without discount + expect(line1DataWDiscount.no_discount_total_excluded).toBe(9.0); + expect(line1DataWDiscount.no_discount_total_included).toBe(10.35); + expect(line1DataWDiscount.no_discount_taxes_data[0].tax_amount).toBe(1.35); +}); diff --git a/addons/point_of_sale/static/tests/unit/models/pos_order_line.test.js b/addons/point_of_sale/static/tests/unit/models/pos_order_line.test.js index 77a27847b96c1..63a276c8e788f 100644 --- a/addons/point_of_sale/static/tests/unit/models/pos_order_line.test.js +++ b/addons/point_of_sale/static/tests/unit/models/pos_order_line.test.js @@ -10,6 +10,7 @@ function getAllPricesData(otherData = {}) { { id: 1, name: "Test Order", + lines: [1], }, ], "pos.order.line": [ @@ -26,82 +27,85 @@ function getAllPricesData(otherData = {}) { }; } -test("[getAllPrices()] Base test", async () => { +test("[get prices()] Base test", async () => { const store = await setupPosEnv(); const models = store.models; const data = models.loadConnectedData(getAllPricesData()); - const lineTax = data["pos.order.line"][0].getAllPrices(); - expect(lineTax.priceWithTax).toBe(230.0); - expect(lineTax.priceWithoutTax).toBe(200.0); - expect(lineTax.taxesData[0].tax).toEqual(models["account.tax"].getFirst()); - expect(lineTax.taxDetails[1].base).toBe(200.0); - expect(lineTax.taxDetails[1].amount).toBe(30.0); + const lineTax = data["pos.order.line"][0].prices; + expect(lineTax.total_included).toBe(230.0); + expect(lineTax.total_excluded).toBe(200.0); + expect(lineTax.taxes_data[0].base_amount).toBe(200.0); + expect(lineTax.taxes_data[0].tax_amount).toBe(30.0); // Test with line qty = 0 data["pos.order.line"][0].qty = 0; - const zeroQtyLineTax = data["pos.order.line"][0].getAllPrices(); - expect(zeroQtyLineTax.priceWithTax).toBe(0.0); - expect(zeroQtyLineTax.priceWithoutTax).toBe(0.0); - expect(zeroQtyLineTax.tax).toBe(0.0); - expect(Object.keys(zeroQtyLineTax.taxDetails).length).toBe(1); + const zeroQtyLineTax = data["pos.order.line"][0].prices; + expect(zeroQtyLineTax.total_included).toBe(0.0); + expect(zeroQtyLineTax.total_excluded).toBe(0.0); + expect(zeroQtyLineTax.taxes_data[0].base_amount).toBe(0.0); + expect(zeroQtyLineTax.taxes_data[0].tax_amount).toBe(0.0); }); -test("[getAllPrices()] with discount applied", async () => { +test("[get prices()] with discount applied", async () => { const store = await setupPosEnv(); const models = store.models; const data = models.loadConnectedData(getAllPricesData()); const orderLine = data["pos.order.line"][0]; - orderLine.setDiscount(10.0); // 10% discount - const lineTax = orderLine.getAllPrices(); + // Prices with a discount of 10% applied: 230 * 0.9 = 207.0 - expect(lineTax.priceWithTax).toBe(207.0); - expect(lineTax.priceWithoutTax).toBe(180.0); - expect(lineTax.taxDetails[1].amount).toBe(27.0); + orderLine.setDiscount(10.0); // 10% discount + const lineTax = orderLine.prices; + expect(lineTax.total_included).toBe(207.0); + expect(lineTax.total_excluded).toBe(180.0); + expect(lineTax.taxes_data[0].tax_amount).toBe(27.0); + // Price with a discount of 100% applied orderLine.setDiscount(100.0); - const updatedLineTax = orderLine.getAllPrices(); - expect(updatedLineTax.priceWithoutTax).toBe(0.0); - expect(updatedLineTax.priceWithTax).toBe(0.0); - expect(updatedLineTax.tax).toBe(0.0); - expect(updatedLineTax.taxDetails[1].amount).toBe(0.0); + const updatedLineTax = orderLine.prices; + expect(updatedLineTax.total_excluded).toBe(0.0); + expect(updatedLineTax.total_included).toBe(0.0); + expect(updatedLineTax.taxes_data[0].tax_amount).toBe(0.0); }); -test("[getAllPrices()] with multiple taxes settings", async () => { +test("[get prices()] with multiple taxes settings", async () => { const store = await setupPosEnv(); const models = store.models; - const data = models.loadConnectedData(getAllPricesData()); + const rawData = getAllPricesData(); + const product = models["product.product"].get(5); + product.taxes_id = models["account.tax"].readMany([1, 2]); // Set two taxes on the product + rawData["pos.order.line"][0].qty = 1; + rawData["pos.order.line"][0].tax_ids = [1, 2]; + + const data = models.loadConnectedData(rawData); const orderLine = data["pos.order.line"][0]; + // Test with two taxes applied (15% and 25%) - orderLine.tax_ids = [1, 2]; - orderLine.qty = 1; - const lineTax = orderLine.getAllPrices(); - expect(lineTax.priceWithoutTax).toBe(100.0); - expect(lineTax.priceWithTax).toBe(140.0); - expect(lineTax.tax).toBe(40.0); - expect(lineTax.taxDetails[1].amount).toBe(15.0); - expect(lineTax.taxDetails[2].amount).toBe(25.0); + const lineTax = orderLine.prices; + expect(lineTax.total_excluded).toBe(100.0); + expect(lineTax.total_included).toBe(140.0); + expect(lineTax.taxes_data[0].tax_amount).toBe(15.0); + expect(lineTax.taxes_data[1].tax_amount).toBe(25.0); // Test with "include_base_amount" and "include_base_amount" to true for both taxes models["account.tax"].get(1).include_base_amount = true; models["account.tax"].get(2).include_base_amount = true; - const updatedLineTax = data["pos.order.line"][0].getAllPrices(); - expect(updatedLineTax.priceWithoutTax).toBe(100.0); - expect(updatedLineTax.priceWithTax).toBe(143.75); - expect(updatedLineTax.tax).toBe(43.75); - expect(updatedLineTax.taxDetails[1].amount).toBe(15.0); - expect(updatedLineTax.taxDetails[2].amount).toBe(28.75); + const updatedLineTax = data["pos.order.line"][0].prices; + expect(updatedLineTax.total_excluded).toBe(100.0); + expect(updatedLineTax.total_included).toBe(143.75); + expect(updatedLineTax.taxes_data[0].tax_amount).toBe(15.0); + expect(updatedLineTax.taxes_data[1].tax_amount).toBe(28.75); // Test without any taxes - orderLine.tax_ids = []; - const noTaxLine = data["pos.order.line"][0].getAllPrices(); - expect(noTaxLine.priceWithoutTax).toBe(100.0); - expect(noTaxLine.priceWithTax).toBe(100.0); - expect(noTaxLine.tax).toBe(0.0); - expect(Object.keys(noTaxLine.taxDetails).length).toBe(0); + product.taxes_id = []; + data["pos.order.line"][0].tax_ids = []; + const noTaxLine = data["pos.order.line"][0].prices; + expect(noTaxLine.total_excluded).toBe(100.0); + expect(noTaxLine.total_included).toBe(100.0); + expect(noTaxLine.taxes_data).toHaveLength(0); }); -test("[getAllPrices()] with fixed-amount tax", async () => { +test("[get prices()] with fixed-amount tax", async () => { const store = await setupPosEnv(); const models = store.models; const data = models.loadConnectedData(getAllPricesData()); @@ -109,30 +113,32 @@ test("[getAllPrices()] with fixed-amount tax", async () => { const orderLine = data["pos.order.line"][0]; orderLine.qty = 3; orderLine.price_unit = 10.0; - const lineTax = orderLine.getAllPrices(); + const lineTax = orderLine.prices; // 3 * 10 = 30, tax = 3 * 15 = 45 - expect(lineTax.priceWithoutTax).toBe(30.0); - expect(lineTax.priceWithTax).toBe(75.0); - expect(lineTax.tax).toBe(45.0); - expect(lineTax.taxDetails[1].amount).toBe(45.0); + expect(lineTax.total_excluded).toBe(30.0); + expect(lineTax.total_included).toBe(75.0); + expect(lineTax.taxes_data[0].tax_amount).toBe(45.0); }); -test("[getAllPrices()] with one price-included and one price-excluded tax", async () => { +test("[get prices()] with one price-included and one price-excluded tax", async () => { const store = await setupPosEnv(); const models = store.models; - const data = models.loadConnectedData(getAllPricesData()); + const product = models["product.product"].get(5); + const rawData = getAllPricesData(); + models["account.tax"].get(1).price_include = true; + product.taxes_id = models["account.tax"].readMany([1, 2]); + rawData["pos.order.line"][0].tax_ids = [1, 2]; + + const data = models.loadConnectedData(rawData); const orderLine = data["pos.order.line"][0]; - orderLine.tax_ids = [1, 2]; orderLine.qty = 1; orderLine.price_unit = 115.0; // price includes 15% tax - models["account.tax"].get(1).price_include = true; - const lineTax = orderLine.getAllPrices(); + const lineTax = orderLine.prices; // priceWithoutTax: 115 / 1.15 = 100, 25% tax = 25, priceWithTax = 115 + 25 = 140 - expect(lineTax.priceWithoutTax).toBe(100.0); - expect(lineTax.priceWithTax).toBe(140.0); - expect(lineTax.tax).toBe(40.0); - expect(lineTax.taxDetails[1].amount).toBe(15.0); - expect(lineTax.taxDetails[2].amount).toBe(25.0); + expect(lineTax.total_excluded).toBe(100.0); + expect(lineTax.total_included).toBe(140.0); + expect(lineTax.taxes_data[0].tax_amount).toBe(15.0); + expect(lineTax.taxes_data[1].tax_amount).toBe(25.0); }); test("[get quantityStr] Base test", async () => { diff --git a/addons/point_of_sale/static/tests/unit/services/pos_service.test.js b/addons/point_of_sale/static/tests/unit/services/pos_service.test.js index ab3f46c9bad01..c6cf00004c7b3 100644 --- a/addons/point_of_sale/static/tests/unit/services/pos_service.test.js +++ b/addons/point_of_sale/static/tests/unit/services/pos_service.test.js @@ -12,21 +12,6 @@ import { definePosModels(); describe("pos_store.js", () => { - test("getProductPrice", async () => { - const store = await setupPosEnv(); - const order = store.addNewOrder(); - const product = store.models["product.template"].get(5); - const price = store.getProductPrice(product); - expect(price).toBe(3.45); - order.setPricelist(null); - - const newPrice = store.getProductPrice(product); - expect(newPrice).toBe(115.0); - - const formattedPrice = store.getProductPrice(product, false, true); - expect(formattedPrice).toBe("$\u00a0115.00"); - }); - test("setTip", async () => { const store = await setupPosEnv(); const order = await getFilledOrder(store); // Should have 2 lines @@ -337,7 +322,7 @@ describe("pos_store.js", () => { store.selectedCategory = store.models["pos.category"].get(1); store.searchProductWord = "TEST"; products = store.productsToDisplay; - expect(products.length).toBe(2); + expect(products.length).toBe(4); expect(products[0].id).toBe(5); expect(products[1].id).toBe(6); expect(store.selectedCategory).toBe(undefined); @@ -355,7 +340,7 @@ describe("pos_store.js", () => { let grouped = store.productToDisplayByCateg; expect(grouped.length).toBe(1); //Only one group expect(grouped[0][0]).toBe(0); - expect(grouped[0][1].length).toBe(10); //10 products in same group + expect(grouped[0][1].length).toBe(12); //10 products in same group // Case 2: Grouping enabled store.config.iface_group_by_categ = true; diff --git a/addons/point_of_sale/static/tests/unit/utils.js b/addons/point_of_sale/static/tests/unit/utils.js index 7454c539cede2..dcd721bb1bc87 100644 --- a/addons/point_of_sale/static/tests/unit/utils.js +++ b/addons/point_of_sale/static/tests/unit/utils.js @@ -5,6 +5,7 @@ import { Deferred } from "@odoo/hoot-mock"; import { MainComponentsContainer } from "@web/core/main_components_container"; import { patch } from "@web/core/utils/patch"; import { onMounted } from "@odoo/owl"; +import { expect } from "@odoo/hoot"; const { DateTime } = luxon; @@ -101,3 +102,7 @@ export const patchDialogComponent = (component) => { }, }); }; + +export const expectFormattedPrice = (value, expected) => { + expect(value).toBe(expected.replaceAll(" ", "\u00a0")); +}; diff --git a/addons/point_of_sale/tests/common.py b/addons/point_of_sale/tests/common.py index fd8fb1ada37a6..8795a45d09058 100644 --- a/addons/point_of_sale/tests/common.py +++ b/addons/point_of_sale/tests/common.py @@ -697,7 +697,7 @@ def create_tax_fixed(amount, price_include_override='tax_excluded', include_base def create_random_uid(self): return ('%05d-%03d-%04d' % (randint(1, 99999), randint(1, 999), randint(1, 9999))) - def create_ui_order_data(self, pos_order_lines_ui_args, customer=False, is_invoiced=False, payments=None, uuid=None): + def create_ui_order_data(self, pos_order_lines_ui_args, pos_order_ui_args={}, customer=False, is_invoiced=False, payments=None, uuid=None): """ Mocks the order_data generated by the pos ui. This is useful in making orders in an open pos session without making tours. @@ -802,6 +802,7 @@ def create_payment(payment_method, amount): 'uuid': uuid, 'user_id': self.env.uid, 'to_invoice': is_invoiced, + **pos_order_ui_args, } @classmethod diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py index e0f2bcf39f111..2c66bf7432fd4 100644 --- a/addons/point_of_sale/tests/test_frontend.py +++ b/addons/point_of_sale/tests/test_frontend.py @@ -627,7 +627,6 @@ def test_01_pos_basic_order(self): self.env['ir.module.module'].search([('name', '=', 'point_of_sale')], limit=1).state = 'installed' self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'pos_pricelist', login="pos_user") - self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'pos_basic_order_01_multi_payment_and_change', login="pos_user") self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'pos_basic_order_02_decimal_order_quantity', login="pos_user") self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'pos_basic_order_03_tax_position', login="pos_user") self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'FloatingOrderTour', login="pos_user") @@ -805,6 +804,7 @@ def test_rounding_up(self): self.main_pos_config.write({ 'rounding_method': rouding_method.id, 'cash_rounding': True, + 'only_round_cash_method': True, }) self.main_pos_config.with_user(self.pos_user).open_ui() @@ -827,6 +827,7 @@ def test_rounding_down(self): self.main_pos_config.write({ 'rounding_method': rouding_method.id, 'cash_rounding': True, + 'only_round_cash_method': True, }) self.main_pos_config.with_user(self.pos_user).open_ui() @@ -834,42 +835,6 @@ def test_rounding_down(self): self.env["pos.order"].search([('state', '=', 'draft')]).write({'state': 'cancel'}) self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'PaymentScreenTotalDueWithOverPayment', login="pos_user") - def test_rounding_half_up(self): - rouding_method = self.env['account.cash.rounding'].create({ - 'name': 'Rounding HALF-UP', - 'rounding': 0.5, - 'rounding_method': 'HALF-UP', - }) - - self.env['product.product'].create({ - 'name': 'Product Test 1.20', - 'available_in_pos': True, - 'list_price': 1.2, - 'taxes_id': False, - }) - - self.env['product.product'].create({ - 'name': 'Product Test 1.25', - 'available_in_pos': True, - 'list_price': 1.25, - 'taxes_id': False, - }) - - self.env['product.product'].create({ - 'name': 'Product Test 1.4', - 'available_in_pos': True, - 'list_price': 1.4, - 'taxes_id': False, - }) - - self.main_pos_config.write({ - 'rounding_method': rouding_method.id, - 'cash_rounding': True, - }) - - self.main_pos_config.with_user(self.pos_user).open_ui() - self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'PaymentScreenRoundingHalfUp', login="pos_user") - def test_pos_closing_cash_details(self): """Test cash difference *loss* at closing. """ @@ -1729,27 +1694,6 @@ def test_product_combo_discount(self): login="pos_user", ) - def test_cash_rounding_payment(self): - """Verify than an error popup is shown if the payment value is more precise than the rounding method""" - rounding_method = self.env['account.cash.rounding'].create({ - 'name': 'Down 0.10', - 'rounding': 0.10, - 'strategy': 'add_invoice_line', - 'profit_account_id': self.company_data['default_account_revenue'].copy().id, - 'loss_account_id': self.company_data['default_account_expense'].copy().id, - 'rounding_method': 'DOWN', - }) - - self.main_pos_config.write({ - 'cash_rounding': True, - 'only_round_cash_method': False, - 'rounding_method': rounding_method.id, - }) - - self.env['ir.config_parameter'].sudo().set_int('barcode.max_time_between_keys_in_ms', 1) - self.main_pos_config.open_ui() - self.start_tour("/pos/ui/%d" % self.main_pos_config.id, 'CashRoundingPayment', login="accountman") - def test_product_categories_order(self): """ Verify that the order of categories doesnt change in the frontend """ self.env['pos.category'].search([]).write({'sequence': 100}) diff --git a/addons/point_of_sale/tests/test_pos_cash_rounding.py b/addons/point_of_sale/tests/test_pos_cash_rounding.py index a9733ce567b79..49ff6032e13ba 100644 --- a/addons/point_of_sale/tests/test_pos_cash_rounding.py +++ b/addons/point_of_sale/tests/test_pos_cash_rounding.py @@ -1,5 +1,3 @@ -from unittest import skip - from odoo import Command from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon from odoo.tests import tagged @@ -37,220 +35,6 @@ def setUpClass(cls): 'pos_categ_ids': [Command.set(cls.pos_desk_misc_test.ids)], }) - def test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method(self): - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': False, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.7, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.65, - 'amount_tax': 2.05, - 'amount_total': 15.7, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.7, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.65, - 'amount_tax': 2.05, - 'amount_total': 15.7, - }]) - - def test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method_pay_by_bank_and_cash(self): - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': False, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method_pay_by_bank_and_cash') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.73, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.68, - 'amount_tax': 2.05, - 'amount_total': 15.73, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.73, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.68, - 'amount_tax': 2.05, - 'amount_total': 15.73, - }]) - - def test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_no_rounding_left(self): - self.cash_rounding_add_invoice_line.rounding_method = 'DOWN' - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': False, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_no_rounding_left') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.72, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.67, - 'amount_tax': 2.05, - 'amount_total': 15.72, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.72, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.67, - 'amount_tax': 2.05, - 'amount_total': 15.72, - }]) - - def test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_with_residual_rounding(self): - self.cash_rounding_add_invoice_line.rounding_method = 'DOWN' - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': False, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_with_residual_rounding') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.68, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.63, - 'amount_tax': 2.05, - 'amount_total': 15.68, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.68, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.63, - 'amount_tax': 2.05, - 'amount_total': 15.68, - }]) - - @skip('Temporary to fast merge new valuation') - def test_cash_rounding_up_add_invoice_line_not_only_round_cash_method(self): - self.cash_rounding_add_invoice_line.rounding_method = 'UP' - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': False, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_up_add_invoice_line_not_only_round_cash_method') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.74, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.69, - 'amount_tax': 2.05, - 'amount_total': 15.74, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.74, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.69, - 'amount_tax': 2.05, - 'amount_total': 15.74, - }]) - - def test_cash_rounding_halfup_add_invoice_line_only_round_cash_method(self): - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': True, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_halfup_add_invoice_line_only_round_cash_method') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.7, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.65, - 'amount_tax': 2.05, - 'amount_total': 15.7, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.7, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.65, - 'amount_tax': 2.05, - 'amount_total': 15.7, - }]) - - def test_cash_rounding_halfup_add_invoice_line_only_round_cash_method_pay_by_bank_and_cash(self): - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': True, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_halfup_add_invoice_line_only_round_cash_method_pay_by_bank_and_cash') - refund, order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=2) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.73, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.68, - 'amount_tax': 2.05, - 'amount_total': 15.73, - }]) - self.assertRecordValues(refund, [{ - 'amount_tax': -2.05, - 'amount_total': -15.72, - 'amount_paid': -15.73, - }]) - self.assertRecordValues(refund.account_move, [{ - 'amount_untaxed': 13.68, - 'amount_tax': 2.05, - 'amount_total': 15.73, - }]) - def test_cash_rounding_halfup_biggest_tax_not_only_round_cash_method(self): self.skipTest('To re-introduce when feature is ready') self.main_pos_config.write({ @@ -375,84 +159,6 @@ def test_cash_rounding_halfup_biggest_tax_only_round_cash_method_pay_by_bank_and 'amount_total': 15.73, }]) - def test_cash_rounding_with_change(self): - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': False, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_with_change') - order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=1) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.7, - 'amount_paid': 15.7, - }]) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.65, - 'amount_tax': 2.05, - 'amount_total': 15.7, - }]) - - def test_cash_rounding_only_cash_method_with_change(self): - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': True, - }) - with self.with_new_session(user=self.pos_user) as session: - self.start_pos_tour('test_cash_rounding_only_cash_method_with_change') - order = self.env['pos.order'].search([('session_id', '=', session.id)], limit=1) - self.assertRecordValues(order.account_move, [{ - 'amount_untaxed': 13.65, - 'amount_tax': 2.05, - 'amount_total': 15.7, - }]) - self.assertRecordValues(order, [{ - 'amount_tax': 2.05, - 'amount_total': 15.72, - 'amount_paid': 15.7, - }]) - - def test_cash_rounding_up_with_change(self): - self.cash_rounding_add_invoice_line = self.env['account.cash.rounding'].create({ - 'name': "cash_rounding_up_1", - 'rounding': 1.00, - 'rounding_method': 'UP', - 'strategy': 'add_invoice_line', - 'profit_account_id': self.env.company.default_cash_difference_income_account_id.id, - 'loss_account_id': self.env.company.default_cash_difference_expense_account_id.id, - }) - self.main_pos_config.write({ - 'rounding_method': self.cash_rounding_add_invoice_line.id, - 'cash_rounding': True, - 'only_round_cash_method': True, - }) - tax_include = self.env['account.tax'].create({ - 'name': 'tax incl', - 'type_tax_use': 'sale', - 'amount_type': 'percent', - 'amount': 7, - 'price_include_override': 'tax_included', - 'include_base_amount': True, - }) - self.env['product.product'].create({ - 'name': "product_a", - 'available_in_pos': True, - 'list_price': 95.00, - 'taxes_id': tax_include, - 'pos_categ_ids': [Command.set(self.pos_desk_misc_test.ids)], - }) - self.env['product.product'].create({ - 'name': "product_b", - 'available_in_pos': True, - 'list_price': 42.00, - 'taxes_id': tax_include, - 'pos_categ_ids': [Command.set(self.pos_desk_misc_test.ids)], - }) - self.start_pos_tour('test_cash_rounding_up_with_change') - def test_remove_archived_product_from_cache(self): self.pos_admin.write({ 'group_ids': [ diff --git a/addons/pos_discount/static/src/app/screens/ticket_screen/ticket_screen.js b/addons/pos_discount/static/src/app/screens/ticket_screen/ticket_screen.js index f21af3f6b29a4..59147b0ea3a82 100644 --- a/addons/pos_discount/static/src/app/screens/ticket_screen/ticket_screen.js +++ b/addons/pos_discount/static/src/app/screens/ticket_screen/ticket_screen.js @@ -11,12 +11,14 @@ patch(TicketScreen.prototype, { const destinationOrder = this.pos.getOrder(); if (discountLine && destinationOrder && !destinationOrder.getDiscountLine()) { - const globalDiscount = -discountLine.price_subtotal_incl; + const globalDiscount = -discountLine.priceIncl; + const priceUnit = + (globalDiscount * destinationOrder.prices.taxDetails.total_amount) / + (order.amount_total + globalDiscount) || 1; + this.pos.models["pos.order.line"].create({ qty: 1, - price_unit: - (globalDiscount * destinationOrder.taxTotals.total_amount) / - (order.amount_total + globalDiscount) || 1, + price_unit: destinationOrder.orderSign * priceUnit, product_id: this.pos.config.discount_product_id, order_id: destinationOrder, }); diff --git a/addons/pos_discount/tests/test_taxes_global_discount.py b/addons/pos_discount/tests/test_taxes_global_discount.py index 21dccedcc55f3..650163b8d3055 100644 --- a/addons/pos_discount/tests/test_taxes_global_discount.py +++ b/addons/pos_discount/tests/test_taxes_global_discount.py @@ -113,7 +113,7 @@ def test_pos_global_discount_sell_and_refund(self): self.assertAlmostEqual(refund_order.amount_total, -2.85) self.assertEqual(len(refund_order.lines), 2) self.assertEqual(refund_order.lines[1].product_id.id, self.main_pos_config.discount_product_id.id) - self.assertAlmostEqual(refund_order.lines[1].price_subtotal_incl, 0.15) + self.assertAlmostEqual(refund_order.lines[1].price_subtotal_incl, -0.15) pos_order = orders[1] self.assertAlmostEqual(pos_order.amount_total, 2.85) self.assertEqual(len(pos_order.lines), 2) diff --git a/addons/pos_event/static/src/app/screens/product_screen/product_screen.js b/addons/pos_event/static/src/app/screens/product_screen/product_screen.js index 77f0b8a545e57..9fef8fb23247b 100644 --- a/addons/pos_event/static/src/app/screens/product_screen/product_screen.js +++ b/addons/pos_event/static/src/app/screens/product_screen/product_screen.js @@ -2,7 +2,6 @@ import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product import { makeAwaitable } from "@point_of_sale/app/utils/make_awaitable_dialog"; import { patch } from "@web/core/utils/patch"; import { EventConfiguratorPopup } from "@pos_event/app/components/popup/event_configurator_popup/event_configurator_popup"; -import { _t } from "@web/core/l10n/translation"; import { EventRegistrationPopup } from "../../components/popup/event_registration_popup/event_registration_popup"; import { EventSlotSelectionPopup } from "../../components/popup/event_slot_selection_popup/event_slot_selection_popup"; @@ -13,13 +12,6 @@ patch(ProductScreen.prototype, { const products = super.products; return [...products].filter((p) => p.service_tracking !== "event"); }, - getProductPrice(productTemplate) { - if (!productTemplate.event_id) { - return super.getProductPrice(productTemplate); - } - - return _t("From %s", this.pos.getProductPrice(productTemplate, false, true)); - }, getProductImage(product) { if (!product.event_id) { return super.getProductImage(product); diff --git a/addons/pos_loyalty/static/src/app/components/popups/manage_giftcard_popup/manage_giftcard_popup.js b/addons/pos_loyalty/static/src/app/components/popups/manage_giftcard_popup/manage_giftcard_popup.js index 07eff1351d066..7f9f5611c3d18 100644 --- a/addons/pos_loyalty/static/src/app/components/popups/manage_giftcard_popup/manage_giftcard_popup.js +++ b/addons/pos_loyalty/static/src/app/components/popups/manage_giftcard_popup/manage_giftcard_popup.js @@ -35,7 +35,7 @@ export class ManageGiftCardPopup extends Component { lockGiftCardFields: false, loading: false, inputValue: this.props.startingValue, - amountValue: this.props.line.getPriceWithTax().toString(), + amountValue: this.props.line.prices.total_included.toString(), error: false, amountError: false, expirationDate: luxon.DateTime.now().plus({ year: 1 }), diff --git a/addons/pos_loyalty/static/src/app/models/pos_order.js b/addons/pos_loyalty/static/src/app/models/pos_order.js index b2b82c8f1a659..05e3a1c7a5323 100644 --- a/addons/pos_loyalty/static/src/app/models/pos_order.js +++ b/addons/pos_loyalty/static/src/app/models/pos_order.js @@ -3,7 +3,6 @@ import { patch } from "@web/core/utils/patch"; import { floatIsZero } from "@web/core/utils/numbers"; import { _t } from "@web/core/l10n/translation"; import { loyaltyIdsGenerator } from "@pos_loyalty/app/services/pos_store"; -import { computePriceForcePriceInclude } from "@point_of_sale/app/models/utils/tax_utils"; const { DateTime } = luxon; function _newRandomRewardCode() { @@ -374,7 +373,7 @@ patch(PosOrder.prototype, { */ _getPointsCorrection(program) { const rewardLines = this.lines.filter((line) => line.is_reward_line); - if (!this._canGenerateRewards(program, this.getTotalWithTax(), this.getTotalWithoutTax())) { + if (!this._canGenerateRewards(program, this.priceIncl, this.priceExcl)) { return 0; } let res = 0; @@ -387,7 +386,7 @@ patch(PosOrder.prototype, { if (this._validForPointsCorrection(reward, line, rule)) { if (rule.reward_point_mode === "money") { res -= ProductPrice.round( - rule.reward_point_amount * line.getPriceWithTax() + rule.reward_point_amount * line.prices.total_included ); } else if (rule.reward_point_mode === "unit") { res += rule.reward_point_amount * line.getQuantity(); @@ -430,7 +429,6 @@ patch(PosOrder.prototype, { /** * @returns {number} The points that are left for the given coupon for this order. */ - //FIXME use of pos _getRealCouponPoints(coupon_id) { let points = 0; const dbCoupon = this.models["loyalty.card"].get(coupon_id); @@ -555,16 +553,16 @@ patch(PosOrder.prototype, { (sum, line) => sum + (line.combo_line_ids.length > 0 - ? line.getComboTotalPrice() - : line.getPriceWithTax()), + ? line.comboTotalPrice + : line.prices.total_included), 0 ); const amountWithoutTax = linesForRule.reduce( (sum, line) => sum + (line.combo_line_ids.length > 0 - ? line.getComboTotalPriceWithoutTax() - : line.getPriceWithoutTax()), + ? line.comboTotalPriceWithoutTax + : line.prices.total_excluded), 0 ); const amountCheck = @@ -607,8 +605,8 @@ patch(PosOrder.prototype, { } orderedProductPaid += line.combo_line_ids.length > 0 - ? line.getComboTotalPrice() - : line.getPriceWithTax(); + ? line.comboTotalPrice + : line.prices.total_included; if (!line.is_reward_line) { totalProductQty += lineQty; } @@ -646,7 +644,7 @@ patch(PosOrder.prototype, { continue; } const pointsPerUnit = ProductPrice.round( - (rule.reward_point_amount * line.getPriceWithTax()) / + (rule.reward_point_amount * line.prices.total_included) / line.getQuantity() ); if (pointsPerUnit > 0) { @@ -749,8 +747,8 @@ patch(PosOrder.prototype, { })) ); const result = []; - const totalWithTax = this.getTotalWithTax(); - const totalWithoutTax = this.getTotalWithoutTax(); + const totalWithTax = this.priceIncl; + const totalWithoutTax = this.priceExcl; const totalIsZero = totalWithTax === 0; const globalDiscountLines = this._getGlobalDiscountLines(); const globalDiscountPercent = globalDiscountLines.length @@ -954,11 +952,11 @@ patch(PosOrder.prototype, { const taxKey = ["ewallet", "gift_card"].includes(reward.program_id.program_type) ? line.tax_ids.map((t) => t.id) : line.tax_ids.filter((t) => t.amount_type !== "fixed").map((t) => t.id); - discountable += line.getPriceWithTax(); + discountable += line.prices.total_included; if (!discountablePerTax[taxKey]) { discountablePerTax[taxKey] = 0; } - discountablePerTax[taxKey] += line.getBasePrice(); + discountablePerTax[taxKey] += line.prices.total_excluded; } return { discountable, discountablePerTax }; }, @@ -976,7 +974,7 @@ patch(PosOrder.prototype, { applicableProductIds.has(line.getProduct().id) ); return filtered_lines.toSorted( - (lineA, lineB) => lineA.getComboTotalPrice() - lineB.getComboTotalPrice() + (lineA, lineB) => lineA.comboTotalPrice / lineA.qty - lineB.comboTotalPrice / lineB.qty )[0]; }, /** @@ -989,9 +987,9 @@ patch(PosOrder.prototype, { } const taxKey = cheapestLine.tax_ids.map((t) => t.id); return { - discountable: cheapestLine.getComboTotalPriceWithoutTax(), + discountable: cheapestLine.comboTotalPriceWithoutTax, discountablePerTax: Object.fromEntries([ - [taxKey, cheapestLine.getComboTotalPriceWithoutTax()], + [taxKey, cheapestLine.comboTotalPriceWithoutTax], ]), }; }, @@ -1032,7 +1030,7 @@ patch(PosOrder.prototype, { if (!line.getQuantity() || !line.price_unit) { continue; } - remainingAmountPerLine[line.uuid] = line.getPriceWithTax(); + remainingAmountPerLine[line.uuid] = line.prices.total_included; const product_id = line.combo_parent_id?.product_id.id || line.getProduct().id; if ( applicableProductIds.has(product_id) || @@ -1103,7 +1101,8 @@ patch(PosOrder.prototype, { discountablePerTax[taxKey] = 0; } discountablePerTax[taxKey] += - line.getBasePrice() * (remainingAmountPerLine[line.uuid] / line.getPriceWithTax()); + line.prices.total_excluded * + (remainingAmountPerLine[line.uuid] / line.prices.total_included); } return { discountable, discountablePerTax }; }, @@ -1142,7 +1141,7 @@ patch(PosOrder.prototype, { return _t("Unknown discount type"); } let { discountable, discountablePerTax } = getDiscountable(reward); - discountable = Math.min(this.getTotalWithTax(), discountable); + discountable = Math.min(this.priceIncl, discountable); if (floatIsZero(discountable)) { return []; } @@ -1169,20 +1168,18 @@ patch(PosOrder.prototype, { // These are considered payments and do not require to be either taxed or split by tax const discountProduct = reward.discount_line_product_id; if (["ewallet", "gift_card"].includes(reward.program_id.program_type)) { - const new_price = computePriceForcePriceInclude( - discountProduct.taxes_id, - -Math.min(maxDiscount, discountable), - discountProduct, - {}, - this.company, - this.currency, - this.models - ); + const price = discountProduct.getTaxDetails({ + overridedValues: { + tax_ids: discountProduct.taxes_id, + price_unit: -Math.min(maxDiscount, discountable), + special_mode: "total_included", + }, + }); return [ { product_id: discountProduct, - price_unit: new_price, + price_unit: price.total_excluded, qty: 1, reward_id: reward, is_reward_line: true, @@ -1205,7 +1202,7 @@ patch(PosOrder.prototype, { lst.push({ product_id: discountProduct, - price_unit: -(Math.min(this.getTotalWithTax(), entry[1]) * discountFactor), + price_unit: -(Math.min(this.priceIncl, entry[1]) * discountFactor), qty: 1, reward_id: reward, is_reward_line: true, diff --git a/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.js b/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.js index 62cb0c44a7fb4..1e8f40f2b3800 100644 --- a/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.js +++ b/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.js @@ -35,7 +35,7 @@ patch(ControlButtons.prototype, { async onClickWallet() { const order = this.pos.getOrder(); const eWalletPrograms = this._getEWalletPrograms(); - const orderTotal = order.getTotalWithTax(); + const orderTotal = order.priceIncl; const eWalletRewards = this._getEWalletRewards(order); if (eWalletRewards.length === 0 && orderTotal >= 0) { this.dialog.add(AlertDialog, { diff --git a/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.xml b/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.xml index 1a00c0c41d053..fafe1cde5b6d5 100644 --- a/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.xml +++ b/addons/pos_loyalty/static/src/app/screens/product_screen/control_buttons/control_buttons.xml @@ -5,7 +5,7 @@ expr="//t[@t-if='props.showRemainingButtons']/div/button[hasclass('o_pricelist_button')]" position="before"> - +
    @@ -1717,6 +1718,7 @@ onSelectionChanged="(ranges) => this.onRangeChanged(ranges[0])" required="true" hasSingleRange="true" + autofocus="props.autofocus" />
    @@ -1786,6 +1788,7 @@ onValueChanged.bind="onValueChanged" criterionType="props.criterion.type" disableFormulas="props.disableFormulas" + focused="props.autofocus" /> @@ -1795,6 +1798,7 @@ onValueChanged.bind="onFirstValueChanged" criterionType="props.criterion.type" disableFormulas="props.disableFormulas" + focused="props.autofocus" /> @@ -2278,6 +2283,7 @@ t-key="state.rules.cellIs.operator" criterion="genericCriterion" onCriterionChanged.bind="onRuleValuesChanged" + autofocus="this.state.hasEditedCf" />
    Formatting style
    @@ -2484,6 +2490,9 @@ className="'mb-2'" />
    +
    + +
    @@ -2523,7 +2532,7 @@ className="'mb-2'" /> -
    +
    @@ -2662,6 +2671,9 @@ hasVerticalAlign="true" />
    +
    + +
    @@ -2722,6 +2734,9 @@ +
    + +
    @@ -2773,7 +2788,7 @@ Color Down -
    +
    @@ -2887,7 +2902,7 @@
    -
    +
    @@ -2902,7 +2917,7 @@
    -
    +
    -
    +
    @@ -3086,7 +3101,7 @@
    -
    +
    @@ -3179,7 +3194,7 @@ -
    +
    @@ -3325,7 +3340,7 @@
    -
    +
    @@ -3365,7 +3380,7 @@ className="'mb-2'" />
    -
    +
    @@ -4044,7 +4059,8 @@ @@ -6324,6 +6340,7 @@ criterion="state.criterion" onCriterionChanged.bind="onCriterionChanged" disableFormulas="true" + autofocus="true" /> From bc377d36d957e7911a1003bc79dd1184fbfe40f9 Mon Sep 17 00:00:00 2001 From: khaj-odoo Date: Thu, 16 Oct 2025 11:22:44 +0530 Subject: [PATCH 015/673] [FIX] website: create test menu for test_website_edit_menus_delete_parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description - The test was failing because the default `Contact us` menu was removed In commit https://github.com/odoo/odoo/pull/223724. Fix - Created a test menu to ensure the test has two menus to work with. runbot-233330 closes odoo/odoo#231833 Signed-off-by: Soukéina Bojabza (sobo) --- addons/website/tests/test_ui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index d9e0e15b4f74b..66ae34d55b35b 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -656,6 +656,12 @@ def test_website_no_dirty_lazy_image(self): def test_website_edit_menus_delete_parent(self): website = self.env['website'].browse(1) + self.env['website.menu'].create({ + 'name': 'Test Child Menu', + 'url': '/test-child', + 'website_id': website.id, + 'parent_id': website.menu_id.id, + }) menu_tree = self.env['website.menu'].get_tree(website.id) parent_menu = menu_tree['children'][0]['fields'] From a54a30553fddc7aad2784785759cca3de8eb4117 Mon Sep 17 00:00:00 2001 From: "Lulu Grimalkin (lugr)" Date: Fri, 25 Apr 2025 09:52:09 +0200 Subject: [PATCH 016/673] [IMP] mrp,purchase,repair,stock: hardcode width for priority fields The priority column takes up too much unnecessary space in the list view because it is defined as a selection rather than a boolean (because modules like `project` add further values to the priority field) and the selection field has a high default minimum width in list views. Thus, we hardcode the column width to 20 px to accommodate exactly one star, making sure the `nolabel="1"` attribute is set (existing behaviour), so we don't end up with an ugly truncated label. The priority widget is used in the following modules within the Inventory scope: * `maintenance`: Only used in kanban and form views. * `mrp_plm`: Only used in a kanban view. * `mrp`: Set to 20px in `mrp.production` list view. * `purchase`: Set to 20px in `purchase.order` list views. * `quality_control`: Only used in kanban and form views. * `repair`: Set to 20px in `repair.order` list view. * `stock_barcode_mrp`: Only used in a kanban view. * `stock`: Set to 20px in `stock.picking` list view. Task ID: 4688315 closes odoo/odoo#207409 Signed-off-by: Tiffany Chang (tic) --- addons/mrp/views/mrp_production_views.xml | 2 +- addons/purchase/views/purchase_views.xml | 6 +++--- addons/repair/views/repair_views.xml | 2 +- addons/stock/views/stock_picking_views.xml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 1de39eca5feb3..77b1118cdcc95 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -48,7 +48,7 @@ +
    At least two options are required
    diff --git a/addons/mail/static/tests/tours/mail_poll_tour.js b/addons/mail/static/tests/tours/mail_poll_tour.js index ac03cab40a1a0..39aaf1a600d9b 100644 --- a/addons/mail/static/tests/tours/mail_poll_tour.js +++ b/addons/mail/static/tests/tours/mail_poll_tour.js @@ -6,10 +6,10 @@ registry.category("web_tour.tours").add("mail_poll_tour.js", { { trigger: "button:contains('Start a poll')", run: "click" }, { trigger: ".modal-header:contains('Create a poll')" }, { trigger: "input[name='poll_question']", run: "edit What is your favorite color?" }, - { trigger: "button:contains('Add another option'):disabled" }, + { trigger: "button:contains('Add another option'):enabled" }, { trigger: ".o-mail-CreatePollOptionDialog input:eq(0)", run: "edit Red" }, { trigger: ".o-mail-CreatePollOptionDialog input:eq(1)", run: "edit Green" }, - { trigger: "button:contains('Add another option'):enabled", run: "click" }, + { trigger: "button:contains('Add another option')", run: "click" }, { trigger: ".o-mail-CreatePollOptionDialog input:eq(2)", run: "edit Blue" }, { trigger: "button:contains(Post)", run: "click" }, { trigger: ".o-mail-Poll :contains('What is your favorite color?')" }, From 80d043b36d140c2e894542c56269d221fd529a6f Mon Sep 17 00:00:00 2001 From: "Didier (did)" Date: Tue, 28 Oct 2025 12:51:25 +0100 Subject: [PATCH 018/673] [IMP] mail: refactor message previewText MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, message preview prefix was a subtemplate of ChatBubble. Since this template is reused, being a subtemplate isn't very practical. This commit refactor previewText to include the prefix. closes odoo/odoo#233399 Signed-off-by: Sébastien Theys (seb) --- .../static/src/core/common/chat_bubble.xml | 19 +------ .../static/src/core/common/message_model.js | 51 ++++++++++++------- .../src/core/public_web/messaging_menu.xml | 7 --- .../core/public_web/sub_channel_preview.xml | 5 +- 4 files changed, 35 insertions(+), 47 deletions(-) diff --git a/addons/mail/static/src/core/common/chat_bubble.xml b/addons/mail/static/src/core/common/chat_bubble.xml index b91a9226e8029..abc4e53981320 100644 --- a/addons/mail/static/src/core/common/chat_bubble.xml +++ b/addons/mail/static/src/core/common/chat_bubble.xml @@ -25,27 +25,10 @@
    - - - - - - - - +
    - - - - You: - - - : - - - diff --git a/addons/mail/static/src/core/common/message_model.js b/addons/mail/static/src/core/common/message_model.js index 47e4c1c511707..7ad7dd3626d67 100644 --- a/addons/mail/static/src/core/common/message_model.js +++ b/addons/mail/static/src/core/common/message_model.js @@ -462,28 +462,43 @@ export class Message extends Record { previewText = fields.Html("", { /** @this {import("models").Message} */ compute() { + let messageBody = ""; if (!this.hasOnlyAttachments) { - return this.inlineBody || this.subtype_id?.description; + messageBody = this.inlineBody || this.subtype_id?.description; + } else { + const attachments = this.attachment_ids; + switch (attachments.length) { + case 1: + messageBody = attachments[0].previewName; + break; + case 2: + messageBody = _t("%(file1)s and %(file2)s", { + file1: attachments[0].previewName, + file2: attachments[1].previewName, + count: attachments.length - 1, + }); + break; + default: + messageBody = _t("%(file1)s and %(count)s other attachments", { + file1: attachments[0].previewName, + count: attachments.length - 1, + }); + } + messageBody = markup`${messageBody}`; } - const { attachment_ids: attachments } = this; - if (!attachments || attachments.length === 0) { - return ""; + if (this.isSelfAuthored) { + return markup`${_t( + "You: %(message_content)s", + { message_content: messageBody } + )}`; } - switch (attachments.length) { - case 1: - return attachments[0].previewName; - case 2: - return _t("%(file1)s and %(file2)s", { - file1: attachments[0].previewName, - file2: attachments[1].previewName, - count: attachments.length - 1, - }); - default: - return _t("%(file1)s and %(count)s other attachments", { - file1: attachments[0].previewName, - count: attachments.length - 1, - }); + if (!this.author || this.author.notEq(this.thread?.channel?.correspondent?.persona)) { + return _t("%(authorName)s: %(message_content)s", { + authorName: this.authorName, + message_content: messageBody, + }); } + return messageBody; }, }); diff --git a/addons/mail/static/src/core/public_web/messaging_menu.xml b/addons/mail/static/src/core/public_web/messaging_menu.xml index 92f524cf44691..105ee7c9c8093 100644 --- a/addons/mail/static/src/core/public_web/messaging_menu.xml +++ b/addons/mail/static/src/core/public_web/messaging_menu.xml @@ -37,7 +37,6 @@ - @@ -46,12 +45,6 @@ - - - - - - diff --git a/addons/mail/static/src/discuss/core/public_web/sub_channel_preview.xml b/addons/mail/static/src/discuss/core/public_web/sub_channel_preview.xml index 230a5e506ac62..8be14803c03b0 100644 --- a/addons/mail/static/src/discuss/core/public_web/sub_channel_preview.xml +++ b/addons/mail/static/src/discuss/core/public_web/sub_channel_preview.xml @@ -10,10 +10,7 @@
    - - - - +
    From d75f103fda76a5525a30965a03cc58b3a21fe676 Mon Sep 17 00:00:00 2001 From: "Pierrot (prro)" Date: Tue, 31 Dec 2024 12:58:53 +0000 Subject: [PATCH 019/673] [IMP] account: use better management of invalid statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit brings more clarity on invalid statements. The reflected changes are : - Hiding Last Statement if its date is <= Lock Date - "Invalid Statement(s)" alert on the journal dashboard - Red balance amount and warning in the BankRecW when it contains invalid statements (clicking on the warning applies the filter) - Possibility to choose a statement when creating a transaction - Invalid statement warning in the statement creation form - Displays all warnings in the statement form view - When a file generate a statement, it is kept in its attachments - Prevent deletion of transactions if they belong to a valid statement - Empty statement are not taken into account for the dashboard Last Statement and the BankRecW balance task-4413473 closes odoo/odoo#232246 X-original-commit: f3cf5dfe29d9acd63b94e36ac3a94034ea06b270 Related: odoo/enterprise#97597 Signed-off-by: Maximilien La Barre (malb) Signed-off-by: Pierre-Rodéric Roose (prro) --- addons/account/i18n/account.pot | 418 ++++++++++-------- .../account/models/account_bank_statement.py | 4 + .../models/account_bank_statement_line.py | 6 + addons/account/models/account_journal.py | 11 + .../models/account_journal_dashboard.py | 13 + .../views/account_journal_dashboard_view.xml | 12 +- 6 files changed, 286 insertions(+), 178 deletions(-) diff --git a/addons/account/i18n/account.pot b/addons/account/i18n/account.pot index 88392017b1d86..e6cbe9a3a08dc 100644 --- a/addons/account/i18n/account.pot +++ b/addons/account/i18n/account.pot @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 19.0\n" +"Project-Id-Version: Odoo Server 19.1a1+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-12 14:42+0000\n" -"PO-Revision-Date: 2025-09-12 14:42+0000\n" +"POT-Creation-Date: 2025-10-30 08:38+0000\n" +"PO-Revision-Date: 2025-10-30 08:38+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -201,18 +201,13 @@ msgstr "" msgid "%d moves" msgstr "" -#. module: account -#. odoo-python -#: code:addons/account/models/account_tax.py:0 -msgid "%s (Copy)" -msgstr "" - #. module: account #. odoo-python #: code:addons/account/models/account_account.py:0 #: code:addons/account/models/account_journal.py:0 #: code:addons/account/models/account_payment_term.py:0 #: code:addons/account/models/account_reconcile_model.py:0 +#: code:addons/account/models/account_tax.py:0 msgid "%s (copy)" msgstr "" @@ -1019,8 +1014,8 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:account.portal_my_details msgid "" "\n" -" You can choose how you want us to send your invoices, and with which electronic format.\n" -" " +" You can choose how you want us to send your invoices, and with which electronic format.\n" +"
    " msgstr "" #. module: account @@ -1577,7 +1572,7 @@ msgid "" " You have received new electronic invoices!a new electronic invoice!\n" " \n" " \n" -" \"Odoo\"\n" +" \"Odoo\"\n" " \n" " \n" " \n" @@ -2041,7 +2036,6 @@ msgid "Account Number" msgstr "" #. module: account -#: model:account.account,name:account.1_payable #: model:ir.model.fields,field_description:account.field_res_partner__property_account_payable_id #: model:ir.model.fields,field_description:account.field_res_users__property_account_payable_id msgid "Account Payable" @@ -2058,17 +2052,11 @@ msgid "Account Properties" msgstr "" #. module: account -#: model:account.account,name:account.1_receivable #: model:ir.model.fields,field_description:account.field_res_partner__property_account_receivable_id #: model:ir.model.fields,field_description:account.field_res_users__property_account_receivable_id msgid "Account Receivable" msgstr "" -#. module: account -#: model:account.account,name:account.1_pos_receivable -msgid "Account Receivable (PoS)" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_account_move_line__is_account_reconcile msgid "Account Reconcile" @@ -2352,6 +2340,7 @@ msgid "Accounts Mapping of Fiscal Position" msgstr "" #. module: account +#: model:account.account,name:account.1_payable #: model:account.account,name:account.2_account_account_us_payable msgid "Accounts Payable" msgstr "" @@ -2368,11 +2357,13 @@ msgid "Accounts Prefixes" msgstr "" #. module: account +#: model:account.account,name:account.1_receivable #: model:account.account,name:account.2_account_account_us_receivable msgid "Accounts Receivable" msgstr "" #. module: account +#: model:account.account,name:account.1_pos_receivable #: model:account.account,name:account.2_account_account_us_pos_receivable msgid "Accounts Receivable (PoS)" msgstr "" @@ -2398,8 +2389,8 @@ msgstr "" #. odoo-python #: code:addons/account/wizard/accrued_orders.py:0 msgid "" -"Accrual entry created on %(date)s: %(accrual_entry)s. And" -" its reverse entry: %(reverse_entry)s." +"Accrual entry created on %(date)s: %(accrual_entry)s. And its" +" reverse entry: %(reverse_entry)s." msgstr "" #. module: account @@ -2510,6 +2501,17 @@ msgstr "" msgid "Activity Exception Decoration" msgstr "" +#. module: account +#: model:ir.model.fields,field_description:account.field_account_account__activity_plans_ids +#: model:ir.model.fields,field_description:account.field_account_bank_statement_line__activity_plans_ids +#: model:ir.model.fields,field_description:account.field_account_journal__activity_plans_ids +#: model:ir.model.fields,field_description:account.field_account_move__activity_plans_ids +#: model:ir.model.fields,field_description:account.field_account_payment__activity_plans_ids +#: model:ir.model.fields,field_description:account.field_account_setup_bank_manual_config__activity_plans_ids +#: model:ir.model.fields,field_description:account.field_res_partner_bank__activity_plans_ids +msgid "Activity Plans" +msgstr "" + #. module: account #: model:ir.model.fields,field_description:account.field_account_account__activity_state #: model:ir.model.fields,field_description:account.field_account_bank_statement_line__activity_state @@ -3138,11 +3140,6 @@ msgstr "" msgid "Analytic Distribution Models" msgstr "" -#. module: account -#: model:ir.model.fields,field_description:account.field_account_report__filter_analytic -msgid "Analytic Filter" -msgstr "" - #. module: account #: model:ir.ui.menu,name:account.menu_action_analytic_lines_tree msgid "Analytic Items" @@ -3160,7 +3157,7 @@ msgstr "" #. module: account #: model:ir.model,name:account.model_account_analytic_applicability -msgid "Analytic Plan's Applicabilities" +msgid "Analytic Plan's Applicability" msgstr "" #. module: account @@ -3514,6 +3511,11 @@ msgstr "" msgid "Automatic Entry Default Journal" msgstr "" +#. module: account +#: model:account.fiscal.position,name:account.1_account_fiscal_position_avatax_us +msgid "Automatic Tax Mapping (AvaTax)" +msgstr "" + #. module: account #: model:ir.model,name:account.model_sequence_mixin msgid "Automatic sequence" @@ -3531,6 +3533,11 @@ msgstr "" msgid "Automation" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_665 +msgid "Automobile Expenses" +msgstr "" + #. module: account #. odoo-python #: code:addons/account/models/account_move.py:0 @@ -3654,8 +3661,8 @@ msgstr "" #. odoo-python #: code:addons/account/models/account_journal.py:0 #: code:addons/account/models/chart_template.py:0 -#: model:account.account,name:account.1_bank_journal_default_account_44 -#: model:account.account,name:account.2_bank_journal_default_account_149 +#: model:account.account,name:account.1_bank_journal_default_account_43 +#: model:account.account,name:account.2_bank_journal_default_account_148 #: model:account.journal,name:account.1_bank #: model:account.journal,name:account.2_bank #: model:ir.model.fields,field_description:account.field_account_journal__bank_id @@ -3682,6 +3689,7 @@ msgid "Bank & Cash accounts cannot be shared between companies." msgstr "" #. module: account +#: model:ir.model,name:account.model_res_partner_bank #: model:ir.model.fields,field_description:account.field_account_journal__bank_account_id #: model_terms:ir.ui.view,arch_db:account.view_account_journal_form msgid "Bank Account" @@ -3733,7 +3741,6 @@ msgstr "" #. module: account #: model:ir.actions.act_window,name:account.action_account_supplier_accounts -#: model:ir.model,name:account.model_res_partner_bank msgid "Bank Accounts" msgstr "" @@ -3778,6 +3785,11 @@ msgstr "" msgid "Bank Reconciliation Move preset" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_650 +msgid "Bank Service Charges" +msgstr "" + #. module: account #: model_terms:ir.ui.view,arch_db:account.account_journal_dashboard_kanban_view msgid "Bank Setup" @@ -3930,11 +3942,6 @@ msgstr "" msgid "Based on Payment" msgstr "" -#. module: account -#: model:res.groups,name:account.group_account_basic -msgid "Basic" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_res_company__batch_payment_sequence_id msgid "Batch Payment Sequence" @@ -3945,6 +3952,22 @@ msgstr "" msgid "Batch Payments" msgstr "" +#. module: account +#. odoo-python +#: code:addons/account/wizard/account_move_send_batch_wizard.py:0 +msgid "" +"Batch invoice sending is unavailable. Please, activate the cron to enable " +"batch sending of invoices." +msgstr "" + +#. module: account +#. odoo-python +#: code:addons/account/wizard/account_move_send_batch_wizard.py:0 +msgid "" +"Batch invoice sending is unavailable. Please, contact your system " +"administrator to activate the cron to enable batch sending of invoices." +msgstr "" + #. module: account #. odoo-javascript #: code:addons/account/static/src/components/account_resequence/account_resequence.xml:0 @@ -4075,6 +4098,11 @@ msgstr "" msgid "Body content is the same as the template" msgstr "" +#. module: account +#: model:res.groups,name:account.group_account_user +msgid "Bookkeeper" +msgstr "" + #. module: account #: model:ir.model.fields.selection,name:account.selection__account_report_column__figure_type__boolean #: model:ir.model.fields.selection,name:account.selection__account_report_expression__figure_type__boolean @@ -4109,6 +4137,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_buildings +#: model:account.asset,name:account.2_account_asset_us_buildings msgid "Buildings" msgstr "" @@ -4344,7 +4373,6 @@ msgstr "" #. module: account #. odoo-python #: code:addons/account/models/account_journal.py:0 -#: model:account.account,name:account.1_cash_journal_default_account_160 #: model:ir.model.fields.selection,name:account.selection__account_journal__type__cash #: model_terms:ir.ui.view,arch_db:account.view_account_move_filter #: model_terms:ir.ui.view,arch_db:account.view_account_move_line_filter @@ -4356,16 +4384,6 @@ msgstr "" msgid "Cash Account" msgstr "" -#. module: account -#: model:account.account,name:account.1_cash_journal_default_account_157 -msgid "Cash Bakery" -msgstr "" - -#. module: account -#: model:account.account,name:account.1_cash_journal_default_account_159 -msgid "Cash Bar" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_res_config_settings__tax_exigibility msgid "Cash Basis" @@ -4403,11 +4421,6 @@ msgstr "" msgid "Cash Basis Transition Account" msgstr "" -#. module: account -#: model:account.account,name:account.1_cash_journal_default_account_156 -msgid "Cash Clothes Shop" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_res_company__default_cash_difference_expense_account_id msgid "Cash Difference Expense" @@ -4465,22 +4478,12 @@ msgstr "" msgid "Cash Discount Write-Off Loss Account" msgstr "" -#. module: account -#: model:account.account,name:account.1_cash_journal_default_account_155 -msgid "Cash Furn. Shop" -msgstr "" - #. module: account #: model:ir.actions.act_window,name:account.action_view_bank_statement_tree #: model_terms:ir.ui.view,arch_db:account.account_journal_dashboard_kanban_view msgid "Cash Registers" msgstr "" -#. module: account -#: model:account.account,name:account.1_cash_journal_default_account_158 -msgid "Cash Restaurant" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_res_config_settings__group_cash_rounding msgid "Cash Rounding" @@ -4715,6 +4718,7 @@ msgstr "" #: model:ir.model.fields,field_description:account.field_account_payment__payment_method_code #: model:ir.model.fields,field_description:account.field_account_payment_method__code #: model:ir.model.fields,field_description:account.field_account_payment_method_line__code +#: model:ir.model.fields,field_description:account.field_account_payment_register__payment_method_code #: model:ir.model.fields,field_description:account.field_account_report_line__code #: model_terms:ir.ui.view,arch_db:account.view_account_form #: model_terms:ir.ui.view,arch_db:account.view_account_list @@ -4815,7 +4819,6 @@ msgid "Communication history" msgstr "" #. module: account -#: model:ir.model,name:account.model_res_company #: model:ir.model.fields,field_description:account.field_account_account__company_ids #: model:ir.model.fields,field_description:account.field_account_merge_wizard_line__company_ids msgid "Companies" @@ -4828,6 +4831,7 @@ msgid "Companies that refers to partner" msgstr "" #. module: account +#: model:ir.model,name:account.model_res_company #: model:ir.model.fields,field_description:account.field_account_accrued_orders_wizard__company_id #: model:ir.model.fields,field_description:account.field_account_automatic_entry_wizard__company_id #: model:ir.model.fields,field_description:account.field_account_bank_statement__company_id @@ -4908,11 +4912,6 @@ msgstr "" msgid "Company Fiscal Country Code" msgstr "" -#. module: account -#: model:ir.model.fields,field_description:account.field_res_company__company_registry_placeholder -msgid "Company Registry Placeholder" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_account_move_line__is_storno msgid "Company Storno Accounting" @@ -5047,6 +5046,14 @@ msgstr "" msgid "Consider paying the %(btn_start)sfull amount%(btn_end)s." msgstr "" +#. module: account +#. odoo-python +#: code:addons/account/wizard/account_payment_register.py:0 +msgid "" +"Consider paying the amount with %(btn_start)searly payment " +"discount%(btn_end)s instead." +msgstr "" + #. module: account #: model:account.account,description:account.2_account_account_us_buildings msgid "Construction of buildings and costs associated to buildings" @@ -5104,6 +5111,7 @@ msgstr "" #. module: account #: model:account.account,name:account.1_cost_of_goods_sold #: model:account.account,name:account.2_account_account_us_cost_of_goods_sold +#: model:account.group,name:account.2_us_group_5 #: model:ir.model.fields.selection,name:account.selection__account_move_line__display_type__cogs msgid "Cost of Goods Sold" msgstr "" @@ -5255,11 +5263,6 @@ msgstr "" msgid "Create Payments" msgstr "" -#. module: account -#: model:ir.model,website_form_label:account.model_res_partner -msgid "Create a Customer" -msgstr "" - #. module: account #. odoo-python #: code:addons/account/wizard/account_move_send_wizard.py:0 @@ -6269,6 +6272,12 @@ msgstr "" msgid "Default taxes used when selling the product" msgstr "" +#. module: account +#. odoo-python +#: code:addons/account/models/account_move.py:0 +msgid "Default value for 'company_id' for %(record)s is not an integer" +msgstr "" + #. module: account #: model:account.account,name:account.1_deferred_revenue #: model:account.account,name:account.2_account_account_us_deferred_revenue @@ -7097,11 +7106,6 @@ msgid "" "between the standard price and the bill price." msgstr "" -#. module: account -#: model:ir.model.fields,field_description:account.field_res_config_settings__module_account_reports -msgid "Dynamic Reports" -msgstr "" - #. module: account #: model:ir.model.fields,help:account.field_account_tax_repartition_line__tag_ids_domain msgid "Dynamic domain used for the tag that can be set on tax" @@ -7231,7 +7235,7 @@ msgstr "" #. module: account #: model:ir.model,name:account.model_mail_template -msgid "Email Templates" +msgid "Email Template" msgstr "" #. module: account @@ -7623,6 +7627,12 @@ msgstr "" msgid "Expired" msgstr "" +#. module: account +#. odoo-python +#: code:addons/account/models/account_move.py:0 +msgid "Export ZIP" +msgstr "" + #. module: account #: model:ir.model.fields,field_description:account.field_account_report_column__expression_label msgid "Expression Label" @@ -7938,10 +7948,6 @@ msgstr "" #. module: account #: model:account.account,name:account.1_fixed_assets -msgid "Fixed Asset" -msgstr "" - -#. module: account #: model:ir.model.fields.selection,name:account.selection__account_account__account_type__asset_fixed #: model_terms:ir.ui.view,arch_db:account.view_account_search msgid "Fixed Assets" @@ -8266,12 +8272,16 @@ msgid "Full access, including configuration rights." msgstr "" #. module: account +#. odoo-python +#: code:addons/account/models/chart_template.py:0 +#: model:account.account,name:account.1_transfer_account_id #: model:account.account,name:account.2_transfer_account_id msgid "Funds in Transit" msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_furniture +#: model:account.asset,name:account.2_account_asset_us_furniture msgid "Furniture & Fixtures" msgstr "" @@ -8327,7 +8337,7 @@ msgstr "" #. module: account #. odoo-python #: code:addons/account/models/template_generic_coa.py:0 -msgid "Generic Chart of Accounts" +msgid "Generic (Minimal) Chart of Accounts" msgstr "" #. module: account @@ -8352,6 +8362,12 @@ msgstr "" msgid "Global Lock Date" msgstr "" +#. module: account +#. odoo-python +#: code:addons/account/wizard/account_move_send_batch_wizard.py:0 +msgid "Go to cron configuration" +msgstr "" + #. module: account #. odoo-python #: code:addons/account/models/account_move.py:0 @@ -8476,6 +8492,12 @@ msgstr "" msgid "Has Iban Warning" msgstr "" +#. module: account +#: model:ir.model.fields,field_description:account.field_account_bank_statement__journal_has_invalid_statements +#: model:ir.model.fields,field_description:account.field_account_journal__has_invalid_statements +msgid "Has Invalid Statements" +msgstr "" + #. module: account #: model:ir.model.fields,field_description:account.field_account_account__has_message #: model:ir.model.fields,field_description:account.field_account_bank_statement_line__has_message @@ -9026,6 +9048,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_improvements +#: model:account.asset,name:account.2_account_asset_us_improvements msgid "Improvements" msgstr "" @@ -9242,6 +9265,11 @@ msgstr "" msgid "Installments Switch Html" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_655 +msgid "Insurance Expenses" +msgstr "" + #. module: account #: model:account.account,description:account.2_account_account_us_professional_liability_insurance msgid "Insurance for damage caused to third parties" @@ -9390,10 +9418,8 @@ msgid "" msgstr "" #. module: account -#. odoo-python -#: code:addons/account/models/account_report.py:0 -msgid "" -"Invalid domain for expression '%(label)s' of line '%(line)s': %(formula)s" +#: model_terms:ir.ui.view,arch_db:account.account_journal_dashboard_kanban_view +msgid "Invalid Statement(s)" msgstr "" #. module: account @@ -9403,9 +9429,15 @@ msgid "Invalid fiscal year last day" msgstr "" #. module: account +#. odoo-python +#: code:addons/account/models/account_report.py:0 +msgid "" +"Invalid formula for expression '%(label)s' of line '%(line)s': %(formula)s" +msgstr "" + +#. module: account +#: model:account.account,name:account.1_stock_valuation #: model:account.account,name:account.2_account_account_us_inventory_valuation -#: model:account.journal,name:account.1_inventory_valuation -#: model:account.journal,name:account.2_inventory_valuation msgid "Inventory Valuation" msgstr "" @@ -9677,6 +9709,7 @@ msgstr "" #. module: account #. odoo-python +#: code:addons/account/controllers/download_docs.py:0 #: code:addons/account/controllers/portal.py:0 #: model:ir.actions.act_window,name:account.action_move_out_invoice #: model:ir.actions.act_window,name:account.action_move_out_invoice_type @@ -9724,7 +9757,7 @@ msgstr "" #. module: account #. odoo-python -#: code:addons/account/models/account_move.py:0 +#: code:addons/account/models/account_move_send.py:0 msgid "Invoices in error" msgstr "" @@ -9741,13 +9774,13 @@ msgstr "" #. module: account #. odoo-python -#: code:addons/account/models/account_move.py:0 +#: code:addons/account/models/account_move_send.py:0 msgid "Invoices sent" msgstr "" #. module: account #. odoo-python -#: code:addons/account/models/account_move.py:0 +#: code:addons/account/models/account_move_send.py:0 msgid "Invoices sent successfully." msgstr "" @@ -9783,6 +9816,11 @@ msgstr "" msgid "Invoicing" msgstr "" +#. module: account +#: model:res.groups,name:account.group_account_basic +msgid "Invoicing & Banks" +msgstr "" + #. module: account #: model:ir.model.fields.selection,name:account.selection__account_invoice_report__payment_state__invoicing_legacy #: model:ir.model.fields.selection,name:account.selection__account_move__payment_state__invoicing_legacy @@ -10598,13 +10636,6 @@ msgstr "" msgid "Liquidity" msgstr "" -#. module: account -#. odoo-python -#: code:addons/account/models/chart_template.py:0 -#: model:account.account,name:account.1_transfer_account_id -msgid "Liquidity Transfer" -msgstr "" - #. module: account #: model:ir.model.fields,help:account.field_account_tax__original_tax_ids msgid "" @@ -10622,6 +10653,11 @@ msgstr "" msgid "Loan Interest Expense" msgstr "" +#. module: account +#: model:ir.model.fields,field_description:account.field_res_config_settings__module_account_loan_extract +msgid "Loans Digitization" +msgstr "" + #. module: account #: model:account.account,description:account.2_account_account_us_property_tax msgid "Local taxes that have to be paid due to the ownership of property" @@ -10705,6 +10741,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_machines +#: model:account.asset,name:account.2_account_asset_us_machines msgid "Machines & Tools" msgstr "" @@ -10876,6 +10913,11 @@ msgstr "" msgid "Mark as fully paid" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_67 +msgid "Marketing Expenses" +msgstr "" + #. module: account #: model:ir.model.fields.selection,name:account.selection__account_reconcile_model__match_label__match_regex msgid "Match Regex" @@ -11376,17 +11418,6 @@ msgstr "" msgid "Next Activity" msgstr "" -#. module: account -#: model:ir.model.fields,field_description:account.field_account_account__activity_calendar_event_id -#: model:ir.model.fields,field_description:account.field_account_bank_statement_line__activity_calendar_event_id -#: model:ir.model.fields,field_description:account.field_account_journal__activity_calendar_event_id -#: model:ir.model.fields,field_description:account.field_account_move__activity_calendar_event_id -#: model:ir.model.fields,field_description:account.field_account_payment__activity_calendar_event_id -#: model:ir.model.fields,field_description:account.field_account_setup_bank_manual_config__activity_calendar_event_id -#: model:ir.model.fields,field_description:account.field_res_partner_bank__activity_calendar_event_id -msgid "Next Activity Calendar Event" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_account_account__activity_date_deadline #: model:ir.model.fields,field_description:account.field_account_bank_statement_line__activity_date_deadline @@ -11811,6 +11842,11 @@ msgstr "" msgid "Off-Balance Sheet" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_651 +msgid "Office Expenses" +msgstr "" + #. module: account #: model:account.account.tag,name:account.demo_office_furniture_account msgid "Office Furniture" @@ -11903,7 +11939,7 @@ msgstr "" #. module: account #. odoo-python -#: code:addons/account/models/account_move.py:0 +#: code:addons/account/models/account_move_send.py:0 msgid "One or more invoices couldn't be processed." msgstr "" @@ -11991,7 +12027,7 @@ msgstr "" #. module: account #. odoo-python -#: code:addons/account/models/account_move.py:0 +#: code:addons/account/models/account_move_send.py:0 #: model:ir.model.fields.selection,name:account.selection__account_invoice_report__state__posted msgid "Open" msgstr "" @@ -12054,6 +12090,11 @@ msgstr "" msgid "Operating Activities" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_6 +msgid "Operating Expenses" +msgstr "" + #. module: account #: model_terms:ir.ui.view,arch_db:account.view_account_reconcile_model_form msgid "Operation Templates" @@ -12155,6 +12196,7 @@ msgid "Other Employees Benefits" msgstr "" #. module: account +#: model:account.group,name:account.2_us_group_7 #: model:ir.model.fields.selection,name:account.selection__account_account__account_type__expense_other msgid "Other Expenses" msgstr "" @@ -12173,6 +12215,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_other_property +#: model:account.asset,name:account.2_account_asset_us_other_property msgid "Other property" msgstr "" @@ -12447,12 +12490,6 @@ msgstr "" msgid "Partner" msgstr "" -#. module: account -#: model:ir.model.fields,field_description:account.field_res_partner__partner_company_registry_placeholder -#: model:ir.model.fields,field_description:account.field_res_users__partner_company_registry_placeholder -msgid "Partner Company Registry Placeholder" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_res_partner__contract_ids #: model:ir.model.fields,field_description:account.field_res_users__contract_ids @@ -12563,6 +12600,7 @@ msgstr "" #. odoo-python #: code:addons/account/models/account_move.py:0 #: code:addons/account/models/account_payment.py:0 +#: model:ir.model,name:account.model_account_payment #: model:ir.model.fields,field_description:account.field_account_bank_statement_line__origin_payment_id #: model:ir.model.fields,field_description:account.field_account_move__origin_payment_id #: model_terms:ir.ui.view,arch_db:account.view_account_payment_search @@ -12627,6 +12665,7 @@ msgid "Payment Items" msgstr "" #. module: account +#: model:ir.model,name:account.model_account_payment_method #: model:ir.model.fields,field_description:account.field_account_payment__payment_method_line_id #: model:ir.model.fields,field_description:account.field_account_payment_method_line__payment_method_id #: model:ir.model.fields,field_description:account.field_account_payment_register__payment_method_line_id @@ -12638,6 +12677,7 @@ msgid "Payment Method" msgstr "" #. module: account +#: model:ir.model,name:account.model_account_payment_method_line #: model_terms:ir.ui.view,arch_db:account.view_account_payment_search msgid "Payment Method Line" msgstr "" @@ -12653,8 +12693,6 @@ msgid "Payment Method:" msgstr "" #. module: account -#: model:ir.model,name:account.model_account_payment_method -#: model:ir.model,name:account.model_account_payment_method_line #: model_terms:ir.ui.view,arch_db:account.view_account_journal_form msgid "Payment Methods" msgstr "" @@ -12809,7 +12847,6 @@ msgstr "" #: code:addons/account/models/account_move.py:0 #: code:addons/account/wizard/account_payment_register.py:0 #: model:ir.actions.act_window,name:account.action_account_all_payments -#: model:ir.model,name:account.model_account_payment #: model:ir.model.fields,field_description:account.field_account_move__payment_ids #: model:ir.ui.menu,name:account.menu_action_account_payments_payable #: model:ir.ui.menu,name:account.menu_action_account_payments_receivable @@ -12834,6 +12871,17 @@ msgid "" "Payments related to partners with no bank account specified will be skipped." msgstr "" +#. module: account +#: model:ir.model.fields,help:account.field_account_bank_statement_line__reconciled_payment_ids +#: model:ir.model.fields,help:account.field_account_move__reconciled_payment_ids +msgid "Payments that have been reconciled with this invoice." +msgstr "" + +#. module: account +#: model:account.group,name:account.2_us_group_61 +msgid "Payroll Expenses" +msgstr "" + #. module: account #: model:account.account,name:account.2_account_account_us_payroll_tax msgid "Payroll Tax" @@ -13084,13 +13132,12 @@ msgid "" msgstr "" #. module: account -#: model:account.account,name:account.1_prepaid_expenses +#: model:account.account,name:account.1_prepayments #: model:account.account,name:account.2_account_account_us_prepaid_expenses msgid "Prepaid Expenses" msgstr "" #. module: account -#: model:account.account,name:account.1_prepayments #: model:account.account,name:account.2_account_account_us_prepayments #: model:ir.model.fields.selection,name:account.selection__account_account__account_type__asset_prepayments msgid "Prepayments" @@ -13271,6 +13318,11 @@ msgstr "" msgid "Professional %" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_62 +msgid "Professional Fees" +msgstr "" + #. module: account #: model:account.account,name:account.2_account_account_us_professional_insurance msgid "Professional Insurance" @@ -13492,20 +13544,6 @@ msgstr "" msgid "RD Expenses" msgstr "" -#. module: account -#: model:ir.model.fields,field_description:account.field_account_account__rating_ids -#: model:ir.model.fields,field_description:account.field_account_bank_statement_line__rating_ids -#: model:ir.model.fields,field_description:account.field_account_journal__rating_ids -#: model:ir.model.fields,field_description:account.field_account_move__rating_ids -#: model:ir.model.fields,field_description:account.field_account_payment__rating_ids -#: model:ir.model.fields,field_description:account.field_account_reconcile_model__rating_ids -#: model:ir.model.fields,field_description:account.field_account_setup_bank_manual_config__rating_ids -#: model:ir.model.fields,field_description:account.field_account_tax__rating_ids -#: model:ir.model.fields,field_description:account.field_res_company__rating_ids -#: model:ir.model.fields,field_description:account.field_res_partner_bank__rating_ids -msgid "Ratings" -msgstr "" - #. module: account #: model:account.account,name:account.2_account_account_us_raw_materials msgid "Raw Materials" @@ -13516,6 +13554,11 @@ msgstr "" msgid "Re-Sequence" msgstr "" +#. module: account +#: model:res.groups,name:account.group_account_readonly +msgid "Read-only" +msgstr "" + #. module: account #: model:ir.model.fields,field_description:account.field_account_lock_exception__reason msgid "Reason" @@ -13625,6 +13668,12 @@ msgstr "" msgid "Reconciled Lines Excluding Exchange Diff" msgstr "" +#. module: account +#: model:ir.model.fields,field_description:account.field_account_bank_statement_line__reconciled_payment_ids +#: model:ir.model.fields,field_description:account.field_account_move__reconciled_payment_ids +msgid "Reconciled Payments" +msgstr "" + #. module: account #: model:ir.model.fields,field_description:account.field_account_payment__reconciled_statement_line_ids msgid "Reconciled Statement Lines" @@ -13689,11 +13738,6 @@ msgid "" " name, etc." msgstr "" -#. module: account -#: model_terms:ir.ui.view,arch_db:account.view_move_form -msgid "Refresh currency rate to the invoice date" -msgstr "" - #. module: account #. odoo-javascript #: code:addons/account/static/src/components/fetch_einvoices/fetch_einvoices_cog.js:0 @@ -13915,6 +13959,11 @@ msgstr "" msgid "Resequence" msgstr "" +#. module: account +#: model_terms:ir.ui.view,arch_db:account.view_move_form +msgid "Reset the currency rate to the default accordingly to the invoice date" +msgstr "" + #. module: account #: model_terms:ir.ui.view,arch_db:account.view_account_payment_form #: model_terms:ir.ui.view,arch_db:account.view_move_form @@ -13992,6 +14041,7 @@ msgstr "" #. module: account #. odoo-python #: code:addons/account/wizard/accrued_orders.py:0 +#: model:account.group,name:account.2_us_group_4 #: model:ir.model.fields,field_description:account.field_digest_digest__kpi_account_total_revenue #: model:ir.model.fields.selection,name:account.selection__account_automatic_entry_wizard__account_type__income msgid "Revenue" @@ -14586,7 +14636,9 @@ msgid "Selected Payment Method Codes" msgstr "" #. module: account +#: model:ir.model.fields,field_description:account.field_account_bank_statement_line__is_self_billing #: model:ir.model.fields,field_description:account.field_account_journal__is_self_billing +#: model:ir.model.fields,field_description:account.field_account_move__is_self_billing #: model_terms:ir.ui.view,arch_db:account.report_invoice_document msgid "Self Billing" msgstr "" @@ -14723,7 +14775,7 @@ msgstr "" #. module: account #. odoo-python -#: code:addons/account/models/account_move.py:0 +#: code:addons/account/models/account_move_send.py:0 msgid "Sent invoices" msgstr "" @@ -14948,11 +15000,6 @@ msgstr "" msgid "Show Aba Routing" msgstr "" -#. module: account -#: model:res.groups,name:account.group_account_readonly -msgid "Show Accounting Features - Readonly" -msgstr "" - #. module: account #: model:ir.model.fields,field_description:account.field_res_partner__show_credit_limit #: model:ir.model.fields,field_description:account.field_res_users__show_credit_limit @@ -14981,11 +15028,6 @@ msgstr "" msgid "Show E-Invoice Status Buttons" msgstr "" -#. module: account -#: model:res.groups,name:account.group_account_user -msgid "Show Full Accounting Features" -msgstr "" - #. module: account #: model:res.groups,name:account.group_account_secured msgid "Show Inalterability Features" @@ -15316,11 +15358,6 @@ msgstr "" msgid "Step completed!" msgstr "" -#. module: account -#: model:account.account,name:account.1_stock_valuation -msgid "Stock Valuation" -msgstr "" - #. module: account #: model_terms:ir.ui.view,arch_db:account.res_config_settings_view_form msgid "Storno Accounting" @@ -15899,6 +15936,7 @@ msgstr "" #. module: account #. odoo-python #: code:addons/account/models/account_account.py:0 +#: model:account.group,name:account.2_us_group_8 #: model:ir.actions.act_window,name:account.action_tax_form #: model:ir.model.fields,field_description:account.field_account_fiscal_position__tax_ids #: model:ir.model.fields,field_description:account.field_account_move_line__tax_ids @@ -16019,6 +16057,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_technology +#: model:account.asset,name:account.2_account_asset_us_technology msgid "Technology" msgstr "" @@ -17289,7 +17328,9 @@ msgid "This journal entry has been secured." msgstr "" #. module: account +#: model:ir.model.fields,help:account.field_account_bank_statement_line__is_self_billing #: model:ir.model.fields,help:account.field_account_journal__is_self_billing +#: model:ir.model.fields,help:account.field_account_move__is_self_billing msgid "" "This journal is for self-billing invoices. Invoices will be created using a " "different sequence per partner." @@ -17748,6 +17789,11 @@ msgstr "" msgid "Transfer to %s" msgstr "" +#. module: account +#: model:account.group,name:account.2_us_group_661 +msgid "Travel Expenses" +msgstr "" + #. module: account #: model:account.account,description:account.2_account_account_us_public_transportation msgid "Travel expenses done using public transit (metro, bus, tramways, ...)" @@ -17774,6 +17820,10 @@ msgid "True" msgstr "" #. module: account +#. odoo-javascript +#: code:addons/account/static/src/components/many2many_tags_banks/many2many_tags_banks.xml:0 +#: code:addons/account/static/src/components/manyone_banks/many2one_banks.js:0 +#: code:addons/account/static/src/components/manyone_banks/many2one_banks.xml:0 #: model_terms:ir.ui.view,arch_db:account.view_partner_bank_search_inherit msgid "Trusted" msgstr "" @@ -17998,6 +18048,10 @@ msgid "Untaxed amount" msgstr "" #. module: account +#. odoo-javascript +#: code:addons/account/static/src/components/many2many_tags_banks/many2many_tags_banks.xml:0 +#: code:addons/account/static/src/components/manyone_banks/many2one_banks.js:0 +#: code:addons/account/static/src/components/manyone_banks/many2one_banks.xml:0 #: model_terms:ir.ui.view,arch_db:account.view_partner_bank_search_inherit msgid "Untrusted" msgstr "" @@ -18224,6 +18278,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_utilities +#: model:account.group,name:account.2_us_group_64 msgid "Utilities" msgstr "" @@ -18336,6 +18391,7 @@ msgstr "" #. module: account #: model:account.account,name:account.2_account_account_us_vehicles #: model:account.account,name:account.2_account_account_us_vehicles_expense +#: model:account.asset,name:account.2_account_asset_us_vehicles msgid "Vehicles" msgstr "" @@ -18475,12 +18531,6 @@ msgid "" "vendor ?" msgstr "" -#. module: account -#. odoo-javascript -#: code:addons/account/static/src/components/json_checkboxes/json_checkboxes.xml:0 -msgid "Warning" -msgstr "" - #. module: account #. odoo-python #: code:addons/account/models/account_move.py:0 @@ -18697,6 +18747,14 @@ msgid "" "3/ select them all and post or delete them through the action menu" msgstr "" +#. module: account +#. odoo-python +#: code:addons/account/models/account_bank_statement_line.py:0 +msgid "" +"You can not delete a transaction from a valid statement.\n" +"If you want to delete it, please remove the statement first." +msgstr "" + #. module: account #. odoo-python #: code:addons/account/models/account_move_line.py:0 @@ -19397,7 +19455,20 @@ msgstr "" #. module: account #. odoo-python #: code:addons/account/models/account_move.py:0 -msgid "[Partner name]" +msgid "[Partner id]" +msgstr "" + +#. module: account +#: model:res.groups,comment:account.group_account_user +msgid "" +"access to all Accounting features, including reporting, asset management, " +"analytic accounting, without configuration rights." +msgstr "" + +#. module: account +#: model:res.groups,comment:account.group_account_readonly +msgid "" +"access to all the accounting data but in readonly mode, no actions allowed." msgstr "" #. module: account @@ -19416,6 +19487,11 @@ msgstr "" msgid "activate the currency of the invoice" msgstr "" +#. module: account +#: model:res.groups,comment:account.group_account_basic +msgid "adds the accounting dashboard, bank management and follow-up reports." +msgstr "" + #. module: account #: model_terms:ir.ui.view,arch_db:account.view_account_journal_form msgid "alias" @@ -19824,12 +19900,6 @@ msgstr "" msgid "to create the taxes for this country." msgstr "" -#. module: account -#. odoo-python -#: code:addons/account/models/res_partner_bank.py:0 -msgid "trusted" -msgstr "" - #. module: account #. odoo-javascript #: code:addons/account/static/src/components/bill_guide/bill_guide.xml:0 @@ -19855,12 +19925,6 @@ msgstr "" msgid "until" msgstr "" -#. module: account -#. odoo-python -#: code:addons/account/models/res_partner_bank.py:0 -msgid "untrusted" -msgstr "" - #. module: account #: model_terms:ir.ui.view,arch_db:account.view_account_payment_register_form msgid "untrusted bank accounts" diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 29216c839476a..e1e4b2c435044 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -94,6 +94,10 @@ class AccountBankStatement(models.Model): search='_search_is_valid', ) + journal_has_invalid_statements = fields.Boolean( + related='journal_id.has_invalid_statements', + ) + problem_description = fields.Text( compute='_compute_problem_description', ) diff --git a/addons/account/models/account_bank_statement_line.py b/addons/account/models/account_bank_statement_line.py index 232c55252419d..2efd547ef32e8 100644 --- a/addons/account/models/account_bank_statement_line.py +++ b/addons/account/models/account_bank_statement_line.py @@ -474,6 +474,12 @@ def action_undo_reconciliation(self): # HELPERS # ------------------------------------------------------------------------- + @api.ondelete(at_uninstall=False) + def _check_allow_unlink(self): + if self.statement_id.filtered(lambda stmt: stmt.is_valid and stmt.is_complete): + raise UserError(_("You can not delete a transaction from a valid statement.\n" + "If you want to delete it, please remove the statement first.")) + def _find_or_create_bank_account(self): self.ensure_one() diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py index 1d424b152efe0..b7e8f2d732c6d 100644 --- a/addons/account/models/account_journal.py +++ b/addons/account/models/account_journal.py @@ -264,6 +264,7 @@ def _get_default_account_domain(self): ) accounting_date = fields.Date(compute='_compute_accounting_date') display_alias_fields = fields.Boolean(compute='_compute_display_alias_fields') + has_invalid_statements = fields.Boolean(compute='_compute_has_invalid_statements') show_fetch_in_einvoices_button = fields.Boolean( string="Show E-Invoice Buttons", @@ -287,6 +288,16 @@ def _get_default_account_domain(self): 'Journal codes must be unique per company.', ) + def _compute_has_invalid_statements(self): + journals_with_invalid_statements = self.env['account.bank.statement'].search([ + ('journal_id', 'in', self.ids), + '|', + ('is_valid', '=', False), + ('is_complete', '=', False), + ]).journal_id + journals_with_invalid_statements.has_invalid_statements = True + (self - journals_with_invalid_statements).has_invalid_statements = False + def _compute_display_alias_fields(self): self.display_alias_fields = self.env['mail.alias.domain'].search_count([], limit=1) diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 43d72250f8aa5..2d9dc80172c09 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -51,6 +51,7 @@ def _compute_last_bank_statement(self): SELECT id, company_id FROM account_bank_statement WHERE journal_id = journal.id + AND first_line_index IS NOT NULL ORDER BY first_line_index DESC LIMIT 1 ) statement ON TRUE @@ -526,6 +527,11 @@ def _fill_bank_cash_dashboard_data(self, dashboard_data): 'image': '/account/static/src/img/bank.svg' if journal.type in ('bank', 'credit') else '/web/static/img/rfq.svg', 'text': _('Drop to import transactions'), } + last_statement_visible = ( + not journal.company_id.fiscalyear_lock_date + or journal.last_statement_id.date + and journal.company_id.fiscalyear_lock_date < journal.last_statement_id.date + ) dashboard_data[journal.id].update({ 'number_to_check': number_to_check, @@ -538,6 +544,8 @@ def _fill_bank_cash_dashboard_data(self, dashboard_data): 'nb_lines_outstanding_pay_account_balance': has_outstanding, 'last_balance': currency.format(journal.last_statement_id.balance_end_real), 'last_statement_id': journal.last_statement_id.id, + 'last_statement_visible': last_statement_visible, + 'has_invalid_statements': journal.has_invalid_statements, 'bank_statements_source': journal.bank_statements_source, 'is_sample_data': journal.has_statement_lines, 'nb_misc_operations': number_misc, @@ -802,6 +810,7 @@ def _get_journal_dashboard_bank_running_balance(self): FROM account_bank_statement WHERE journal_id = journal.id AND company_id = ANY(%s) + AND first_line_index IS NOT NULL ORDER BY date DESC, id DESC LIMIT 1 ) statement ON TRUE @@ -1119,6 +1128,10 @@ def open_bank_difference_action(self): } return action + def open_invalid_statements_action(self): + self.ensure_one() + return self.env["ir.actions.act_window"]._for_xml_id('account.action_bank_statement_tree') + def _show_sequence_holes(self, domain): return { 'type': 'ir.actions.act_window', diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index fd1b8b9adbe8f..772c31ad587e5 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -257,7 +257,7 @@ - +
    Last Statement @@ -290,6 +290,16 @@
    + + + From f8392500d6c2d8b02ea937a9c006895869bd86fb Mon Sep 17 00:00:00 2001 From: nees-odoo Date: Wed, 29 Oct 2025 11:31:11 +0530 Subject: [PATCH 020/673] [FIX] mail: prevent traceback when adding emojis to a poll option Purpose of this commit: Fix the traceback that occurs when adding emojis to a poll. Steps to Reproduce: - Start a poll - Try adding emojis to a poll option Before this commit, the emoji picker element was correctly referenced in the component, but was not used properly inside the useEmojiPicker hook. As a result, the code attempted to access an undefined reference, causing an error when interacting with the picker. This commit ensures that the correct reference is used within useEmojiPicker, resolving the issue. closes odoo/odoo#233560 Signed-off-by: Matthieu Stockbauer (tsm) --- .../core/common/create_poll_option_dialog.js | 2 +- addons/mail/static/tests/poll/poll.test.js | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 addons/mail/static/tests/poll/poll.test.js diff --git a/addons/mail/static/src/core/common/create_poll_option_dialog.js b/addons/mail/static/src/core/common/create_poll_option_dialog.js index 21cad39b29fab..0378209b28d82 100644 --- a/addons/mail/static/src/core/common/create_poll_option_dialog.js +++ b/addons/mail/static/src/core/common/create_poll_option_dialog.js @@ -33,7 +33,7 @@ export class CreatePollOptionDialog extends Component { this.props.model.label = firstPart + str + secondPart; this.selection.moveCursor((firstPart + str).length); if (!this.ui.isSmall) { - this.ref.el.focus(); + this.pickerRef.el.focus(); } }, }); diff --git a/addons/mail/static/tests/poll/poll.test.js b/addons/mail/static/tests/poll/poll.test.js new file mode 100644 index 0000000000000..eb473fe4d59ae --- /dev/null +++ b/addons/mail/static/tests/poll/poll.test.js @@ -0,0 +1,25 @@ +import { + click, + contains, + defineMailModels, + openDiscuss, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { describe, test } from "@odoo/hoot"; + +describe.current.tags("desktop"); +defineMailModels(); + +test("can add emojis to a poll option", async () => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + await start(); + await openDiscuss(channelId); + await click(".o-mail-Composer button[title='More Actions']"); + await click(".o-dropdown-item:contains('Start a Poll')"); + await contains(".modal-header", { text: "Create a poll" }); + await click(".o-mail-CreatePollOptionDialog:first .fa-smile-o"); + await click(".o-Emoji:contains('😀')"); + await contains(".o-mail-CreatePollOptionDialog input:eq(0)", { value: "😀" }); +}); From c8d81a737c49049654b6a4bd1d3d6a10a1cf041a Mon Sep 17 00:00:00 2001 From: SaraBriki Date: Thu, 4 Sep 2025 10:06:49 +0200 Subject: [PATCH 021/673] [IMP] hr: format private and emergency phone numbers on employee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up of: https://github.com/odoo/odoo/pull/224224 Behavior before PR: Employee private_phone and emergency_phone are saved as entered, without automatic formatting. Only work_phone and mobile_phone benefit from automatic formatting (added in the previous PR). Behavior after PR: `private_phone` and `emergency_phone` are now also formatted according to the employee’s private country (from the current version). Solution: Add an onchange method to hr.employee to format `private_phone` and `emergency_phone` automatically when edited in the UI, using `_phone_format()` from phone_validation. task-5063306 closes odoo/odoo#225482 Signed-off-by: Yannick Tivisse (yti) --- addons/hr/models/hr_employee.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/addons/hr/models/hr_employee.py b/addons/hr/models/hr_employee.py index 2b9dda106bf86..a3ce4190cf7a6 100644 --- a/addons/hr/models/hr_employee.py +++ b/addons/hr/models/hr_employee.py @@ -396,6 +396,16 @@ def check_no_existing_contract(self, date): "Please select a date outside existing contracts", format_date_abbr(self.env, date))) + @api.onchange('private_phone') + def _onchange_private_phone_validation(self): + if self.private_phone: + self.private_phone = self._phone_format(fname="private_phone", force_format="INTERNATIONAL") or self.private_phone + + @api.onchange('emergency_phone') + def _onchange_emergency_phone_validation(self): + if self.emergency_phone: + self.emergency_phone = self._phone_format(fname="emergency_phone", force_format="INTERNATIONAL") or self.emergency_phone + @api.onchange('contract_template_id') def _onchange_contract_template_id(self): if self.contract_template_id: From ca47a9e24cedca1ce6c8b331e950e49f9da01e04 Mon Sep 17 00:00:00 2001 From: Florian Vranckx Date: Tue, 15 Jul 2025 14:57:42 +0000 Subject: [PATCH 022/673] [FIX] hr_expense: Broken access rights The state changes right check was only done on specific method but it wasn't check at write level. Which allowed to bypass it. The record rule on hr_expense_user without a check on the state is in draft allow to change data on approved expense sheets. closes odoo/odoo#233097 X-original-commit: 07626ca770cb05a4c275783c1c96b62c6006affb Signed-off-by: Olivier Colson (oco) Signed-off-by: Julien Alardot (jual) --- .../tests/test_expenses_access_rights.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/addons/hr_expense/tests/test_expenses_access_rights.py b/addons/hr_expense/tests/test_expenses_access_rights.py index 4ef341ef0c667..22f29400188d4 100644 --- a/addons/hr_expense/tests/test_expenses_access_rights.py +++ b/addons/hr_expense/tests/test_expenses_access_rights.py @@ -28,6 +28,25 @@ def test_expense_access_rights(self): 'price_unit': 1, }) + expense = self.env['hr.expense'].with_user(self.expense_user_employee).create({ + 'name': 'expense_1', + 'date': '2016-01-01', + 'product_id': self.product_a.id, + 'quantity': 10.0, + 'employee_id': self.expense_employee.id, + }) + + # The expense employee shouldn't be able to bypass the submit state. + with self.assertRaises(UserError): + expense.with_user(self.expense_user_employee).state = 'approved' + + expense.with_user(self.expense_user_employee).action_submit() + self.assertEqual(expense.state, 'submitted') + + # Employee can also revert from the submitted state to a draft state + expense.with_user(self.expense_user_employee).action_reset() + self.assertEqual(expense.state, 'draft') + def test_expense_access_rights_user(self): # The expense base user (without other rights) is able to create and read sheet From e0aecbdcdf9d3c2105f416a40671ff73932ca4ee Mon Sep 17 00:00:00 2001 From: metu-odoo Date: Thu, 30 Oct 2025 05:10:04 +0000 Subject: [PATCH 023/673] [FIX] website_sale: make "Add to Cart" translatable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t-out is used for variables, so static text inside it isn’t picked up for translation. - Replaced it with a direct so the text is automatically translatable Steps to reproduce before the fix: Go to Website → change the website language to any non-English language. Add any product to the cart. A Product Configuration wizard (for options) will open. The “Add to Cart” button for options is not translated. closes odoo/odoo#233759 X-original-commit: 3934eebbb7070c3eaa4854b7b725700e4a336b36 Signed-off-by: Louis Tinel (loti) --- addons/website_sale/static/src/js/product/product.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/website_sale/static/src/js/product/product.xml b/addons/website_sale/static/src/js/product/product.xml index b28f3b799bd4b..37fa65d1aeaf7 100644 --- a/addons/website_sale/static/src/js/product/product.xml +++ b/addons/website_sale/static/src/js/product/product.xml @@ -17,8 +17,8 @@ > - - 'Add to Cart' + + Add to Cart + + + @@ -33,7 +38,7 @@ The cash machine must be emptied, the following coins/bills are full:
    • - +
    @@ -42,7 +47,7 @@ The cash machine is running low on the following coins/bills:
    • - +
    @@ -51,7 +56,7 @@ Consider emptying the cash machine, the following coins/bills are nearly full:
    • - +
    diff --git a/addons/pos_glory_cash/static/src/utils/constants.js b/addons/pos_glory_cash/static/src/utils/constants.js index ad52187b8f26d..c5d530c7da3c8 100644 --- a/addons/pos_glory_cash/static/src/utils/constants.js +++ b/addons/pos_glory_cash/static/src/utils/constants.js @@ -42,6 +42,10 @@ export const XML_REQUESTS = { requestName: "ChangeCancelRequest", responseName: "ChangeCancelResponse", }, + collect: { + requestName: "CollectRequest", + responseName: "CollectResponse", + }, setDateAndTime: { requestName: "AdjustTimeRequest", responseName: "AdjustTimeResponse", @@ -103,10 +107,14 @@ export const GLORY_STATUS_STRING = { STARTING_PAYMENT: _t("Starting payment"), WAITING_PAYMENT: _t("Waiting for insertion of cash"), COUNTING: _t("Counting"), + COUNTING_REPLENISHMENT: _t("Counting replenished cash"), + COLLECTING: _t("Collection in progress"), ERROR: _t("Error"), DISPENSING: _t("Dispensing"), WAITING_CASH_IN_REMOVE: _t("Waiting for cash to be removed"), WAITING_CASH_OUT_REMOVE: _t("Waiting for cash to be removed"), + WAITING_COFT_REMOVAL: _t("Waiting for coin overflow removal"), + WAITING_REPLENISHMENT: _t("Waiting for cash to be replenished"), RESETTING: _t("Resetting"), CANCELLING: _t("Cancelling payment"), CALCULATING_CHANGE: _t("Calculating change"), @@ -123,6 +131,7 @@ export const GLORY_RESULT = { 1: "CANCEL", 2: "RESET", 3: "OCCUPIED_BY_OTHER", + 4: "OCCUPATION_NOT_AVAILABLE", 5: "NOT_OCCUPIED", 6: "DESIGNATION_SHORTAGE", 9: "CANCEL_CHANGE_SHORTAGE", @@ -137,6 +146,7 @@ export const GLORY_RESULT = { 43: "EXCHANGE_RATE_ERROR", 44: "COUNTED_CATEGORY_2_3", 96: "DUPLICATE_TRANSACTION", + 98: "PARAMETER_ERROR", 99: "PROGRAM_ERROR", 100: "DEVICE_ERROR", }; diff --git a/addons/pos_glory_cash/static/src/utils/glory_xml.js b/addons/pos_glory_cash/static/src/utils/glory_xml.js index 27bcfcae5e3b3..37989e88482b3 100644 --- a/addons/pos_glory_cash/static/src/utils/glory_xml.js +++ b/addons/pos_glory_cash/static/src/utils/glory_xml.js @@ -1,4 +1,5 @@ import { parseXML } from "@web/core/utils/xml"; +import { GLORY_RESULT } from "./constants"; /** * @param {Blob} xmlBlob @@ -58,3 +59,68 @@ export const makeGloryHeader = (sequenceNumber, sessionId) => { }, ]; }; + +/** + * Takes an XML response and returns a status + * string e.g. "SUCCESS" or "CHANGE_SHORTAGE" + * + * @param {Element} xmlResponse + * @returns {string} + */ +export function parseGloryResult(xmlResponse) { + const resultString = GLORY_RESULT[xmlResponse.getAttribute("result")]; + if (!resultString) { + throw new Error("Not a valid Glory XML response"); + } + + return resultString; +} + +/** + * Takes an XML response containing the verification status + * of the Glory machine, and returns a number from 0-3 + * corresponding to the required verification action: + * + * `0`: No verification needed + * + * `1`: Notes and coins need verification + * + * `2`: Notes need verification + * + * `3`: Coins need verification + * + * This result can be passed directly into a `CollectRequest` + * to trigger the verification process. + * + * @param {Element} xmlResponse + * @returns {0 | 1 | 2 | 3} + */ +export function parseVerificationInfo(xmlResponse) { + const denominationInfos = Array.from( + xmlResponse.getElementsByTagName("RequireVerifyDenomination") + ); + const collectionContainerInfos = Array.from( + xmlResponse.getElementsByTagName("RequireVerifyCollectionContainer") + ); + const mixStackerInfos = Array.from(xmlResponse.getElementsByTagName("RequireVerifyMixStacker")); + const allInfos = [...denominationInfos, ...collectionContainerInfos, ...mixStackerInfos]; + + const notesRequireVerify = allInfos.some( + (info) => info.getAttribute("devid") === "1" && info.getAttribute("val") === "1" + ); + const coinsRequireVerify = allInfos.some( + (info) => info.getAttribute("devid") === "2" && info.getAttribute("val") === "1" + ); + + if (notesRequireVerify && coinsRequireVerify) { + return 1; + } + if (notesRequireVerify) { + return 2; + } + if (coinsRequireVerify) { + return 3; + } + + return 0; +} diff --git a/addons/pos_glory_cash/static/src/utils/socket_io.js b/addons/pos_glory_cash/static/src/utils/socket_io.js index fd70e8fc8ff2c..d360e3bd5fae7 100644 --- a/addons/pos_glory_cash/static/src/utils/socket_io.js +++ b/addons/pos_glory_cash/static/src/utils/socket_io.js @@ -18,6 +18,8 @@ const MSG_TYPES = { BINARY_ACK: "6", }; +const CONNECTION_TIMEOUT_MS = 10000; + export class SocketIoService { /** * @param {string} url @@ -35,6 +37,7 @@ export class SocketIoService { this.websocket = null; this.socketId = null; this.pingIntervalId = null; + this.pongTimeoutIds = []; this.callbacks = callbacks; this._connect(url); @@ -57,6 +60,7 @@ export class SocketIoService { if (this.pingIntervalId) { clearInterval(this.pingIntervalId); } + this.callbacks.onClose(); setTimeout(() => this._connect(url), 5000); }; this.websocket.onmessage = (event) => this._onMessageReceived(event.data); @@ -69,10 +73,13 @@ export class SocketIoService { _handleOpenMessage(data) { const info = JSON.parse(data); this.socketId = info.sid; - this.pingIntervalId = setInterval( - () => this.websocket.send(PACKET_TYPES.PING), - info.pingInterval - ); + this.pingIntervalId = setInterval(() => { + this.websocket.send(PACKET_TYPES.PING); + const pongTimeoutId = setTimeout(() => { + this.websocket.close(); + }, CONNECTION_TIMEOUT_MS); + this.pongTimeoutIds.push(pongTimeoutId); + }, info.pingInterval); } async _handleMessage(data) { @@ -104,6 +111,11 @@ export class SocketIoService { const packetData = data.slice(1); if (packetType === PACKET_TYPES.OPEN) { this._handleOpenMessage(packetData); + } else if (packetType === PACKET_TYPES.PONG) { + for (const timeoutId of this.pongTimeoutIds) { + clearTimeout(timeoutId); + } + this.pongTimeoutIds = []; } else if (packetType === PACKET_TYPES.MESSAGE) { this._handleMessage(packetData); } diff --git a/addons/pos_glory_cash/static/tests/socket_io.test.js b/addons/pos_glory_cash/static/tests/socket_io.test.js index 5250d2275a05e..0b0d32462afd7 100644 --- a/addons/pos_glory_cash/static/tests/socket_io.test.js +++ b/addons/pos_glory_cash/static/tests/socket_io.test.js @@ -10,6 +10,7 @@ const websocketState = { }; const PING_MESSAGE = "2"; +const PONG_MESSAGE = "3"; const OPEN_MESSAGE = '0{"sid":"testSocketId", "pingInterval": 5000}'; const CONNECT_MESSAGE = "40"; const EVENT_MESSAGE = '42["test message"]'; @@ -22,7 +23,7 @@ beforeEach(() => { websocketState.instance = ws; websocketState.closed = false; ws.addEventListener("message", (event) => { - websocketState.received.push(event.data); + websocketState.sent.push(event.data); }); ws.addEventListener("close", () => { websocketState.closed = true; @@ -33,7 +34,7 @@ beforeEach(() => { afterEach(() => { websocketState.instance?.close(); websocketState.instance = null; - websocketState.received = []; + websocketState.sent = []; websocketState.closed = true; }); @@ -48,15 +49,42 @@ describe("when open message is received", () => { }); test("sends ping request every 5 seconds", async () => { - new SocketIoService("mockUrl", {}); + new SocketIoService("mockUrl", { onClose: () => {} }); await waitUntil(() => websocketState.instance.readyState); websocketState.instance.send(OPEN_MESSAGE); await advanceTime(11000); - expect(websocketState.received).toHaveLength(2); - expect(websocketState.received[0]).toBe(PING_MESSAGE); - expect(websocketState.received[1]).toBe(PING_MESSAGE); + expect(websocketState.sent).toHaveLength(2); + expect(websocketState.sent[0]).toBe(PING_MESSAGE); + expect(websocketState.sent[1]).toBe(PING_MESSAGE); + }); + + test("closes connection if pong response is not received in 10s after ping", async () => { + new SocketIoService("mockUrl", { onClose: () => {} }); + await waitUntil(() => websocketState.instance.readyState); + + websocketState.instance.send(OPEN_MESSAGE); + await advanceTime(6000); + expect(websocketState.sent).toHaveLength(1); + expect(websocketState.sent[0]).toBe(PING_MESSAGE); + await advanceTime(10000); + + expect(websocketState.closed).toBe(true); + }); + + test("keep connection open if pong response is received", async () => { + new SocketIoService("mockUrl", {}); + await waitUntil(() => websocketState.instance.readyState); + + websocketState.instance.send(OPEN_MESSAGE); + await advanceTime(1000); + websocketState.instance.send(PONG_MESSAGE); + await advanceTime(5000); + websocketState.instance.send(PONG_MESSAGE); + await advanceTime(5000); + + expect(websocketState.closed).toBe(false); }); }); @@ -80,6 +108,7 @@ describe("when event message is received", () => { test("does not call callback and closes websocket if message is empty", async () => { let eventReceived = null; new SocketIoService("mockUrl", { + onClose: () => {}, onEvent: (event) => { eventReceived = event; }, @@ -95,6 +124,7 @@ describe("when event message is received", () => { test("does not call callback and closes websocket if message is invalid", async () => { let eventReceived = null; new SocketIoService("mockUrl", { + onClose: () => {}, onEvent: (event) => { eventReceived = event; }, @@ -147,7 +177,7 @@ describe("when sending a message", () => { socketIo.sendMessage("test"); - expect(websocketState.received).toHaveLength(1); - expect(websocketState.received[0]).toBe('42["test"]'); + expect(websocketState.sent).toHaveLength(1); + expect(websocketState.sent[0]).toBe('42["test"]'); }); }); From 8affa008a36b3e21f69b48e3158e7f3dcc3c802a Mon Sep 17 00:00:00 2001 From: "Walid (wasa)" Date: Tue, 30 Sep 2025 07:59:10 +0000 Subject: [PATCH 027/673] [FIX] html_editor: prevent unformatted typing at link edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: If we add a link on a slice of formatted text we end up being able to type unformatted content at the link edges. Cause: After https://github.com/odoo/odoo/commit/3bcbd6f34facb9c88290dbd6496cc5103665a0a2 the `span` can be split and `feff`s are placed around the link, precisely between the link and the `span`. This allows writing unformatted content at the caret when placed between them. Solution: Ensure that the link is created inside the `span`. Also prevent the formatting applied by `.btn` when the link is inside a `span`. Steps to reproduce: 1. Add "abc". 2. Format all the text: set font size 48 (or whatever). 3. Select "b". 4. Create a link on "b" only. 5. Put caret before "a". 6. Press Arrow left. 7. Type any character. → The character is not formatted as the link content. task-5092298 closes odoo/odoo#233801 X-original-commit: f8012b9f71e2013a871d5c591b5fbc88e199b0de Signed-off-by: David Monjoie (dmo) Signed-off-by: Walid Sahli (wasa) --- .../static/src/main/link/link.scss | 10 ++ .../static/src/main/link/link_plugin.js | 112 +++++++----------- .../static/src/utils/formatting.js | 1 + .../static/tests/link/button.test.js | 66 ++++++++++- .../static/tests/link/popover.test.js | 63 +--------- 5 files changed, 116 insertions(+), 136 deletions(-) diff --git a/addons/html_editor/static/src/main/link/link.scss b/addons/html_editor/static/src/main/link/link.scss index d8b1a64c11f06..d07c2957e2b53 100644 --- a/addons/html_editor/static/src/main/link/link.scss +++ b/addons/html_editor/static/src/main/link/link.scss @@ -16,4 +16,14 @@ [contenteditable=false] .btn { cursor: pointer; } + + // Unset the font size and family applied by `.btn` + // if inside `FONT_SIZE_CLASSES`. + span[class$="-fs"], + span.small { + .btn:not(:has(span[class$="-fs"], span.small)) { + font-family: unset; + font-size: unset; + } + } } diff --git a/addons/html_editor/static/src/main/link/link_plugin.js b/addons/html_editor/static/src/main/link/link_plugin.js index 7b62129da6884..54eee86079751 100644 --- a/addons/html_editor/static/src/main/link/link_plugin.js +++ b/addons/html_editor/static/src/main/link/link_plugin.js @@ -13,7 +13,6 @@ import { memoize } from "@web/core/utils/functions"; import { withSequence } from "@html_editor/utils/resource"; import { isBlock, closestBlock } from "@html_editor/utils/blocks"; import { isHtmlContentSupported } from "@html_editor/core/selection_plugin"; -import { FONT_SIZE_CLASSES } from "@html_editor/utils/formatting"; import { isBrowserFirefox } from "@web/core/browser/feature_detection"; /** @@ -507,18 +506,10 @@ export class LinkPlugin extends Plugin { } } else if (url) { // prevent the link creation if the url field was empty - const sameTextOrImage = - (selectionTextContent && selectionTextContent === label) || isImage; - if (sameTextOrImage || label) { - // Create a new link with current selection as a content. + + // create a new link with current selection as a content + if ((selectionTextContent && selectionTextContent === label) || isImage) { const link = this.createLink(url); - const fontSizeWrapper = closestElement( - selection.commonAncestorContainer, - (el) => - el.tagName === "SPAN" && - (FONT_SIZE_CLASSES.some((cls) => el.classList.contains(cls)) || - el.style?.fontSize) - ); if (relValue) { link.setAttribute("rel", relValue); } @@ -526,74 +517,48 @@ export class LinkPlugin extends Plugin { const figure = image?.parentElement?.matches("figure[contenteditable=false]") && image.parentElement; - let content; - - // Split selection to include font-size - // inside to preserve styling. - if (fontSizeWrapper) { - this.dependencies.split.splitSelection(); - const selectedNodes = this.dependencies.selection - .getTargetedNodes() - .filter( - this.dependencies.selection.areNodeContentsFullySelected.bind(this) - ); - content = this.dependencies.split.splitAroundUntil( - selectedNodes, - fontSizeWrapper - ); - const [anchorNode, anchorOffset] = leftPos(content); - // Force selection to correct spot after split to prevent wrong link placement. - this.dependencies.selection.setSelection( - { anchorNode, anchorOffset }, - { normalize: false } - ); - if (!sameTextOrImage) { - // If label changed, clear existing content and set new text. - content.textContent = label; - } - } else if (sameTextOrImage) { - if (figure) { - figure.before(link); - link.append(figure); - if (link.parentElement === this.editable) { - const baseContainer = - this.dependencies.baseContainer.createBaseContainer(); - link.before(baseContainer); - baseContainer.append(link); - } - } else { - content = this.dependencies.selection.extractContent(selection); - selection = this.dependencies.selection.getEditableSelection(); - const anchorClosestElement = closestElement(selection.anchorNode); - if (commonAncestor !== anchorClosestElement && !fontSizeWrapper) { - // We force the cursor after the anchorClosestElement - // To be sure the link is inserted in the correct place in the dom. - const [anchorNode, anchorOffset] = rightPos(anchorClosestElement); - this.dependencies.selection.setSelection( - { anchorNode, anchorOffset }, - { normalize: false } - ); - } + if (figure) { + figure.before(link); + link.append(figure); + if (link.parentElement === this.editable) { + const baseContainer = + this.dependencies.baseContainer.createBaseContainer(); + link.before(baseContainer); + baseContainer.append(link); } } else { - content = this.document.createTextNode(label); - if (customStyle) { - link.setAttribute("style", customStyle); - } - if (linkTarget) { - link.setAttribute("target", linkTarget); + const content = this.dependencies.selection.extractContent(selection); + link.append(content); + link.normalize(); + cursorsToRestore = null; + selection = this.dependencies.selection.getEditableSelection(); + const anchorClosestElement = closestElement(selection.anchorNode); + if (commonAncestor !== anchorClosestElement) { + // We force the cursor after the anchorClosestElement + // To be sure the link is inserted in the correct place in the dom. + const [anchorNode, anchorOffset] = rightPos(anchorClosestElement); + this.dependencies.selection.setSelection( + { anchorNode, anchorOffset }, + { normalize: false } + ); } + this.dependencies.dom.insert(link); } this.linkInDocument = link; + } else if (label) { + const link = this.createLink(url, label); if (classes) { link.className = classes; } - if (!figure) { - link.append(content); - link.normalize(); - cursorsToRestore = null; - this.dependencies.dom.insert(link); + if (customStyle) { + link.setAttribute("style", customStyle); } + if (linkTarget) { + link.setAttribute("target", linkTarget); + } + this.linkInDocument = link; + cursorsToRestore = null; + this.dependencies.dom.insert(link); } } if (attachmentId) { @@ -801,7 +766,10 @@ export class LinkPlugin extends Plugin { if (!selectionData.currentSelectionIsInEditable) { const popoverEl = document.querySelector(".o-we-linkpopover"); const anchorNode = document.getSelection()?.anchorNode; - if ((popoverEl && !selectionData.documentSelection) || (anchorNode && isElement(anchorNode) && anchorNode.closest(".o-we-linkpopover"))) { + if ( + (popoverEl && !selectionData.documentSelection) || + (anchorNode && isElement(anchorNode) && anchorNode.closest(".o-we-linkpopover")) + ) { return; } this.linkInDocument = null; diff --git a/addons/html_editor/static/src/utils/formatting.js b/addons/html_editor/static/src/utils/formatting.js index b15b1d3f510cb..f94ac2f5f838e 100644 --- a/addons/html_editor/static/src/utils/formatting.js +++ b/addons/html_editor/static/src/utils/formatting.js @@ -148,6 +148,7 @@ export const formatsSpecs = { node.classList.add(props.className); }, removeStyle: (node) => { + removeStyle(node, "font-size"); removeClass(node, ...FONT_SIZE_CLASSES); // Typography classes should be preserved on block elements since // they act as semantic equivalents of

    ,

    , etc., not just diff --git a/addons/html_editor/static/tests/link/button.test.js b/addons/html_editor/static/tests/link/button.test.js index 967676249838b..3cbd937c252f3 100644 --- a/addons/html_editor/static/tests/link/button.test.js +++ b/addons/html_editor/static/tests/link/button.test.js @@ -11,8 +11,8 @@ import { manuallyDispatchProgrammaticEvent, } from "@odoo/hoot-dom"; import { animationFrame } from "@odoo/hoot-mock"; -import { contains } from "@web/../tests/web_test_helpers"; -import { setupEditor } from "../_helpers/editor"; +import { contains, onRpc } from "@web/../tests/web_test_helpers"; +import { setupEditor, testEditor } from "../_helpers/editor"; import { cleanLinkArtifacts, unformat } from "../_helpers/format"; import { getContent, setSelection } from "../_helpers/selection"; import { insertText } from "../_helpers/user_actions"; @@ -50,6 +50,68 @@ describe("button style", () => { el.setAttribute("contenteditable", "false"); expect(queryOne(".test-btn")).toHaveStyle({ userSelect: "none" }); }); + test("Button styling should not override inner font size", async () => { + onRpc("/test", () => ({})); + onRpc("/html_editor/link_preview_internal", () => ({ + description: "test", + link_preview_name: "test", + })); + const { el } = await setupEditor( + unformat(` +
    + a[b]c +
    + `) + ); + await waitFor(".o-we-toolbar"); + await click("button[name='link']"); + await animationFrame(); + await click('select[name="link_type"]'); + await animationFrame(); + await select("primary"); + await animationFrame(); + await contains(".o-we-linkpopover input.o_we_href_input_link").edit("/test"); + + // Ensure `.display-1-fs` overrides the `.btn`'s default font size. + const link = el.querySelector("a.btn"); + const span = el.querySelector("span.display-1-fs"); + expect(getComputedStyle(link).fontSize).toBe(getComputedStyle(span).fontSize); + + expect(el).toHaveInnerHTML( + unformat(` +
    + `) + ); + }); + + test("Should be able to change button style", async () => { + await testEditor({ + contentBefore: unformat(` +
    + a[b]c +
    + `), + stepFunction: (editor) => { + editor.shared.format.formatSelection("setFontSizeClassName", { + formatProps: { className: "h1-fs" }, + applyStyle: true, + }); + }, + contentAfter: unformat(` +
    + + a + + [b] + + c + +
    + `), + }); + }); }); const allowCustomOpt = { diff --git a/addons/html_editor/static/tests/link/popover.test.js b/addons/html_editor/static/tests/link/popover.test.js index 2d2c9493af898..3c548fd257c44 100644 --- a/addons/html_editor/static/tests/link/popover.test.js +++ b/addons/html_editor/static/tests/link/popover.test.js @@ -15,7 +15,7 @@ import { animationFrame, tick } from "@odoo/hoot-mock"; import { markup } from "@odoo/owl"; import { contains, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers"; import { setupEditor } from "../_helpers/editor"; -import { cleanLinkArtifacts, unformat } from "../_helpers/format"; +import { cleanLinkArtifacts } from "../_helpers/format"; import { getContent, setContent, setSelection } from "../_helpers/selection"; import { expectElementCount } from "../_helpers/ui_expectations"; import { insertLineBreak, insertText, splitBlock, undo } from "../_helpers/user_actions"; @@ -844,67 +844,6 @@ describe("Link creation", () => { `

    [Hello my friend]

    ` ); }); - test("should wrap selected text with link and preserve styles", async () => { - const { el } = await setupEditor( - `

    s[trongunderlin]e

    ` - ); - await waitFor(".o-we-toolbar"); - await click(".o-we-toolbar .fa-link"); - await expectElementCount(".o-we-linkpopover", 1); - queryOne(".o_we_href_input_link").focus(); - expect(".o_we_href_input_link").toBeFocused(); - await fill("http://test.com"); - await click('select[name="link_type"'); - await select("primary"); - await animationFrame(); - await click(".o_we_apply_link"); - await animationFrame(); - expect(cleanLinkArtifacts(getContent(el))).toBe( - unformat(` -

    - s - - trongunderlin[] - - e -

    - `) - ); - }); - test("should apply link over split text nodes while preserving styles", async () => { - const { el } = await setupEditor(`

    `); - - const fontSizeSpan = queryOne("span.display-1-fs"); - fontSizeSpan.appendChild(document.createTextNode("te")); - fontSizeSpan.appendChild(document.createTextNode("st")); - setSelection({ - anchorNode: fontSizeSpan.firstChild, - anchorOffset: 1, - focusNode: fontSizeSpan.lastChild, - focusOffset: 1, - }); - - await waitFor(".o-we-toolbar"); - await click(".o-we-toolbar .fa-link"); - await expectElementCount(".o-we-linkpopover", 1); - queryOne(".o_we_href_input_link").focus(); - expect(".o_we_href_input_link").toBeFocused(); - await fill("http://test.com"); - await animationFrame(); - await click(".o_we_apply_link"); - await animationFrame(); - expect(cleanLinkArtifacts(getContent(el))).toBe( - unformat(` -

    - t - - es[] - - t -

    - `) - ); - }); }); }); From 5548043b849c32ef17b5d6864dc429fd003fc367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20K=C3=BChn?= Date: Tue, 28 Oct 2025 17:09:19 +0000 Subject: [PATCH 028/673] [FIX] mail: search panel in mailbox takes whole screen width in mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, the search panel in mailboxes (inbox, starred, history) took only part of the screen in mobile. This happens because the template being used in mobile for mailbox is the same as desktop UI for discuss app. This component `DiscussContent` is used because mailboxes are not supported in chat windows, and full-screen conversations in discuss on mobile are actually chat windows. Reusing the `DiscussContent` component is almost the desired UX/UI. The side panel however was not designed for small screen, thus action panel is very narrow and is hard to use in mobile. This commit fixes the issue by making the action panel mutually exclusive to message list in mobile, so that when search panel is open it takes the whole width of screen. We don't need to see message list in mobile, and thanks to chat window also using search panel that takes the whole screen, the search panel is already designed to auto-close itself and jump to a message. Part of Task-4967066 closes odoo/odoo#233762 X-original-commit: c1db1d223b0aebd7aad0c3091315d5f09c1a4c58 Signed-off-by: Didier Debondt (did) Signed-off-by: Alexandre Kühn (aku) --- .../src/core/public_web/discuss_content.xml | 4 +- .../discuss/search_messages_panel.test.js | 58 ++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/addons/mail/static/src/core/public_web/discuss_content.xml b/addons/mail/static/src/core/public_web/discuss_content.xml index 7d0567cb60ad0..84e0b99a5d959 100644 --- a/addons/mail/static/src/core/public_web/discuss_content.xml +++ b/addons/mail/static/src/core/public_web/discuss_content.xml @@ -67,11 +67,11 @@
    -
    +
    -
    +
    diff --git a/addons/mail/static/tests/discuss/search_messages_panel.test.js b/addons/mail/static/tests/discuss/search_messages_panel.test.js index b5a8e72bbb3fc..9cf25c593e8a3 100644 --- a/addons/mail/static/tests/discuss/search_messages_panel.test.js +++ b/addons/mail/static/tests/discuss/search_messages_panel.test.js @@ -5,21 +5,23 @@ import { insertText, onRpcBefore, openDiscuss, + patchUiSize, scroll, + SIZES, start, startServer, triggerHotkey, } from "@mail/../tests/mail_test_helpers"; -import { describe, expect, test } from "@odoo/hoot"; +import { expect, mockTouch, mockUserAgent, test } from "@odoo/hoot"; import { press } from "@odoo/hoot-dom"; import { tick } from "@odoo/hoot-mock"; import { serverState } from "@web/../tests/web_test_helpers"; import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook"; -describe.current.tags("desktop"); defineMailModels(); +test.tags("desktop"); test("Should have a search button", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -28,6 +30,7 @@ test("Should have a search button", async () => { await contains("[title='Search Messages']"); }); +test.tags("desktop"); test("Should open the search panel when search button is clicked", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -39,6 +42,7 @@ test("Should open the search panel when search button is clicked", async () => { await contains(".o_searchview_input"); }); +test.tags("desktop"); test("Should open the search panel with hotkey 'f'", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -57,6 +61,7 @@ test("Should open the search panel with hotkey 'f'", async () => { await contains(".o-mail-SearchMessagesPanel"); }); +test.tags("desktop"); test("Search a message", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -77,6 +82,7 @@ test("Search a message", async () => { await contains(".o-mail-SearchMessagesPanel .o-mail-Message"); }); +test.tags("desktop"); test("Search should be hightlighted", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -97,6 +103,7 @@ test("Search should be hightlighted", async () => { await contains(`.o-mail-SearchMessagesPanel .o-mail-Message .${HIGHLIGHT_CLASS}`); }); +test.tags("desktop"); test("Search a starred message", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -118,6 +125,7 @@ test("Search a starred message", async () => { await contains(".o-mail-SearchMessagesPanel .o-mail-Message"); }); +test.tags("desktop"); test("Search a message in inbox", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -139,7 +147,8 @@ test("Search a message in inbox", async () => { await contains(".o-mail-SearchMessagesPanel .o-mail-Message"); }); -test("Search a message in history", async () => { +test.tags("desktop"); +test("Search a message in history (desktop)", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); const messageId = pyEnv["mail.message"].create({ @@ -166,6 +175,44 @@ test("Search a message in history", async () => { await contains(".o-mail-SearchMessagesPanel .o-mail-Message"); }); +test.tags("mobile"); +test("Search a message in history (mobile)", async () => { + mockTouch(true); + mockUserAgent("android"); + patchUiSize({ size: SIZES.SM }); + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + const messageId = pyEnv["mail.message"].create({ + author_id: serverState.partnerId, + body: "This is a message", + attachment_ids: [], + message_type: "comment", + model: "discuss.channel", + res_id: channelId, + needaction: false, + }); + pyEnv["mail.notification"].create({ + is_read: true, + mail_message_id: messageId, + notification_status: "sent", + notification_type: "inbox", + res_partner_id: serverState.partnerId, + }); + await start(); + await openDiscuss("mail.box_history"); + await contains(".o-mail-Thread"); + await click("[title='Search Messages']"); + await contains(".o-mail-SearchMessagesPanel"); + await contains(".o-mail-Thread", { count: 0 }); + await insertText(".o_searchview_input", "message"); + await triggerHotkey("Enter"); + await contains(".o-mail-SearchMessagesPanel .o-mail-Message"); + await click(".o-mail-MessageCard-jump"); + await contains(".o-mail-Thread"); + await contains(".o-mail-SearchMessagesPanel", { count: 0 }); +}); + +test.tags("desktop"); test("Should close the search panel when search button is clicked again", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -176,6 +223,7 @@ test("Should close the search panel when search button is clicked again", async await contains(".o-mail-SearchMessagesPanel"); }); +test.tags("desktop"); test("Search a message in 60 messages should return 30 message first", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -201,6 +249,7 @@ test("Search a message in 60 messages should return 30 message first", async () await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 }); }); +test.tags("desktop"); test("Scrolling to the bottom should load more searched message", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -228,6 +277,7 @@ test("Scrolling to the bottom should load more searched message", async () => { await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 60 }); }); +test.tags("desktop"); test("Editing the searched term should not edit the current searched term", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -256,6 +306,7 @@ test("Editing the searched term should not edit the current searched term", asyn await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom"); }); +test.tags("desktop"); test("Search a message containing round brackets", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); @@ -276,6 +327,7 @@ test("Search a message containing round brackets", async () => { await contains(".o-mail-SearchMessagesPanel .o-mail-Message"); }); +test.tags("desktop"); test("Search a message containing single quotes", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); From 7b78b7fa9a1fd9385cff768fdcc12b7f44e9055c Mon Sep 17 00:00:00 2001 From: Serge Bayet Date: Mon, 27 Oct 2025 07:41:50 +0000 Subject: [PATCH 029/673] [FIX] website_blog, *: allow editing blog landing title *: html_builder, website Since [1] and [2] reverting it by mistake, the blog landing title was not editable anymore. Steps to reproduce: - Open /blog in the website editor. - Try to edit the "Our Latest Posts" heading. After this commit the heading becomes editable in the website editor. [1]: https://github.com/odoo/odoo/commit/ec49429d0d405948fcb93228e7350dfea585246f [2]: https://github.com/odoo/odoo/commit/f503f98915ab39efff80a5904494c879805bb057 closes odoo/odoo#233789 X-original-commit: ed381be04a56f1ce83269e9e250cc8ae83b4bc59 Signed-off-by: Benjamin Vray (bvr) Signed-off-by: Serge Bayet (seba) --- addons/html_builder/static/src/core/setup_editor_plugin.js | 1 + addons/website/static/src/builder/website_builder.js | 2 +- addons/website_blog/views/website_blog_templates.xml | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/addons/html_builder/static/src/core/setup_editor_plugin.js b/addons/html_builder/static/src/core/setup_editor_plugin.js index a062c9c0068ab..29c4fde517658 100644 --- a/addons/html_builder/static/src/core/setup_editor_plugin.js +++ b/addons/html_builder/static/src/core/setup_editor_plugin.js @@ -17,6 +17,7 @@ export class SetupEditorPlugin extends Plugin { "#wrap .o_homepage_editor_welcome_message" ); welcomeMessageEl?.remove(); + this.dispatchTo("before_setup_editor_handlers"); let editableEls = this.getEditableElements( this.getResource("o_editable_selectors").join(", ") ) diff --git a/addons/website/static/src/builder/website_builder.js b/addons/website/static/src/builder/website_builder.js index 7037c4cba63a2..ce4e28d2aad73 100644 --- a/addons/website/static/src/builder/website_builder.js +++ b/addons/website/static/src/builder/website_builder.js @@ -156,7 +156,7 @@ export class WebsiteBuilder extends Component { get builderProps() { const builderProps = Object.assign({}, this.props.builderProps); const websitePlugins = this.props.translation - ? TRANSLATION_PLUGINS + ? [...TRANSLATION_PLUGINS, ...registry.category("website-translation-plugins").getAll()] : [ ...registry.category("builder-plugins").getAll(), ...registry.category("website-plugins").getAll(), diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml index 59be0dc70121d..4bc7642c54992 100644 --- a/addons/website_blog/views/website_blog_templates.xml +++ b/addons/website_blog/views/website_blog_templates.xml @@ -108,7 +108,7 @@ list of filtered posts (by date or tag).
    -
    Our Latest Posts
    +
    Our Latest Posts

    @@ -134,7 +134,7 @@ list of filtered posts (by date or tag).
    -
    Our Latest Posts
    +
    Our Latest Posts
    From 9ab89c290b3430387d7d119f09589d85fdc5b99c Mon Sep 17 00:00:00 2001 From: ELCO Date: Mon, 27 Oct 2025 13:02:48 +0000 Subject: [PATCH 030/673] [IMP] web: sample data backpedaling This commit removes the sample data ribbon introduced in #208864 and adds an opacity blur over sample data. task-5207352 closes odoo/odoo#233797 X-original-commit: 6ee090c29f1f4e1745c6cae2d50ea77d3324965b Signed-off-by: Lucas Perais (lpe) --- addons/web/static/src/scss/utils.scss | 1 - addons/web/static/src/views/action_helper.js | 4 ---- addons/web/static/src/views/action_helper.xml | 6 ------ addons/web/static/src/views/view.scss | 5 +++++ addons/web/static/tests/views/graph/graph_view.test.js | 5 ----- addons/web/static/tests/views/kanban/kanban_view.test.js | 5 ----- addons/web/static/tests/views/list/list_view.test.js | 6 ------ addons/web/static/tests/views/pivot_view.test.js | 5 ----- 8 files changed, 5 insertions(+), 32 deletions(-) diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss index 3b7a70bc9441e..346c1276cc99b 100644 --- a/addons/web/static/src/scss/utils.scss +++ b/addons/web/static/src/scss/utils.scss @@ -332,7 +332,6 @@ // Sample data @mixin o-sample-data-disabled { - opacity: 0.06; pointer-events: none; user-select: none; } diff --git a/addons/web/static/src/views/action_helper.js b/addons/web/static/src/views/action_helper.js index 5698c2ebf0fa3..dade37482e277 100644 --- a/addons/web/static/src/views/action_helper.js +++ b/addons/web/static/src/views/action_helper.js @@ -13,8 +13,4 @@ export class ActionHelper extends Component { get showDefaultHelper() { return !this.props.noContentHelp; } - - showWidgetSampleData() { - return this.props.showRibbon; - } } diff --git a/addons/web/static/src/views/action_helper.xml b/addons/web/static/src/views/action_helper.xml index d85dde2f4839e..427905b25416c 100644 --- a/addons/web/static/src/views/action_helper.xml +++ b/addons/web/static/src/views/action_helper.xml @@ -4,12 +4,6 @@
    - - -

    diff --git a/addons/web/static/src/views/view.scss b/addons/web/static/src/views/view.scss index 0074ab67b6741..3101716a98f43 100644 --- a/addons/web/static/src/views/view.scss +++ b/addons/web/static/src/views/view.scss @@ -18,6 +18,11 @@ display: flex; align-items: center; justify-content: center; + background-image: radial-gradient( + at 50% 50%, + #{$o-view-background-color} 0px, + #{rgba($o-view-background-color, 0.5)} 100% + ); .o_nocontent_help { @include o-nocontent-empty; diff --git a/addons/web/static/tests/views/graph/graph_view.test.js b/addons/web/static/tests/views/graph/graph_view.test.js index fddbdd37baa99..36859382306b0 100644 --- a/addons/web/static/tests/views/graph/graph_view.test.js +++ b/addons/web/static/tests/views/graph/graph_view.test.js @@ -2291,8 +2291,6 @@ test("empty graph view with sample data", async () => { expect(".o_graph_view .o_content").toHaveClass("o_view_sample_data"); expect(".o_view_nocontent").toHaveCount(1); - expect(".ribbon").toHaveCount(1); - expect(".ribbon").toHaveText("SAMPLE DATA"); expect(".o_graph_canvas_container canvas").toHaveCount(1); await toggleSearchBarMenu(); @@ -2300,7 +2298,6 @@ test("empty graph view with sample data", async () => { expect(".o_graph_view .o_content").not.toHaveClass("o_view_sample_data"); expect(".o_view_nocontent").toHaveCount(0); - expect(".ribbon").toHaveCount(0); expect(".o_graph_canvas_container canvas").toHaveCount(1); }); @@ -2325,7 +2322,6 @@ test("non empty graph view with sample data", async () => { expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_view_nocontent").toHaveCount(0); expect(".o_graph_canvas_container canvas").toHaveCount(1); - expect(".ribbon").toHaveCount(0); await toggleSearchBarMenu(); await toggleMenuItem("False Domain"); @@ -2333,7 +2329,6 @@ test("non empty graph view with sample data", async () => { expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_graph_canvas_container canvas").toHaveCount(0); expect(".o_view_nocontent").toHaveCount(1); - expect(".ribbon").toHaveCount(0); }); test("empty graph view without sample data after filter", async () => { diff --git a/addons/web/static/tests/views/kanban/kanban_view.test.js b/addons/web/static/tests/views/kanban/kanban_view.test.js index 0e6356c1aa9fa..51796323f7a7a 100644 --- a/addons/web/static/tests/views/kanban/kanban_view.test.js +++ b/addons/web/static/tests/views/kanban/kanban_view.test.js @@ -4178,8 +4178,6 @@ test("empty kanban with sample data", async () => { message: "there should be 10 sample records", }); expect(".o_view_nocontent").toHaveCount(1); - expect(".ribbon").toHaveCount(1); - expect(".ribbon").toHaveText("SAMPLE DATA"); await toggleSearchBarMenu(); await toggleMenuItem("Match nothing"); @@ -4187,7 +4185,6 @@ test("empty kanban with sample data", async () => { expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(0); expect(".o_view_nocontent").toHaveCount(1); - expect(".ribbon").toHaveCount(0); }); test("empty grouped kanban with sample data and many2many_tags", async () => { @@ -4307,14 +4304,12 @@ test("non empty kanban with sample data", async () => { expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4); expect(".o_view_nocontent").toHaveCount(0); - expect(".ribbon").toHaveCount(0); await toggleSearchBarMenu(); await toggleMenuItem("Match nothing"); expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(0); - expect(".ribbon").toHaveCount(0); }); test("empty grouped kanban with sample data: add a column", async () => { diff --git a/addons/web/static/tests/views/list/list_view.test.js b/addons/web/static/tests/views/list/list_view.test.js index 2242e2a2f102e..851b304595e21 100644 --- a/addons/web/static/tests/views/list/list_view.test.js +++ b/addons/web/static/tests/views/list/list_view.test.js @@ -7239,8 +7239,6 @@ test(`empty list with sample data`, async () => { expect(`.o_list_table`).toHaveCount(1); expect(`.o_data_row`).toHaveCount(10); expect(`.o_nocontent_help`).toHaveCount(1); - expect(".ribbon").toHaveCount(1); - expect(".ribbon").toHaveText("SAMPLE DATA"); // Check list sample data expect(`.o_data_row .o_data_cell:eq(0)`).toHaveText("", { @@ -7269,7 +7267,6 @@ test(`empty list with sample data`, async () => { expect(`.o_list_view .o_content`).not.toHaveClass("o_view_sample_data"); expect(`.o_list_table`).toHaveCount(1); expect(`.o_nocontent_help`).toHaveCount(1); - expect(".ribbon").toHaveCount(0); await toggleMenuItem("False Domain"); await toggleMenuItem("True Domain"); @@ -7277,7 +7274,6 @@ test(`empty list with sample data`, async () => { expect(`.o_list_table`).toHaveCount(1); expect(`.o_data_row`).toHaveCount(4); expect(`.o_nocontent_help`).toHaveCount(0); - expect(".ribbon").toHaveCount(0); }); test(`refresh empty list with sample data`, async () => { @@ -7428,7 +7424,6 @@ test(`non empty list with sample data`, async () => { expect(`.o_list_table`).toHaveCount(1); expect(`.o_data_row`).toHaveCount(4); expect(`.o_list_view .o_content`).not.toHaveClass("o_view_sample_data"); - expect(".ribbon").toHaveCount(0); await toggleSearchBarMenu(); await toggleMenuItem("true_domain"); @@ -7436,7 +7431,6 @@ test(`non empty list with sample data`, async () => { expect(`.o_list_table`).toHaveCount(1); expect(`.o_data_row`).toHaveCount(0); expect(`.o_list_view .o_content`).not.toHaveClass("o_view_sample_data"); - expect(".ribbon").toHaveCount(0); }); test(`click on header in empty list with sample data`, async () => { diff --git a/addons/web/static/tests/views/pivot_view.test.js b/addons/web/static/tests/views/pivot_view.test.js index 9b0e3c008546e..653ec398a9464 100644 --- a/addons/web/static/tests/views/pivot_view.test.js +++ b/addons/web/static/tests/views/pivot_view.test.js @@ -2814,12 +2814,9 @@ test("empty pivot view with sample data", async () => { expect(".o_pivot_view .o_content").toHaveClass("o_view_sample_data"); expect(".o_view_nocontent .abc").toHaveCount(1); - expect(".ribbon").toHaveCount(1); - expect(".ribbon").toHaveText("SAMPLE DATA"); await removeFacet(); expect(".o_pivot_view .o_content").not.toHaveClass("o_view_sample_data"); expect(".o_view_nocontent .abc").toHaveCount(0); - expect(".ribbon").toHaveCount(0); expect("table").toHaveCount(1); }); @@ -2843,13 +2840,11 @@ test("non empty pivot view with sample data", async () => { expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_view_nocontent .abc").toHaveCount(0); - expect(".ribbon").toHaveCount(0); expect("table").toHaveCount(1); await toggleSearchBarMenu(); await toggleMenuItem("Small Than 0"); expect(".o_content").not.toHaveClass("o_view_sample_data"); expect(".o_view_nocontent .abc").toHaveCount(1); - expect(".ribbon").toHaveCount(0); expect("table").toHaveCount(0); }); From 664876d1f95b72dba50957556757e8bdb4e9de7d Mon Sep 17 00:00:00 2001 From: "Mahdi Alijani (malj)" Date: Tue, 7 Oct 2025 13:44:42 +0000 Subject: [PATCH 031/673] [FIX] mrp: workorder duration inverse matching compute logic Issue: In this bug, workorder duration inverse is causing some time_ids to be deleted. To reproduce: 1- Create a db with mrp installed, and enable work orders in Setting 2- Create a MO, and confirm it 3- Add a new work order to the MO 4- Add two time tracking lines: - First one 10:00 -> 12:00 - Second one 10:00 -> 11:00 5- As you see, duration reflects duration of first line as it is the interval duration 6- Save and close work center form. Then save MO form. 7- Open work orders again: As you see second line is unlinked Cause: The reason to this bug, is because in Enterprise, the `_compute_duration` override changes the logic of how duration is computed but the inverse function doesn't reflect the same logic. To be specific this is the compute function override: https://github.com/odoo/enterprise/blob/3cbe2bbbfd989a3daaa32769a843aeaa09c7ed3e/mrp_workorder/models/mrp_workorder.py#L757-L766 In which duration is calculated using get_duration: https://github.com/odoo/enterprise/blob/3cbe2bbbfd989a3daaa32769a843aeaa09c7ed3e/mrp_workorder/models/mrp_workorder.py#L828-L837 Which doesn't sum the durations, but calculates the intervals duration counting overlaps only once. However, there is no override of inverse method in Enterprise, meaning that the logic behind inverse will not match with this logic. In the inverse it is assumed duration is sum of all time_ids intervals: https://github.com/odoo/odoo/blob/9b286285a6c66bc2d629eacf651c3439cffb55cc/addons/mrp/models/mrp_workorder.py#L355-L400 As a result, if time_ids overlap: new_order_duration < old_order_duration As a result some time_ids will be unlinked and some will have duration changed. Fix: Inside the inverse function in Community we can do: ```diff + old_order_duration = order.get_duration() - sum(order.time_ids.mapped('duration')) ``` As get_duration in Odoo Community is: https://github.com/odoo/odoo/blob/9b286285a6c66bc2d629eacf651c3439cffb55cc/addons/mrp/models/mrp_workorder.py#L889-L899 The order.get_duration will be sum of duration of all time_ids in community, hence the logic will be unchanged. In Enterprise, this is going to reflect the logic implemented in override of get_duration, as a result the duration logic will be consistent in compute and inverse function. However, this might cause another issue: If `order.duration` is not computed yet, and inverse method `_set_duration` is called, then `get_duration` inside `_set_duration` will be called before the `get_duration` in compute method. As a result there might be a small unexpected time difference between `old_order_duration` and `new_order_diuration`. To avoid that inside `get_working_duration` we can use cursor now instead: ```diff + now = self.env.cr.now() - now = datetime.now() ``` opw-5082477 closes odoo/odoo#233828 X-original-commit: 937b2acde71eb187d9dbeaa6456d4498146253c9 Related: odoo/enterprise#98488 Signed-off-by: Lancelot Semal (lase) Signed-off-by: Mohammadmahdi Alijani (malj) --- addons/mrp/models/mrp_workorder.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 7bfed5f008ee9..6fc1f2510aacf 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -345,7 +345,7 @@ def _compute_duration_expected(self): @api.depends('time_ids.duration', 'qty_produced') def _compute_duration(self): for order in self: - order.duration = sum(order.time_ids.mapped('duration')) + order.duration = order.get_duration() order.duration_unit = round(order.duration / max(order.qty_produced, 1), 2) # rounding 2 because it is a time if order.duration_expected: order.duration_percent = max(-2147483648, min(2147483647, 100 * (order.duration_expected - order.duration) / order.duration_expected)) @@ -360,7 +360,7 @@ def _float_duration_to_second(duration): return minutes * 60 + seconds for order in self: - old_order_duration = sum(order.time_ids.mapped('duration')) + old_order_duration = order.get_duration() new_order_duration = order.duration if new_order_duration == old_order_duration: continue @@ -890,8 +890,9 @@ def get_working_duration(self): """Get the additional duration for 'open times' i.e. productivity lines with no date_end.""" self.ensure_one() duration = 0 + now = self.env.cr.now() for time in self.time_ids.filtered(lambda time: not time.date_end): - duration += (datetime.now() - time.date_start).total_seconds() / 60 + duration += (now - time.date_start).total_seconds() / 60 return duration def get_duration(self): From 6ee721da51f90e56487901488f28145fe4d25e7f Mon Sep 17 00:00:00 2001 From: Elliot ELCO Date: Wed, 8 Oct 2025 14:54:33 +0000 Subject: [PATCH 032/673] [IMP] web: calendar view wrong end time at create When creating an event in week view spanning over multiple days, all parts of the event display 12pm as end date (except the last one) and 00am as the start date (except the first one). This fix allow calendar views to display the correct hour in week view when the event spans multiple days. task-4700158 closes odoo/odoo#233827 X-original-commit: 22cec35b0ff10042851589b6171b13ae9cbd21e7 Signed-off-by: Bastien Pierre (ipb) Signed-off-by: Elliot Cosneau (elco) --- .../calendar_common_renderer.js | 16 ++++++++++------ .../tests/views/calendar/calendar_view.test.js | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/addons/web/static/src/views/calendar/calendar_common/calendar_common_renderer.js b/addons/web/static/src/views/calendar/calendar_common/calendar_common_renderer.js index 7fc628d2d2982..148694267d88f 100644 --- a/addons/web/static/src/views/calendar/calendar_common/calendar_common_renderer.js +++ b/addons/web/static/src/views/calendar/calendar_common/calendar_common_renderer.js @@ -68,7 +68,7 @@ export class CalendarCommonRenderer extends Component { this.fc = useFullCalendar("fullCalendar", this.options); this.clickTimeoutId = null; this.popover = useCalendarPopover(this.constructor.components.Popover); - + this.timeFormat = is24HourFormat() ? "HH:mm" : "hh:mm a"; useBus(this.props.model.bus, "SCROLL_TO_CURRENT_HOUR", () => this.fc.api.scrollToTime(`${luxon.DateTime.local().hour - 2}:00:00`) ); @@ -175,13 +175,11 @@ export class CalendarCommonRenderer extends Component { } getStartTime(record) { - const timeFormat = is24HourFormat() ? "HH:mm" : "hh:mm a"; - return record.start.toFormat(timeFormat); + return record.start.toFormat(this.timeFormat); } getEndTime(record) { - const timeFormat = is24HourFormat() ? "HH:mm" : "hh:mm a"; - return record.end.toFormat(timeFormat); + return record.end.toFormat(this.timeFormat); } computeEventSelector(event) { @@ -253,7 +251,13 @@ export class CalendarCommonRenderer extends Component { }, 250); } } - onEventContent({ event }) { + onEventContent(arg) { + const { event } = arg; + if (event.start && event.end) { + const dateFmt = (date) => + luxon.DateTime.fromJSDate(date).toFormat(this.timeFormat); + arg.timeText = `${dateFmt(event.start)} - ${dateFmt(event.end)}`; + } const record = this.props.model.records[event.id]; if (record) { // This is needed in order to give the possibility to change the event template. diff --git a/addons/web/static/tests/views/calendar/calendar_view.test.js b/addons/web/static/tests/views/calendar/calendar_view.test.js index a10a11b81f312..f2dcb576d7f42 100644 --- a/addons/web/static/tests/views/calendar/calendar_view.test.js +++ b/addons/web/static/tests/views/calendar/calendar_view.test.js @@ -1445,7 +1445,6 @@ test(`create event with timezone in week mode European locale`, async () => { `, }); - await selectTimeRange("2016-12-13 08:00:00", "2016-12-13 10:00:00"); expect(`.fc-event-main .fc-event-time`).toHaveText("08:00 - 10:00"); @@ -1460,6 +1459,23 @@ test(`create event with timezone in week mode European locale`, async () => { expect(`.fc-event-main`).toHaveCount(0); }); +test(`create multi day event in week mode`, async () => { + mockTimeZone(2); + + patchWithCleanup(CalendarCommonRenderer.prototype, { + get options() { + return { ...super.options, selectAllow: () => true }; + }, + }); + await mountView({ + resModel: "event", + type: "calendar", + arch: ``, + }); + await selectTimeRange("2016-12-13 11:00:00", "2016-12-14 16:00:00"); + expect(`.fc-event-main .fc-event-time`).toHaveText("11:00 - 16:00"); +}); + test(`default week start (US)`, async () => { // if not given any option, default week start is on Sunday mockTimeZone(-7); From 58392e961de7cc94bd501026a1371e4fee2f5313 Mon Sep 17 00:00:00 2001 From: adip-odoo Date: Mon, 13 Oct 2025 11:34:02 +0000 Subject: [PATCH 033/673] [FIX] stock: prevent error when horizon days field is empty Currently, an error occurs when the Horizon days field was cleared in the Replenishment view by the user. Step to Reproduce: - Install the purchase_stock module. - Create a new product and set the Minimum Quantity in the reordering rule (e.g., 5). - Update the In Hand Quantity to a value greater than the minimum (e.g., 10). - Go to Inventory > Operations > Replenishment. - In the Horizon section on the left panel, clear the days field. Error: TypeError- unsupported operand type(s) for +: 'NoneType' and 'int' Cause: When the Horizon days field is cleared, NaN value is assigned to the context as `global_horizon_days` at [1]. As a result, the get_horizon_days() method returns None, which later triggers an error at [2] when performing date computation. Fix: This commit handles the issue by defaulting the Horizon days value to 0, when the input is empty. [1] - https://github.com/odoo/odoo/blob/e7aeef2fca0897e5240b087e2c3b95291ed6d568/addons/stock/static/src/views/search/stock_orderpoint_search_panel.js#L21-L24 [2] - https://github.com/odoo/odoo/blob/e7aeef2fca0897e5240b087e2c3b95291ed6d568/addons/stock/models/stock_orderpoint.py#L816 sentry-6936562315 closes odoo/odoo#233835 X-original-commit: b081d313f47b4e7f545ba5f7ddbfd3e17b05efd8 Signed-off-by: Quentin Wolfs (quwo) --- .../static/src/views/search/stock_orderpoint_search_panel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/stock/static/src/views/search/stock_orderpoint_search_panel.js b/addons/stock/static/src/views/search/stock_orderpoint_search_panel.js index 2bcef8a6f277d..21493c9d5f684 100644 --- a/addons/stock/static/src/views/search/stock_orderpoint_search_panel.js +++ b/addons/stock/static/src/views/search/stock_orderpoint_search_panel.js @@ -19,7 +19,7 @@ export class StockOrderpointSearchPanel extends SearchPanel { } async applyGlobalHorizonDays(ev) { - this.globalHorizonDays.value = Math.max(parseInt(ev.target.value), 0); + this.globalHorizonDays.value = Math.max(parseInt(ev.target.value || 0), 0); await this.env.searchModel.applyGlobalHorizonDays(this.globalHorizonDays.value); } } From 8772a6eca78f870c9ef009f3020d09d26a127646 Mon Sep 17 00:00:00 2001 From: utma-odoo Date: Mon, 27 Oct 2025 12:54:14 +0000 Subject: [PATCH 034/673] [FIX] tools: prevent error when uploading invalid pdf file Currently, an error occurs when trying to preview the first page of a PDF attachment in Discuss if the PDF has no raw data. Steps to produce: - Install the mail module. - Open Discuss and attach the PDF file without raw data [1]. Error: TypeError: a bytes-like object is required, not 'bool' Root cause: At [3], to_pdf_stream directly calls io.BytesIO(), when the attachment has no raw data, Python raises an error. Fix: This commit prevents errors when a user attaches a PDF file that has no raw data. [1]: https://drive.google.com/file/d/1onJYlCL_k51UwqKhc0kFaYFJynKuk4fT/view?usp=sharing [2]: https://github.com/odoo/odoo/blob/d42102cac8fff3967cb605a897bbb0e8690464ed/odoo/tools/pdf/__init__.py#L222 sentry-6963775400 closes odoo/odoo#233863 X-original-commit: 7563392be6c457b799b2700d39b2d424ca32a02e Signed-off-by: Chong Wang (cwg) Signed-off-by: Utsav Maru (utma) --- odoo/tools/pdf/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/odoo/tools/pdf/__init__.py b/odoo/tools/pdf/__init__.py index e23e88b9956b3..5ac4f44b55bec 100644 --- a/odoo/tools/pdf/__init__.py +++ b/odoo/tools/pdf/__init__.py @@ -217,8 +217,11 @@ def rotate_pdf(pdf): return _buffer.getvalue() -def to_pdf_stream(attachment) -> io.BytesIO: +def to_pdf_stream(attachment) -> io.BytesIO | None: """Get the byte stream of the attachment as a PDF.""" + if not attachment.raw: + _logger.warning("%s has no raw data.", attachment) + return None stream = io.BytesIO(attachment.raw) if attachment.mimetype == 'application/pdf': return stream @@ -227,6 +230,7 @@ def to_pdf_stream(attachment) -> io.BytesIO: Image.open(stream).convert("RGB").save(output_stream, format="pdf") return output_stream _logger.warning("mimetype (%s) not recognized for %s", attachment.mimetype, attachment) + return None def extract_page(attachment, num_page=0) -> io.BytesIO | None: From 0f13ce988215a313c436b725b46336aeae12e642 Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 2 Oct 2025 21:44:28 +0000 Subject: [PATCH 035/673] [FIX] hr: traceback when archiving employee Issue: The tracking is not implemented for the html field, so when we archive an employee the tracking cannot work. Purpose of this PR: The tracking has been removed from the html field. The tracking is now replaced by a message in the chatter in the write method. Steps to Reproduce on Runbot: install hr archive employee set a `departure description` Notes: originally fixed by #140527 reintroduced by #223342 opw-5137695 closes odoo/odoo#233855 X-original-commit: 48d7ce6849563c2d0ea4f5a23da98e2e49c469e7 Signed-off-by: Yannick Tivisse (yti) Signed-off-by: Ian Dao (iada) --- addons/hr/tests/test_hr_version.py | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/hr/tests/test_hr_version.py b/addons/hr/tests/test_hr_version.py index 6ffec2c5add38..d262b7fa8f953 100644 --- a/addons/hr/tests/test_hr_version.py +++ b/addons/hr/tests/test_hr_version.py @@ -652,6 +652,7 @@ def test_hr_version_fields_tracking(self): "currency_id", "date_end", "date_start", + "departure_description", "display_name", "id", "is_current", From 54ebb4ca3cc196c6a22a5b6c7a95f84f8f238b58 Mon Sep 17 00:00:00 2001 From: "Xavier Bol (xbo)" Date: Tue, 28 Oct 2025 13:54:05 +0000 Subject: [PATCH 036/673] [FIX] project_todo: reset context to avoid adding additional todo vals Before this commit, when the user creates a new user from the user_ids field of a task, an onboarding todo will be created with the context given by Framework JS, which means, if the context contains `default_project_id` the onboarding todo will become a task inside the project instead of being a real todo (task with no project set). This commit makes sure the context is reset before creating the onboarding todo. task-5217306 closes odoo/odoo#233758 X-original-commit: c10c50a964021ff714a5f72ca24a786fe30a644d Signed-off-by: Xavier Bol (xbo) --- addons/project_todo/models/res_users.py | 2 +- .../tests/test_todo_onboarding_for_users.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/addons/project_todo/models/res_users.py b/addons/project_todo/models/res_users.py index 2f200031897d4..e73f9a252a5a3 100644 --- a/addons/project_todo/models/res_users.py +++ b/addons/project_todo/models/res_users.py @@ -104,4 +104,4 @@ def _generate_onboarding_todo(self): "name": title, }) if create_vals: - self.env["project.task"].with_user(SUPERUSER_ID).with_context(mail_auto_subscribe_no_notify=True).create(create_vals) + self.env["project.task"].with_user(SUPERUSER_ID).with_context({'mail_auto_subscribe_no_notify': True}).create(create_vals) diff --git a/addons/project_todo/tests/test_todo_onboarding_for_users.py b/addons/project_todo/tests/test_todo_onboarding_for_users.py index e700937031cbf..73ecb976c9763 100644 --- a/addons/project_todo/tests/test_todo_onboarding_for_users.py +++ b/addons/project_todo/tests/test_todo_onboarding_for_users.py @@ -17,6 +17,7 @@ def test_onboarding_stages_and_task_created_for_new_users(self): onboarding_tasks = ProjectTaskSudo.search([('user_ids', 'in', internal_user.ids)]) self.assertEqual(len(onboarding_tasks), 1, "Exactly 1 onboarding task should be created for internal users upon creation.") + self.assertFalse(onboarding_tasks.project_id, "Onboarding task should not be linked to any project.") portal_user = new_test_user( self.env, login="portal_user", @@ -32,3 +33,14 @@ def test_onboarding_stages_and_task_created_for_new_users(self): ) onboarding_tasks = ProjectTaskSudo.search([('user_ids', 'in', public_user.ids)]) self.assertEqual(len(onboarding_tasks), 0, "Public users should not receive onboarding tasks upon creation.") + + project = self.env['project.project'].create({'name': 'Test Project'}) + other_internal_user = new_test_user( + self.env, + login="other_internal_user", + groups="base.group_user", + context={'default_project_id': project.id}, + ) + onboarding_tasks = ProjectTaskSudo.search([('user_ids', 'in', other_internal_user.ids)]) + self.assertEqual(len(onboarding_tasks), 1, "Exactly 1 onboarding task should be created for internal users upon creation.") + self.assertFalse(onboarding_tasks.project_id, "Onboarding task should not be linked to any project.") From 9d5efd1493cb9b7a098b258bc027735e0d50f2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20K=C3=BChn?= Date: Thu, 30 Oct 2025 14:03:43 +0000 Subject: [PATCH 037/673] [FIX] im_livechat, *: last agent leaving from chat window ends convo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *: mail Before this commit, when the last agent from a live chat conversation leave, the live chat conversation did not end. Steps to reproduce: - install "ai" and "im_livechat" modules - have a visitor initiate a live chat conversation with 1 available human agent - have have human agent open conversation in chat window and close chat window + confirm button => the live chat conversation does not end When live chat agent is about to close the chat window of live chat, there's a warning to tell that this will make him/her leave the conversation and thus end the conversation. When proceeding, it doesn't actually do this. This is a bug caused by overrides of `ChatWindow._onClose()`, which is a function invoked during the closing of chat window, that has an option `notifyState` that determines whether the user leaves the conversation or not. The overridden code had to ensure the param is preserved and passed to `super` calls, but they fail to do this, and thus the closing of chat window is not making the user leave the conversation. This commit fixes the issue by passing `...arguments` to super calls to make sure the params are preserved as expected by original code of the `_onClose` function. Note that we had a test for the good working of the feature, but this test run with `im_livechat` assets and not overrides on top of it such as `ai` module. The main culpit of the problem was caused by the override in `ai` module. To have test coverage for this problem, the test is not executed in both `im_livechat` test suite and the `test_discuss_full_enterprise`, which is a module whose HOOT suite runs code of discuss with all overrides such as `ai` module. closes odoo/odoo#233876 X-original-commit: 3b4e170f2845f7709521cf24bd1cd931c812ea82 Related: odoo/enterprise#98513 Signed-off-by: Sébastien Theys (seb) Signed-off-by: Alexandre Kühn (aku) --- .../public_web/chat_window_model_patch.js | 4 +- .../static/tests/channel_join_leave.test.js | 31 +------------ .../static/tests/im_livechat_shared_tests.js | 43 +++++++++++++++++++ .../src/core/web/chat_window_model_patch.js | 2 +- 4 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 addons/im_livechat/static/tests/im_livechat_shared_tests.js diff --git a/addons/im_livechat/static/src/core/public_web/chat_window_model_patch.js b/addons/im_livechat/static/src/core/public_web/chat_window_model_patch.js index 958b3b44bae31..be2f5cf8e9335 100644 --- a/addons/im_livechat/static/src/core/public_web/chat_window_model_patch.js +++ b/addons/im_livechat/static/src/core/public_web/chat_window_model_patch.js @@ -8,13 +8,13 @@ patch(ChatWindow.prototype, { this.channel.livechatVisitorMember?.persona?.notEq(this.store.self) ) { const channel = this.channel; // save ref before delete - super._onClose(); + super._onClose(...arguments); this.delete(); if (options.notifyState) { channel.leaveChannel({ force: true }); } } else { - super._onClose(); + super._onClose(...arguments); } }, }); diff --git a/addons/im_livechat/static/tests/channel_join_leave.test.js b/addons/im_livechat/static/tests/channel_join_leave.test.js index a30d132ba9065..39a9d0ddd50c4 100644 --- a/addons/im_livechat/static/tests/channel_join_leave.test.js +++ b/addons/im_livechat/static/tests/channel_join_leave.test.js @@ -13,6 +13,7 @@ import { describe, test } from "@odoo/hoot"; import { withGuest } from "@mail/../tests/mock_server/mail_mock_server"; import { rpc } from "@web/core/network/rpc"; import { serializeDate, today } from "@web/core/l10n/dates"; +import { livechatLastAgentLeaveFromChatWindow } from "./im_livechat_shared_tests"; describe.current.tags("desktop"); defineLivechatModels(); @@ -105,35 +106,7 @@ test("from the command palette", async () => { await contains(".o_notification", { text: "You joined HR." }); }); -test("from chat window", async () => { - const pyEnv = await startServer(); - pyEnv["res.users"].write([serverState.userId], { - group_ids: pyEnv["res.groups"] - .search_read([["id", "=", serverState.groupLivechatId]]) - .map(({ id }) => id), - }); - const guestId = pyEnv["mail.guest"].create({ name: "Visitor" }); - const livechatChannelId = pyEnv["im_livechat.channel"].create({ - name: "HR", - user_ids: [serverState.userId], - }); - const channelId = pyEnv["discuss.channel"].create({ - channel_type: "livechat", - channel_member_ids: [ - Command.create({ partner_id: serverState.partnerId, livechat_member_type: "agent" }), - Command.create({ guest_id: guestId, livechat_member_type: "visitor" }), - ], - livechat_channel_id: livechatChannelId, - livechat_operator_id: serverState.partnerId, - create_uid: serverState.publicUserId, - }); - setupChatHub({ opened: [channelId] }); - await start(); - await contains(".o-mail-ChatWindow"); - await click("button[title*='Close Chat Window']"); - await click("button:contains('Yes, leave conversation')"); - await contains(".o-mail-ChatWindow", { count: 0 }); -}); +test("from chat window", livechatLastAgentLeaveFromChatWindow); test("visitor leaving ends the livechat conversation", async () => { const pyEnv = await startServer(); diff --git a/addons/im_livechat/static/tests/im_livechat_shared_tests.js b/addons/im_livechat/static/tests/im_livechat_shared_tests.js new file mode 100644 index 0000000000000..97c29e316de6c --- /dev/null +++ b/addons/im_livechat/static/tests/im_livechat_shared_tests.js @@ -0,0 +1,43 @@ +import { expect } from "@odoo/hoot"; +import { + click, + contains, + setupChatHub, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { Command, onRpc, serverState } from "@web/../tests/web_test_helpers"; + +export async function livechatLastAgentLeaveFromChatWindow() { + const pyEnv = await startServer(); + pyEnv["res.users"].write([serverState.userId], { + group_ids: pyEnv["res.groups"] + .search_read([["id", "=", serverState.groupLivechatId]]) + .map(({ id }) => id), + }); + const guestId = pyEnv["mail.guest"].create({ name: "Visitor" }); + const livechatChannelId = pyEnv["im_livechat.channel"].create({ + name: "HR", + user_ids: [serverState.userId], + }); + const channelId = pyEnv["discuss.channel"].create({ + channel_type: "livechat", + channel_member_ids: [ + Command.create({ partner_id: serverState.partnerId, livechat_member_type: "agent" }), + Command.create({ guest_id: guestId, livechat_member_type: "visitor" }), + ], + livechat_channel_id: livechatChannelId, + livechat_operator_id: serverState.partnerId, + create_uid: serverState.publicUserId, + }); + setupChatHub({ opened: [channelId] }); + onRpc("discuss.channel", "action_unfollow", () => { + expect.step("action_unfollow"); + }); + await start(); + await contains(".o-mail-ChatWindow"); + await click("button[title*='Close Chat Window']"); + await click("button:contains('Yes, leave conversation')"); + await expect.waitForSteps(["action_unfollow"]); + await contains(".o-mail-ChatWindow", { count: 0 }); +} diff --git a/addons/mail/static/src/core/web/chat_window_model_patch.js b/addons/mail/static/src/core/web/chat_window_model_patch.js index 33debc927a76a..7890a1c5a0325 100644 --- a/addons/mail/static/src/core/web/chat_window_model_patch.js +++ b/addons/mail/static/src/core/web/chat_window_model_patch.js @@ -17,6 +17,6 @@ patch(ChatWindow.prototype, { // ensure messaging menu is opened before chat window is closed await Promise.resolve(); } - await super._onClose(options); + await super._onClose(...arguments); }, }); From c3776136dcb99a4108881b0e2d77980fb30e09ea Mon Sep 17 00:00:00 2001 From: tong-odoo Date: Tue, 28 Oct 2025 05:39:10 +0000 Subject: [PATCH 038/673] [FIX] pos_imin: iMin cashbox not working Explanation: openCashbox() is calling this.connected to check if iMin is connected. However the variable is changed to isConnected in the previous PR. Therefore it will always return null. closes odoo/odoo#233375 X-original-commit: 66042dcccd99009d58b704ea2e0a99a35d8306e2 Signed-off-by: Adrien Guilliams (adgu) Signed-off-by: Tommy Ng (tong) --- addons/pos_imin/static/src/app/utils/imin_printer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/pos_imin/static/src/app/utils/imin_printer.js b/addons/pos_imin/static/src/app/utils/imin_printer.js index b1c0143510297..bbd965f2b1cb7 100644 --- a/addons/pos_imin/static/src/app/utils/imin_printer.js +++ b/addons/pos_imin/static/src/app/utils/imin_printer.js @@ -114,7 +114,7 @@ export class IminPrinterAdapter extends BasePrinter { * @override */ openCashbox() { - if (!this.connected) { + if (!this.isConnected) { return; } try { From 6f91f3f5ab6ca50b1ea92d73b2184a56dd21156f Mon Sep 17 00:00:00 2001 From: adip-odoo Date: Mon, 27 Oct 2025 11:57:57 +0000 Subject: [PATCH 039/673] [FIX] mrp: prevent error on splitting multiple manufacturing orders at once Currently, an error occurs when trying to split multiple manufacturing orders at once from the list view. Steps to Reproduce: 1. Install the MRP module with demo data. 2. Select multiple manufacturing orders from the list view. 3. Click on the "Split" option under the Action button. Error: ValueError - Expected singleton: mrp.production.split(1, 2, 3, 4, 5, 6, 7, 8, 9) Cause: The `_compute_num_splits` method referenced `self.max_batch_size` directly, which expects a single record. When multiple records were processed at once, this caused a singleton error. Fix: This commit handles multiple manufacturing order splits to prevent the error. sentry-6951925545 closes odoo/odoo#233862 X-original-commit: 2b3f7520d5106159608e2fee3827190eb4034072 Signed-off-by: Quentin Wolfs (quwo) Signed-off-by: Aditi Patel (adip) --- addons/mrp/tests/test_backorder.py | 33 +++++++++++++++++++++++ addons/mrp/wizard/mrp_production_split.py | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/addons/mrp/tests/test_backorder.py b/addons/mrp/tests/test_backorder.py index a871fd6f4370e..7b70a434c1acb 100644 --- a/addons/mrp/tests/test_backorder.py +++ b/addons/mrp/tests/test_backorder.py @@ -662,6 +662,39 @@ def test_split_mo(self): self.assertEqual(mo.product_qty, 1) self.assertEqual(mo.move_raw_ids.mapped('product_uom_qty'), [0.5, 1]) + def test_split_multiple_MOs(self): + """ + Test that when multiple MOs are selected and the split + action is triggered, each MO is processed and split correctly. + """ + mos = self.env['mrp.production'].create([ + { + 'product_id': self.product_4.id, + 'product_qty': 10, + }, { + 'product_id': self.product_6.id, + 'product_qty': 20, + } + ]) + for mo in mos: + self.assertEqual(mo.state, 'draft') + + # trigger the split wizard for both MOs + action = mos.action_split() + wizard = Form.from_action(self.env, action) + wizard_record = wizard.save() + + # simulate clicking the “Split Production” button for each MO line + for line in wizard_record.production_ids: + split_action = line.action_prepare_split() + split_wizard = Form.from_action(self.env, split_action) + split_wizard.max_batch_size = 5 + split_wizard.save().action_split() + + # verify that each MO is split into expected number of productions + self.assertEqual(len(mos[0].production_group_id.production_ids), 2) + self.assertEqual(len(mos[1].production_group_id.production_ids), 4) + def test_split_mo_partially_available(self): """ Test that an MO components availability is correct after split. diff --git a/addons/mrp/wizard/mrp_production_split.py b/addons/mrp/wizard/mrp_production_split.py index ec3d875bf5a08..3c54ea2c9eaac 100644 --- a/addons/mrp/wizard/mrp_production_split.py +++ b/addons/mrp/wizard/mrp_production_split.py @@ -39,7 +39,7 @@ def _compute_max_batch_size(self): def _compute_num_splits(self): self.num_splits = 0 for wizard in self: - if wizard.product_uom_id.compare(self.max_batch_size, 0) > 0: + if wizard.product_uom_id.compare(wizard.max_batch_size, 0) > 0: wizard.num_splits = float_round( wizard.product_qty / wizard.max_batch_size, precision_digits=0, From f29066b6a7e9ac6fbb2d165b17405168748feb7b Mon Sep 17 00:00:00 2001 From: Xavier ALT Date: Wed, 29 Oct 2025 12:44:20 +0000 Subject: [PATCH 040/673] [FIX] im_livechat: fix operator not able to pin message in LC session To reproduce (on runbot): - S1: Connect as "admin", leave the "YourWebsite.com" then logout - S1: Connect as "demo" user - S2: As public user, go to /contactus and start a chat session - S2: On the chatbot interaction, choose "I have a pricing question" (this will forward to the operator) - S2: enter a message - S1: On the livechat session, try to pin the last user message Since 1ecddc3d79dd an `AccessError` is raised, as the "demo" user (which is only `LiveChat / User`) don't have access to the chatbot step anymore. As we're not in the interacting with the chatbot when pinning a message, simplify skip that part if there is no "chatbotx answner" context to prevent the `AccessError`. tmp closes odoo/odoo#233878 X-original-commit: cc197141c4e3b2f68ad583aa1615f2fdce53bc04 Signed-off-by: Matthieu Stockbauer (tsm) --- addons/im_livechat/models/discuss_channel.py | 4 ++-- addons/website_livechat/controllers/chatbot.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/addons/im_livechat/models/discuss_channel.py b/addons/im_livechat/models/discuss_channel.py index a1772ff7b4876..cbae615e1afcc 100644 --- a/addons/im_livechat/models/discuss_channel.py +++ b/addons/im_livechat/models/discuss_channel.py @@ -755,13 +755,13 @@ def _message_post_after_hook(self, message, msg_vals): It's created only if the mail channel is linked to a chatbot step. We also need to save the user answer if the current step is a question selection. """ - if self.chatbot_current_step_id: + if self.chatbot_current_step_id and not self.livechat_agent_history_ids: selected_answer = ( self.env["chatbot.script.answer"] .browse(self.env.context.get("selected_answer_id")) .exists() ) - if selected_answer in self.chatbot_current_step_id.answer_ids: + if selected_answer and selected_answer in self.chatbot_current_step_id.answer_ids: # sudo - chatbot.message: finding the question message to update the user answer is allowed. question_msg = ( self.env["chatbot.message"] diff --git a/addons/website_livechat/controllers/chatbot.py b/addons/website_livechat/controllers/chatbot.py index 4c46837a5817f..e284124797d8b 100644 --- a/addons/website_livechat/controllers/chatbot.py +++ b/addons/website_livechat/controllers/chatbot.py @@ -33,9 +33,15 @@ def chatbot_test_script(self, chatbot_script): # so that the channel is unpinned "unpin_dt": fields.Datetime.now(), "last_interest_dt": fields.Datetime.now() - timedelta(seconds=30), - } + "livechat_member_type": "bot", + }, + ), + Command.create( + { + "partner_id": request.env.user.partner_id.id, + "livechat_member_type": "visitor", + }, ), - Command.create({"partner_id": request.env.user.partner_id.id}), ], 'livechat_operator_id': chatbot_script.operator_partner_id.id, 'chatbot_current_step_id': chatbot_script._get_welcome_steps()[-1].id, From 76c891e4e9d53d62b8971cc0d68ec24e3e93fd93 Mon Sep 17 00:00:00 2001 From: Benjamin Vray Date: Thu, 16 Oct 2025 07:59:52 +0000 Subject: [PATCH 041/673] [IMP] html_builder, website: reset crop after shape removal Steps to reproduce: - Enter website edit mode. - Drag and drop a snippet containing an image onto the page. - Click the image. - Apply a shape that enforces a 1/1 ratio with stretch disabled (e.g the first one). - Remove the shape with the close button. Before this commit, removing the shape kept data-aspect-ratio at 1/1, so the picture stayed cropped or distorted. After this commit, removing the shape clears the crop dataset when no manual crop values exist, restoring the original proportions. task-5170195 closes odoo/odoo#233852 X-original-commit: 0c4ad96e585980c62e219c7bfd28e08c75964705 Signed-off-by: Colin Louis (loco) Signed-off-by: Benjamin Vray (bvr) --- .../plugins/image/image_shape_option_plugin.js | 13 +++++++++++++ .../static/tests/builder/image_shape.test.js | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/addons/html_builder/static/src/plugins/image/image_shape_option_plugin.js b/addons/html_builder/static/src/plugins/image/image_shape_option_plugin.js index 849d6856bc852..292b06cd15596 100644 --- a/addons/html_builder/static/src/plugins/image/image_shape_option_plugin.js +++ b/addons/html_builder/static/src/plugins/image/image_shape_option_plugin.js @@ -5,6 +5,7 @@ import { getShapeURL } from "@html_builder/plugins/image/image_helpers"; import { activateCropper, createDataURL, + cropperDataFields, loadImage, loadImageInfo, isGif, @@ -406,6 +407,18 @@ export class SetImageShapeAction extends BuilderAction { static dependencies = ["imageShapeOption"]; async load({ editingElement: img, value: shapeId }) { const params = { shape: shapeId }; + // A crop is applied to the image at the same time as certain shapes, + // which is why we reset the crop here or when the shape is removed. + // However, we don’t reset it when the crop was applied intentionally. + // In that case, there are crop values; otherwise, there are none, + // only a 'data-aspect-ratio'. + if ( + !shapeId && + img.dataset.aspectRatio && + !cropperDataFields.some((field) => field in img.dataset) + ) { + params["aspectRatio"] = undefined; + } // todo nby: re-read the old option method `setImgShape` and be sure all the logic is in there return this.dependencies.imageShapeOption.loadShape(img, params); } diff --git a/addons/website/static/tests/builder/image_shape.test.js b/addons/website/static/tests/builder/image_shape.test.js index 7311b224273f2..4e7469fbed357 100644 --- a/addons/website/static/tests/builder/image_shape.test.js +++ b/addons/website/static/tests/builder/image_shape.test.js @@ -524,3 +524,21 @@ describe("toggle ratio", () => { expect(`:iframe .test-options-target img`).not.toHaveAttribute("src", croppedSrc); }); }); + +test("Should reset crop when removing shape with ratio", async () => { + const { waitSidebarUpdated } = await setupWebsiteBuilder(` +

    + ${testImg} +
    + `); + + await contains(":iframe .test-options-target img").click(); + await contains("[data-label='Shape'] .dropdown").click(); + await contains("[data-action-value='html_builder/geometric/geo_shuriken']").click(); + await waitSidebarUpdated(); + expect(`:iframe .test-options-target img`).toHaveAttribute("data-aspect-ratio"); + // Remove the shape. + await contains("[data-action-id='setImageShape']").click(); + await waitSidebarUpdated(); + expect(`:iframe .test-options-target img`).not.toHaveAttribute("data-aspect-ratio"); +}); From c85df20341092d0f354a25db4a2f0bbec00ecdfb Mon Sep 17 00:00:00 2001 From: plha-odoo Date: Fri, 3 Oct 2025 17:03:36 +0000 Subject: [PATCH 042/673] [FIX] mrp_subcontracting: prevent unbuild subcontracted MO **Problem:** unbuilding a Manufactring order created through a subcontracting process gives the wrong account move lines **Steps to reproduce:** - create a storable product (the comp) and set a cost - create a storable product (the final product), set a cost and set a vendor - for the final product set the category as avco and automated - for the final product create a bill of materials subcontracted and set the same vendor - for the components add the comp for a quantity of 1 - create a Purchase order for the final product and the same vendor and confirm - validate the receipt - From the receipt click on the valuation smart button and click on the book widget of the line of the final product - notice how there is 3 journal items line including one crediting "stock interim (Received)" - unarchive the operation type "subcontracting" - open Manufacturing/Manufacturing Orders, delete the "to do" filter and search for a Manufacturing order with your final product - unbuild it - Open accounting/journal entries and select the journal entry for the unbuild **Current behavior:** There is only two account lines. There is no line balancing the "Stock Interim" line of the manufacturing order. **Cause of the issue:** The override of _generate_valuation_lines_data in mrp_subcontracted_account adds the stock interim line on the manufacturing order. However when unbuilding, the qty is negative so we exit the function https://github.com/odoo/odoo/blob/1358f93a4c73de5a28cda72ec78769625c863efd/addons/mrp_subcontracting_account/models/stock_move.py#L20 **fix** Because subcontracted Manufacturing orders are not meant to be unbuilt, we prevent it opw-4998137 closes odoo/odoo#233742 X-original-commit: 7d7488acb00232c51348e6a66966f0bf12a56207 Signed-off-by: Quentin Wolfs (quwo) Signed-off-by: Pierre-Louis Hance (plha) --- addons/mrp_subcontracting/models/__init__.py | 1 + addons/mrp_subcontracting/models/mrp_unbuild.py | 15 +++++++++++++++ .../tests/test_subcontracting.py | 16 ++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 addons/mrp_subcontracting/models/mrp_unbuild.py diff --git a/addons/mrp_subcontracting/models/__init__.py b/addons/mrp_subcontracting/models/__init__.py index 33434a1c1f6a5..6547f4657d3e8 100644 --- a/addons/mrp_subcontracting/models/__init__.py +++ b/addons/mrp_subcontracting/models/__init__.py @@ -12,3 +12,4 @@ from . import stock_rule from . import stock_warehouse from . import mrp_production +from . import mrp_unbuild diff --git a/addons/mrp_subcontracting/models/mrp_unbuild.py b/addons/mrp_subcontracting/models/mrp_unbuild.py new file mode 100644 index 0000000000000..ee71a0dbcc33b --- /dev/null +++ b/addons/mrp_subcontracting/models/mrp_unbuild.py @@ -0,0 +1,15 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, _ +from odoo.exceptions import UserError + + +class MrpProduction(models.Model): + _inherit = 'mrp.production' + + def button_unbuild(self): + if self.subcontractor_id: + raise UserError(_( + "You can't unbuild a subcontracted Manufacturing Order.", + )) + return super().button_unbuild() diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index e71c6f67080ad..441cde23b59bd 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -933,6 +933,22 @@ def test_replenish_with_subcontracting_bom(self): }) self.assertFalse(replenish_wizard.allowed_route_ids) + def test_subcontracting_unbuild_warning(self): + with Form(self.env['stock.picking']) as picking_form: + picking_form.picking_type_id = self.env.ref('stock.picking_type_in') + picking_form.partner_id = self.subcontractor_partner1 + with picking_form.move_ids.new() as move: + move.product_id = self.finished + move.product_uom_qty = 3 + move.quantity = 3 + picking_receipt = picking_form.save() + picking_receipt.action_confirm() + subcontract = picking_receipt._get_subcontract_production() + error_message = "You can't unbuild a subcontracted Manufacturing Order." + with self.assertRaisesRegex(UserError, error_message): + subcontract.button_unbuild() + + @tagged('post_install', '-at_install') class TestSubcontractingTracking(TransactionCase): From 2235a3aa522bbf8d8e13704dfbdf92cf616dca51 Mon Sep 17 00:00:00 2001 From: "Pedram (PEBR)" Date: Tue, 21 Oct 2025 14:13:14 +0000 Subject: [PATCH 043/673] [FIX] pos_self_order: remove call to resetTableIdentifier Before this commit, there was still a call to the resetTableIdentifier, even though the function had been removed, which could cause errors. opw-5166737 closes odoo/odoo#233829 X-original-commit: aa1ac27d046e9f77e2f646f2b6ac39969db16801 Signed-off-by: Adrien Guilliams (adgu) Signed-off-by: Pedram Bi Ria (pebr) --- .../pos_self_order/static/src/app/services/self_order_service.js | 1 - 1 file changed, 1 deletion(-) 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 2f35f2c236966..f1b9fed6f0920 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 @@ -724,7 +724,6 @@ export class SelfOrder extends Reactive { cleanOrders = true; } else if (error?.data?.name === "odoo.exceptions.UserError") { message = error.data.message; - this.resetTableIdentifier(); } } else if (error instanceof ConnectionLostError) { this.dialog.add(NetworkConnectionLostPopup, { From 0c8ff49676cd50b1eff8ffc9091461b694af766e Mon Sep 17 00:00:00 2001 From: "Pedram (PEBR)" Date: Mon, 27 Oct 2025 16:53:30 +0000 Subject: [PATCH 044/673] [FIX] pos_sale: prevent setting lot when user cancels lot selection Before this commit, when loading a sale order containing an order line tracked by lot, the system prompted the user to select a lot. However, even if the user canceled the selection, the lot was still added to the order line. After this commit, the lot will no longer be set if the user cancels the selection. opw-5162487 closes odoo/odoo#233873 X-original-commit: e0d95a1b1158589e36598e7a56db1004849f8ead Signed-off-by: Adrien Guilliams (adgu) Signed-off-by: Pedram Bi Ria (pebr) --- .../pos_sale/static/src/app/services/pos_store.js | 6 +++++- addons/pos_sale/static/tests/tours/pos_sale_tour.js | 13 +++++++++++++ .../static/tests/tours/utils/pos_sale_utils.js | 6 ++++-- addons/pos_sale/tests/test_pos_sale_flow.py | 1 + 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/addons/pos_sale/static/src/app/services/pos_store.js b/addons/pos_sale/static/src/app/services/pos_store.js index 086e3890e2a6f..fd50ed0d18bec 100644 --- a/addons/pos_sale/static/src/app/services/pos_store.js +++ b/addons/pos_sale/static/src/app/services/pos_store.js @@ -178,7 +178,11 @@ patch(PosStore.prototype, { } // Order line can only hold one lot, so we need to split the line if there are multiple lots - if (line.product_id.tracking == "lot" && converted_line.lot_names.length > 0) { + if ( + line.product_id.tracking == "lot" && + converted_line.lot_names.length > 0 && + useLoadedLots + ) { newLine.delete(); for (const lot of converted_line.lot_names) { const splitted_line = this.models["pos.order.line"].create({ diff --git a/addons/pos_sale/static/tests/tours/pos_sale_tour.js b/addons/pos_sale/static/tests/tours/pos_sale_tour.js index 05533dbfdbe92..8f705ae78d3bc 100644 --- a/addons/pos_sale/static/tests/tours/pos_sale_tour.js +++ b/addons/pos_sale/static/tests/tours/pos_sale_tour.js @@ -516,6 +516,19 @@ registry.category("web_tour.tours").add("test_multiple_lots_sale_order_1", { }); registry.category("web_tour.tours").add("test_multiple_lots_sale_order_2", { + steps: () => + [ + Chrome.startPoS(), + PosSale.settleNthOrder(1, { loadSN: false }), + Order.hasLine({ productName: "Product", quantity: "3.0" }), + { + content: "Check that the line-lot-icon has text-danger class", + trigger: `.order-container .orderline:has(.product-name:contains("Product")) .line-lot-icon.text-danger`, + }, + ].flat(), +}); + +registry.category("web_tour.tours").add("test_multiple_lots_sale_order_3", { steps: () => [ Chrome.startPoS(), diff --git a/addons/pos_sale/static/tests/tours/utils/pos_sale_utils.js b/addons/pos_sale/static/tests/tours/utils/pos_sale_utils.js index 7923f099395a5..f02ceaa922f6f 100644 --- a/addons/pos_sale/static/tests/tours/utils/pos_sale_utils.js +++ b/addons/pos_sale/static/tests/tours/utils/pos_sale_utils.js @@ -39,10 +39,12 @@ export function settleNthOrder(n, options = {}) { run: "click", }, ]; - if (loadSN) { + if (loadSN !== undefined) { step.push({ content: `Choose to auto link the lot number to the order line`, - trigger: `.modal-content:contains('Do you want to load the SN/Lots linked to the Sales Order?') button:contains('Ok')`, + trigger: `.modal-content:contains('Do you want to load the SN/Lots linked to the Sales Order?') button:contains('${ + loadSN ? "Ok" : "Cancel" + }')`, run: "click", }); } diff --git a/addons/pos_sale/tests/test_pos_sale_flow.py b/addons/pos_sale/tests/test_pos_sale_flow.py index 09f17faf06bab..2094e00d19c18 100644 --- a/addons/pos_sale/tests/test_pos_sale_flow.py +++ b/addons/pos_sale/tests/test_pos_sale_flow.py @@ -1525,6 +1525,7 @@ def test_multiple_lots_sale_order(self): self.start_tour("/pos/ui?config_id=%d" % self.main_pos_config.id, 'test_multiple_lots_sale_order_1', login="accountman") sale_order.action_confirm() self.start_tour("/pos/ui?config_id=%d" % self.main_pos_config.id, 'test_multiple_lots_sale_order_2', login="accountman") + self.start_tour("/pos/ui?config_id=%d" % self.main_pos_config.id, 'test_multiple_lots_sale_order_3', login="accountman") self.main_pos_config.current_session_id.action_pos_session_close() picking = sale_order.pos_order_line_ids.order_id.picking_ids self.assertEqual(picking.move_ids.quantity, 3) From d2987732c8addf657fded6f97f2bde235b1c9940 Mon Sep 17 00:00:00 2001 From: "Maruan Aguerdouh (magm)" Date: Tue, 28 Oct 2025 16:52:11 +0000 Subject: [PATCH 045/673] [FIX] mass_mailing: ensure MassMailingHtmlField works on Safari There is an issue with the MassMailingHtmlField on WebKit browsers such as Safari where, once a "mail theme" is selected, the iframe stays blank and the user is not able to edit the mailing. Steps to reproduce: 1. On Safari, got to Email Marketing app. 2. Create a new email campaign. 3. Select any of the pre-built templates (mail theme). Cause: There is an [issue] with the WebKit implementation of `iframe` `sandbox`: - For an iframe with `src="about:blank"` (equivalent to no `src`) with `sandbox="allow-same-origin"`, if the parent document adds event listeners on elements inside the iframe contentDocument, they can not be executed without the flag `allow-scripts`, which defeats the purpose of the `sandbox` in our case. Other JavaScript engines don't have this issue. Resolution: Event listeners set by the parent document currently are an essential feature of the `HtmlBuilder` editor, and are also used to load scss bundles inside the iframe. A major refactoring would be needed to not require them (the only viable solution would be to make an editor endpoint, and enclose the whole editor feature inside the iframe, and communicate with an Odoo view through `postMessage`, sandbox would be set to "allow-scripts" only). To alleviate the Odoo issue in the short term, `script-src` Content-Security-Policy is set to none inside the iframe, and `allow-scripts` flag is added to the sandbox attribute for browsers identifying as Safari. This effectively allows event listeners set by the parent document to run, but still prevents script execution inside the iframe. [issue]: https://bugs.webkit.org/show_bug.cgi?id=218086 opw-5208425 closes odoo/odoo#233877 X-original-commit: 5cb84f4e7b0385162f6c38c11a1cb6bad397bae7 Signed-off-by: Damien Abeloos (abd) Co-authored-by: Damien Abeloos Co-authored-by: Maruan Aguerdouh --- .../mass_mailing/static/src/iframe/mass_mailing_iframe.js | 7 ++++++- .../mass_mailing/static/src/iframe/mass_mailing_iframe.xml | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.js b/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.js index e71da2a66c4e2..7f1b38c112d05 100644 --- a/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.js +++ b/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.js @@ -20,6 +20,7 @@ import { useThrottleForAnimation } from "@web/core/utils/timing"; import { closestScrollableY } from "@web/core/utils/scrolling"; import { _t } from "@web/core/l10n/translation"; import { localization } from "@web/core/l10n/localization"; +import { isBrowserSafari } from "@web/core/browser/feature_detection"; const IFRAME_VALUE_SELECTOR = ".o_mass_mailing_value"; @@ -192,7 +193,12 @@ export class MassMailingIframe extends Component { ); } + get isBrowserSafari() { + return isBrowserSafari(); + } + async setupIframe() { + this.iframeRef.el?.contentDocument.head.appendChild(this.renderHeadContent()); await this.loadIframeAssets(); if (status(this) === "destroyed") { return; @@ -204,7 +210,6 @@ export class MassMailingIframe extends Component { } else { this.iframeRef.el.contentDocument.body.classList.add("bg-white"); } - this.iframeRef.el.contentDocument.head.appendChild(this.renderHeadContent()); this.iframeRef.el.contentDocument.body.appendChild(this.renderBodyContent()); htmlResizeObserver.observe( this.iframeRef.el.contentDocument.body.querySelector(IFRAME_VALUE_SELECTOR) diff --git a/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.xml b/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.xml index 564536d70ad5e..4f1ff65a53a6b 100644 --- a/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.xml +++ b/addons/mass_mailing/static/src/iframe/mass_mailing_iframe.xml @@ -9,7 +9,8 @@
    -