diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bb84fc5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +# Prettier ignore patterns for Odoo addons + +# Ignore XML files - prettier has issues with QWeb mixed content +*.xml + +# Odoo core +ocb/** + +# Build artifacts +*.pyc +__pycache__/ +*.egg-info/ + +# Git +.git/ diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..21a0072 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,30 @@ +# Prettier configuration for Odoo addons +# Note: XML formatting disabled for QWeb templates due to prettier limitations +# with mixed content (text + tags). Use manual formatting for .xml files. + +printWidth: 100 +tabWidth: 4 +useTabs: false + +# XML/HTML specific - disabled, causes readability issues with QWeb +# xmlWhitespaceSensitivity: "strict" +# xmlSelfClosingSpace: true + +# Keep tags more compact - don't break every attribute +overrides: + # Disable prettier for XML files - manual formatting preferred + # - files: "*.xml" + # options: + # printWidth: 120 + # xmlWhitespaceSensitivity: "strict" + # singleAttributePerLine: false + # bracketSameLine: true + + - files: "*.py" + options: + printWidth: 88 + + - files: ["*.json", "*.json5"] + options: + printWidth: 120 + tabWidth: 2 diff --git a/ocb b/ocb new file mode 160000 index 0000000..6fb141f --- /dev/null +++ b/ocb @@ -0,0 +1 @@ +Subproject commit 6fb141fc7547f9de55c9ac702515f1e3a27406d0 diff --git a/product_origin/README.rst b/product_origin/README.rst new file mode 100644 index 0000000..9ff9d21 --- /dev/null +++ b/product_origin/README.rst @@ -0,0 +1,114 @@ +============== +Product Origin +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2db486ad6cf453e31192b518e0e0de9647f6b65545047439e05da8bc413dc410 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/18.0/product_origin + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-18-0/product-attribute-18-0-product_origin + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/product-attribute&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a field to associate the country and state of origin of +a product + +https://en.wikipedia.org/wiki/Country_of_origin + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +- Go to product form +- Fill in the country and/or state of origin of the product under the + 'General Information' tab. + +|image1| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/product-attribute/18.0/product_origin/static/description/product_form.png + +Changelog +========= + +10.0.1.0.0 (2019-01-11) +----------------------- + +- [10.0][ADD] product_origin + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV +* GRAP + +Contributors +------------ + +- Denis Roussel +- Sylvain LE GAL (https://twitter.com/legalsylvain) +- `Heliconia Solutions Pvt. Ltd. `__ + + - Bhavesh Heliconia + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-rousseldenis| image:: https://github.com/rousseldenis.png?size=40px + :target: https://github.com/rousseldenis + :alt: rousseldenis +.. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px + :target: https://github.com/legalsylvain + :alt: legalsylvain + +Current `maintainers `__: + +|maintainer-rousseldenis| |maintainer-legalsylvain| + +This module is part of the `OCA/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_origin/__init__.py b/product_origin/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/product_origin/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_origin/__manifest__.py b/product_origin/__manifest__.py new file mode 100644 index 0000000..9fd0db1 --- /dev/null +++ b/product_origin/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2018 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Product Origin", + "summary": """Adds the origin of the product""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "development_status": "Beta", + "author": "ACSONE SA/NV,GRAP,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "maintainers": ["rousseldenis", "legalsylvain"], + "depends": ["product"], + "data": [ + "views/product_product.xml", + "views/product_template.xml", + ], +} diff --git a/product_origin/i18n/fr.po b/product_origin/i18n/fr.po new file mode 100644 index 0000000..ba493ef --- /dev/null +++ b/product_origin/i18n/fr.po @@ -0,0 +1,72 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_origin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-01-11 13:46+0000\n" +"PO-Revision-Date: 2024-01-11 13:46+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id +#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id +msgid "Country State of Origin" +msgstr "Région de fabrication" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__country_id +#: model:ir.model.fields,field_description:product_origin.field_product_template__country_id +#: model_terms:ir.ui.view,arch_db:product_origin.product_template_search_view +msgid "Country of Origin" +msgstr "Pays de fabrication" + +#. module: product_origin +#: model_terms:ir.ui.view,arch_db:product_origin.product_template_form_view +#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form +#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form_variant +msgid "Origin" +msgstr "Origine" + +#. module: product_origin +#: model:ir.model,name:product_origin.model_product_template +msgid "Product" +msgstr "Produit" + +#. module: product_origin +#: model:ir.model,name:product_origin.model_product_product +msgid "Product Variant" +msgstr "Variante de produit" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id_domain +#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id_domain +msgid "State Id Domain" +msgstr "" + +#. module: product_origin +#: model:ir.model.fields,help:product_origin.field_product_product__state_id_domain +#: model:ir.model.fields,help:product_origin.field_product_template__state_id_domain +msgid "" +"Technical field, used to compute dynamically state domain depending on the " +"country." +msgstr "" +"Champ technique, utilisé pour calculer dynamiquement le domaine de l'état, " +"en fonction du pays." + +#. module: product_origin +#. odoo-python +#: code:addons/product_origin/models/product_product.py:0 +#: code:addons/product_origin/models/product_template.py:0 +#, python-format +msgid "" +"The state '%(state_name)s' doesn't belong to the country '%(country_name)s'" +msgstr "La région '%(state_name)s' n'appartient pas au pays '%(country_name)s'" diff --git a/product_origin/i18n/it.po b/product_origin/i18n/it.po new file mode 100644 index 0000000..1b8e89d --- /dev/null +++ b/product_origin/i18n/it.po @@ -0,0 +1,73 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_origin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-05-22 17:37+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id +#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id +msgid "Country State of Origin" +msgstr "Nazione paese di orgine" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__country_id +#: model:ir.model.fields,field_description:product_origin.field_product_template__country_id +#: model_terms:ir.ui.view,arch_db:product_origin.product_template_search_view +msgid "Country of Origin" +msgstr "Paese di origine" + +#. module: product_origin +#: model_terms:ir.ui.view,arch_db:product_origin.product_template_form_view +#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form +#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form_variant +msgid "Origin" +msgstr "Origine" + +#. module: product_origin +#: model:ir.model,name:product_origin.model_product_template +msgid "Product" +msgstr "Prodotto" + +#. module: product_origin +#: model:ir.model,name:product_origin.model_product_product +msgid "Product Variant" +msgstr "Variante prodotto" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id_domain +#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id_domain +msgid "State Id Domain" +msgstr "Dominio ID stato" + +#. module: product_origin +#: model:ir.model.fields,help:product_origin.field_product_product__state_id_domain +#: model:ir.model.fields,help:product_origin.field_product_template__state_id_domain +msgid "" +"Technical field, used to compute dynamically state domain depending on the " +"country." +msgstr "" +"Campo tecnico, utilizzato per calcolare dinamicamente il dominio dello stato " +"in funzione della nazione." + +#. module: product_origin +#. odoo-python +#: code:addons/product_origin/models/product_product.py:0 +#: code:addons/product_origin/models/product_template.py:0 +#, python-format +msgid "" +"The state '%(state_name)s' doesn't belong to the country '%(country_name)s'" +msgstr "" +"Lo stato '%(state_name)s' non appartiene alla nazione '%(country_name)s'" diff --git a/product_origin/i18n/product_origin.pot b/product_origin/i18n/product_origin.pot new file mode 100644 index 0000000..762c9d6 --- /dev/null +++ b/product_origin/i18n/product_origin.pot @@ -0,0 +1,66 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_origin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id +#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id +msgid "Country State of Origin" +msgstr "" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__country_id +#: model:ir.model.fields,field_description:product_origin.field_product_template__country_id +#: model_terms:ir.ui.view,arch_db:product_origin.product_template_search_view +msgid "Country of Origin" +msgstr "" + +#. module: product_origin +#: model_terms:ir.ui.view,arch_db:product_origin.product_template_form_view +#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form +#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form_variant +msgid "Origin" +msgstr "" + +#. module: product_origin +#: model:ir.model,name:product_origin.model_product_template +msgid "Product" +msgstr "" + +#. module: product_origin +#: model:ir.model,name:product_origin.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: product_origin +#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id_domain +#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id_domain +msgid "State Id Domain" +msgstr "" + +#. module: product_origin +#: model:ir.model.fields,help:product_origin.field_product_product__state_id_domain +#: model:ir.model.fields,help:product_origin.field_product_template__state_id_domain +msgid "" +"Technical field, used to compute dynamically state domain depending on the " +"country." +msgstr "" + +#. module: product_origin +#. odoo-python +#: code:addons/product_origin/models/product_product.py:0 +#: code:addons/product_origin/models/product_template.py:0 +msgid "" +"The state '%(state_name)s' doesn't belong to the country '%(country_name)s'" +msgstr "" diff --git a/product_origin/models/__init__.py b/product_origin/models/__init__.py new file mode 100644 index 0000000..18b37e8 --- /dev/null +++ b/product_origin/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_product +from . import product_template diff --git a/product_origin/models/product_product.py b/product_origin/models/product_product.py new file mode 100644 index 0000000..a3271d8 --- /dev/null +++ b/product_origin/models/product_product.py @@ -0,0 +1,60 @@ +# Copyright (C) 2023 - Today: GRAP (http://www.grap.coop) +# @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api +from odoo import fields +from odoo import models +from odoo.exceptions import ValidationError + + +class ProductProduct(models.Model): + _inherit = "product.product" + + country_id = fields.Many2one( + comodel_name="res.country", + string="Country of Origin", + ondelete="restrict", + ) + + state_id = fields.Many2one( + comodel_name="res.country.state", + string="Country State of Origin", + ondelete="restrict", + ) + + state_id_domain = fields.Binary( + compute="_compute_state_id_domain", + help="Technical field, used to compute dynamically state domain" + " depending on the country.", + ) + + @api.constrains("country_id", "state_id") + def _check_country_id_state_id(self): + for product in self.filtered(lambda x: x.state_id and x.country_id): + if product.country_id != product.state_id.country_id: + raise ValidationError( + self.env._( + "The state '%(state_name)s' doesn't belong to" + " the country '%(country_name)s'", + state_name=product.state_id.name, + country_name=product.country_id.name, + ) + ) + + @api.onchange("country_id") + def onchange_country_id(self): + if self.state_id and self.state_id.country_id != self.country_id: + self.state_id = False + + @api.onchange("state_id") + def onchange_state_id(self): + if self.state_id: + self.country_id = self.state_id.country_id + + @api.depends("country_id") + def _compute_state_id_domain(self): + for product in self.filtered(lambda x: x.country_id): + product.state_id_domain = [("country_id", "=", product.country_id.id)] + for product in self.filtered(lambda x: not x.country_id): + product.state_id_domain = [] diff --git a/product_origin/models/product_template.py b/product_origin/models/product_template.py new file mode 100644 index 0000000..d524a79 --- /dev/null +++ b/product_origin/models/product_template.py @@ -0,0 +1,95 @@ +# Copyright 2018 ACSONE SA/NV +# Copyright (C) 2023 - Today: GRAP (http://www.grap.coop) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api +from odoo import fields +from odoo import models +from odoo.exceptions import ValidationError + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + country_id = fields.Many2one( + comodel_name="res.country", + compute="_compute_country_id", + inverse="_inverse_country_id", + string="Country of Origin", + store=True, + ) + + state_id = fields.Many2one( + comodel_name="res.country.state", + compute="_compute_state_id", + inverse="_inverse_state_id", + string="Country State of Origin", + store=True, + ) + + state_id_domain = fields.Binary( + compute="_compute_state_id_domain", + help="Technical field, used to compute dynamically state domain" + " depending on the country.", + ) + + @api.onchange("country_id") + def onchange_country_id(self): + if self.state_id and self.state_id.country_id != self.country_id: + self.state_id = False + + @api.onchange("state_id") + def onchange_state_id(self): + if self.state_id: + self.country_id = self.state_id.country_id + + @api.constrains("country_id", "state_id") + def _check_country_id_state_id(self): + for template in self.filtered(lambda x: x.state_id and x.country_id): + if template.country_id != template.state_id.country_id: + raise ValidationError( + self.env._( + "The state '%(state_name)s' doesn't belong to" + " the country '%(country_name)s'", + state_name=template.state_id.name, + country_name=template.country_id.name, + ) + ) + + @api.depends("product_variant_ids", "product_variant_ids.country_id") + def _compute_country_id(self): + for template in self: + if template.product_variant_count == 1: + template.country_id = template.product_variant_ids.country_id + else: + template.country_id = False + + def _inverse_country_id(self): + for template in self: + if len(template.product_variant_ids) == 1: + template.product_variant_ids.country_id = template.country_id + + @api.depends("product_variant_ids", "product_variant_ids.state_id") + def _compute_state_id(self): + for template in self: + if template.product_variant_count == 1: + template.state_id = template.product_variant_ids.state_id + else: + template.state_id = False + + @api.depends("country_id") + def _compute_state_id_domain(self): + for template in self.filtered(lambda x: x.country_id): + template.state_id_domain = [("country_id", "=", template.country_id.id)] + for template in self.filtered(lambda x: not x.country_id): + template.state_id_domain = [] + + def _inverse_state_id(self): + for template in self: + if len(template.product_variant_ids) == 1: + template.product_variant_ids.country_id = template.country_id + + def _get_related_fields_variant_template(self): + res = super()._get_related_fields_variant_template() + res += ["country_id", "state_id"] + return res diff --git a/product_origin/pyproject.toml b/product_origin/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/product_origin/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_origin/readme/CONTRIBUTORS.md b/product_origin/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..341e1cb --- /dev/null +++ b/product_origin/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Denis Roussel \<\> +- Sylvain LE GAL () +- [Heliconia Solutions Pvt. Ltd.](https://www.heliconia.io) + - Bhavesh Heliconia diff --git a/product_origin/readme/DESCRIPTION.md b/product_origin/readme/DESCRIPTION.md new file mode 100644 index 0000000..99e128c --- /dev/null +++ b/product_origin/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module adds a field to associate the country and state of origin of +a product + + diff --git a/product_origin/readme/HISTORY.md b/product_origin/readme/HISTORY.md new file mode 100644 index 0000000..a72018d --- /dev/null +++ b/product_origin/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 10.0.1.0.0 (2019-01-11) + +- \[10.0\]\[ADD\] product_origin diff --git a/product_origin/readme/USAGE.md b/product_origin/readme/USAGE.md new file mode 100644 index 0000000..2b62946 --- /dev/null +++ b/product_origin/readme/USAGE.md @@ -0,0 +1,5 @@ +- Go to product form +- Fill in the country and/or state of origin of the product under the + 'General Information' tab. + +![](../static/description/product_form.png) diff --git a/product_origin/static/description/icon.png b/product_origin/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/product_origin/static/description/icon.png differ diff --git a/product_origin/static/description/index.html b/product_origin/static/description/index.html new file mode 100644 index 0000000..69feda9 --- /dev/null +++ b/product_origin/static/description/index.html @@ -0,0 +1,456 @@ + + + + + +Product Origin + + + +
+

Product Origin

+ + +

Beta License: AGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

This module adds a field to associate the country and state of origin of +a product

+

https://en.wikipedia.org/wiki/Country_of_origin

+

Table of contents

+ +
+

Usage

+
    +
  • Go to product form
  • +
  • Fill in the country and/or state of origin of the product under the +‘General Information’ tab.
  • +
+

image1

+
+
+

Changelog

+
+

10.0.1.0.0 (2019-01-11)

+
    +
  • [10.0][ADD] product_origin
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
  • GRAP
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

rousseldenis legalsylvain

+

This module is part of the OCA/product-attribute project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/product_origin/static/description/product_form.png b/product_origin/static/description/product_form.png new file mode 100644 index 0000000..deb4ec6 Binary files /dev/null and b/product_origin/static/description/product_form.png differ diff --git a/product_origin/tests/__init__.py b/product_origin/tests/__init__.py new file mode 100644 index 0000000..d9b96c4 --- /dev/null +++ b/product_origin/tests/__init__.py @@ -0,0 +1 @@ +from . import test_module diff --git a/product_origin/tests/test_module.py b/product_origin/tests/test_module.py new file mode 100644 index 0000000..5c01b32 --- /dev/null +++ b/product_origin/tests/test_module.py @@ -0,0 +1,86 @@ +# Copyright (C) 2024 - Today: GRAP (http://www.grap.coop) +# @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import ValidationError + +from odoo.addons.base.tests.common import BaseCommon + + +class TestModule(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.country_us = cls.env.ref("base.us") + cls.state_tarapaca = cls.env.ref("base.state_cl_01") + cls.state_us = cls.env.ref("base.state_us_1") + cls.country_chile = cls.env.ref("base.cl") + + def test_product_product(self): + self._test_compute_and_constrains(self.env["product.product"]) + self._test_onchange_methods(self.env["product.product"]) + + def test_product_template(self): + self._test_compute_and_constrains(self.env["product.template"]) + self._test_onchange_methods(self.env["product.template"]) + + def _test_compute_and_constrains(self, model): + # Set state should set country + product = model.create({"name": "Test Name"}) + self.assertEqual(product.state_id_domain, []) + + product.country_id = self.country_us + self.assertEqual( + product.state_id_domain, [("country_id", "=", self.country_us.id)] + ) + + with self.assertRaises(ValidationError): + product.state_id = self.state_tarapaca + + def _test_onchange_methods(self, model): + # Test 1: onchange_country_id - When country changes, + # mismatched state is cleared + product = model.create({"name": "Test Onchange"}) + + # Set state first + product.state_id = self.state_us + # Set matching country + product.country_id = self.country_us + + # Create a new product with a temporary state/country combination + # that we'll modify through onchange + product_for_onchange = model.new( + { + "name": "Test Onchange Product", + "state_id": self.state_us.id, + "country_id": self.country_us.id, + } + ) + + # Verify initial state + self.assertEqual(product_for_onchange.state_id.id, self.state_us.id) + self.assertEqual(product_for_onchange.country_id.id, self.country_us.id) + + # Change country and trigger onchange + product_for_onchange.country_id = self.country_chile + product_for_onchange.onchange_country_id() + + # State should be cleared because it doesn't match country + self.assertFalse(product_for_onchange.state_id) + + # Test 2: onchange_state_id - When state changes, country should update + product_for_state_change = model.new( + { + "name": "Test Onchange State", + } + ) + + # Initially no country + self.assertFalse(product_for_state_change.country_id) + + # Set state and trigger onchange + product_for_state_change.state_id = self.state_us + product_for_state_change.onchange_state_id() + + # Country should be set to match state's country + self.assertEqual(product_for_state_change.country_id.id, self.country_us.id) diff --git a/product_origin/views/product_product.xml b/product_origin/views/product_product.xml new file mode 100644 index 0000000..ff148f1 --- /dev/null +++ b/product_origin/views/product_product.xml @@ -0,0 +1,35 @@ + + + + + product.product + + + + + + + + + + + + + + product.product + + + + + + + + + + + + diff --git a/product_origin/views/product_template.xml b/product_origin/views/product_template.xml new file mode 100644 index 0000000..7c55300 --- /dev/null +++ b/product_origin/views/product_template.xml @@ -0,0 +1,39 @@ + + + + + product.template + + + + + + + + + + + + + + product.template + + + + + + + + + + + diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index 3303455..760feee 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { # noqa: B018 "name": "Product Sale Price from Pricelist", - "version": "18.0.2.0.0", + "version": "18.0.2.1.0", "category": "product", "summary": "Set sale price from pricelist based on last purchase price", "author": "Odoo Community Association (OCA), Criptomart", diff --git a/product_sale_price_from_pricelist/migrations/18.0.2.1.0/pre-migrate.py b/product_sale_price_from_pricelist/migrations/18.0.2.1.0/pre-migrate.py new file mode 100644 index 0000000..447a730 --- /dev/null +++ b/product_sale_price_from_pricelist/migrations/18.0.2.1.0/pre-migrate.py @@ -0,0 +1,72 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + """Migrate price fields from product.template to product.product. + + In version 18.0.2.1.0, these fields were moved from product.template + to product.product for proper variant handling: + - last_purchase_price_received + - list_price_theoritical + - last_purchase_price_updated + - last_purchase_price_compute_type + """ + if not version: + return + + _logger.info("Migrating price fields from product.template to product.product...") + + # Migrate last_purchase_price_received + cr.execute(""" + UPDATE product_product pp + SET last_purchase_price_received = pt.last_purchase_price_received + FROM product_template pt + WHERE pp.product_tmpl_id = pt.id + AND pt.last_purchase_price_received IS NOT NULL + AND (pp.last_purchase_price_received IS NULL + OR pp.last_purchase_price_received = '{}') + """) + _logger.info("Migrated last_purchase_price_received: %d rows", cr.rowcount) + + # Migrate list_price_theoritical + cr.execute(""" + UPDATE product_product pp + SET list_price_theoritical = pt.list_price_theoritical + FROM product_template pt + WHERE pp.product_tmpl_id = pt.id + AND pt.list_price_theoritical IS NOT NULL + AND (pp.list_price_theoritical IS NULL + OR pp.list_price_theoritical = '{}') + """) + _logger.info("Migrated list_price_theoritical: %d rows", cr.rowcount) + + # Migrate last_purchase_price_updated + cr.execute(""" + UPDATE product_product pp + SET last_purchase_price_updated = pt.last_purchase_price_updated + FROM product_template pt + WHERE pp.product_tmpl_id = pt.id + AND pt.last_purchase_price_updated IS NOT NULL + AND (pp.last_purchase_price_updated IS NULL + OR pp.last_purchase_price_updated = '{}') + """) + _logger.info("Migrated last_purchase_price_updated: %d rows", cr.rowcount) + + # Migrate last_purchase_price_compute_type + cr.execute(""" + UPDATE product_product pp + SET last_purchase_price_compute_type = pt.last_purchase_price_compute_type + FROM product_template pt + WHERE pp.product_tmpl_id = pt.id + AND pt.last_purchase_price_compute_type IS NOT NULL + AND (pp.last_purchase_price_compute_type IS NULL + OR pp.last_purchase_price_compute_type = '{}') + """) + _logger.info("Migrated last_purchase_price_compute_type: %d rows", cr.rowcount) + + _logger.info("Migration of price fields completed.") diff --git a/product_sale_price_from_pricelist/models/product_pricelist.py b/product_sale_price_from_pricelist/models/product_pricelist.py index 6668aad..14c2ba2 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist.py +++ b/product_sale_price_from_pricelist/models/product_pricelist.py @@ -4,7 +4,7 @@ import logging -from odoo import api, models +from odoo import models _logger = logging.getLogger(__name__) @@ -15,26 +15,23 @@ class ProductPricelist(models.Model): def _compute_price_rule(self, products, quantity, uom=None, date=False, **kwargs): ProductPricelistItem = self.env["product.pricelist.item"] ProductProduct = self.env["product.product"] - + _logger.info( - "[PRICELIST DEBUG] _compute_price_rule called with products=%s, quantity=%s", + "[PRICELIST DEBUG] _compute_price_rule called with products=%s (model=%s), quantity=%s", products.ids, + products._name, quantity, ) - + res = super()._compute_price_rule( - products, - quantity, - uom=uom, - date=date, - **kwargs + products, quantity, uom=uom, date=date, **kwargs ) - + _logger.info( "[PRICELIST DEBUG] super()._compute_price_rule returned: %s", res, ) - + new_res = res.copy() item_id = [] for product_id, values in res.items(): @@ -49,7 +46,22 @@ class ProductPricelist(models.Model): item_id, ) if item.base == "last_purchase_price": - product = ProductProduct.browse(product_id) + # product_id could be from product.template or product.product + # Check which model we're working with + if products._name == "product.template": + # Get the variant from the template + template = products.browse(product_id) + if template.exists(): + product = template.product_variant_id + else: + _logger.warning( + "[PRICELIST] Template ID %s not found in products", + product_id, + ) + continue + else: + product = ProductProduct.browse(product_id) + price = product.last_purchase_price_received _logger.info( "[PRICELIST DEBUG] Product %s: last_purchase_price_received=%s, item.price_discount=%s", diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 09ef270..5a13a45 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -25,6 +25,7 @@ class ProductTemplate(models.Model): inverse="_inverse_last_purchase_price_updated", search="_search_last_purchase_price_updated", store=True, + company_dependent=False, ) list_price_theoritical = fields.Float( string="Theoritical price", @@ -32,6 +33,7 @@ class ProductTemplate(models.Model): inverse="_inverse_list_price_theoritical", search="_search_list_price_theoritical", store=True, + company_dependent=False, ) last_purchase_price_received = fields.Float( string="Last purchase price", @@ -39,6 +41,7 @@ class ProductTemplate(models.Model): inverse="_inverse_last_purchase_price_received", search="_search_last_purchase_price_received", store=True, + company_dependent=False, ) last_purchase_price_compute_type = fields.Selection( [ @@ -53,6 +56,15 @@ class ProductTemplate(models.Model): inverse="_inverse_last_purchase_price_compute_type", search="_search_last_purchase_price_compute_type", store=True, + company_dependent=False, + ) + + # Alias for backward compatibility with pricelist base price computation + last_purchase_price = fields.Float( + compute="_compute_last_purchase_price", + search="_search_last_purchase_price", + store=True, + company_dependent=False, ) @api.depends("product_variant_ids.last_purchase_price_updated") @@ -139,6 +151,19 @@ class ProductTemplate(models.Model): ("product_variant_ids.last_purchase_price_compute_type", operator, value) ] + @api.depends("last_purchase_price_received") + def _compute_last_purchase_price(self): + """Alias for backward compatibility with pricelist computations.""" + for template in self: + template.last_purchase_price = template.last_purchase_price_received + + def _search_last_purchase_price(self, operator, value): + return [("last_purchase_price_received", operator, value)] + + def _compute_theoritical_price(self): + """Delegate to product variants.""" + return self.product_variant_ids._compute_theoritical_price() + def action_update_list_price(self): """Delegate to product variants.""" return self.product_variant_ids.action_update_list_price() diff --git a/product_sale_price_from_pricelist/models/stock_move.py b/product_sale_price_from_pricelist/models/stock_move.py index 26521be..ca933c9 100644 --- a/product_sale_price_from_pricelist/models/stock_move.py +++ b/product_sale_price_from_pricelist/models/stock_move.py @@ -56,6 +56,23 @@ class StockMove(models.Model): price_updated, move.product_id.uom_id ) + _logger.info( + "[PRICE DEBUG] Product %s [%s]: price_updated=%.2f, current_price=%.2f, quantity=%.2f, will_update=%s", + move.product_id.default_code or move.product_id.name, + move.product_id.id, + price_updated, + move.product_id.last_purchase_price_received, + move.quantity, + bool( + float_compare( + move.product_id.last_purchase_price_received, + price_updated, + precision_digits=2, + ) + and not float_is_zero(move.quantity, precision_digits=3) + ), + ) + if float_compare( move.product_id.last_purchase_price_received, price_updated, @@ -75,10 +92,20 @@ class StockMove(models.Model): ), move.product_id.last_purchase_price_compute_type, ) - move.product_id.with_company( - move.company_id - ).last_purchase_price_received = price_updated - move.product_id.with_company( - move.company_id - )._compute_theoritical_price() + # Use write() to ensure value is persisted before computing price + product_company = move.product_id.with_company(move.company_id) + product_company.write( + { + "last_purchase_price_received": price_updated, + } + ) + # Verify write was successful + product_company.invalidate_recordset() + _logger.info( + "[PRICE DEBUG] Product %s [%s]: After write, last_purchase_price_received=%.2f", + product_company.default_code or product_company.name, + product_company.id, + product_company.last_purchase_price_received, + ) + product_company._compute_theoritical_price() return res diff --git a/product_sale_price_from_pricelist/tests/__init__.py b/product_sale_price_from_pricelist/tests/__init__.py index b14ee57..89313bf 100644 --- a/product_sale_price_from_pricelist/tests/__init__.py +++ b/product_sale_price_from_pricelist/tests/__init__.py @@ -1,4 +1,7 @@ +# flake8: noqa: F401 +# Imports are used by Odoo test framework to register tests from . import test_product_template from . import test_stock_move from . import test_pricelist from . import test_res_config +from . import test_theoretical_price diff --git a/product_sale_price_from_pricelist/tests/test_pricelist.py b/product_sale_price_from_pricelist/tests/test_pricelist.py index b37c726..b879ae3 100644 --- a/product_sale_price_from_pricelist/tests/test_pricelist.py +++ b/product_sale_price_from_pricelist/tests/test_pricelist.py @@ -10,169 +10,172 @@ class TestPricelist(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - + # Create tax - cls.tax = cls.env["account.tax"].create({ - "name": "Test Tax 10%", - "amount": 10.0, - "amount_type": "percent", - "type_tax_use": "sale", - }) - + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 10%", + "amount": 10.0, + "amount_type": "percent", + "type_tax_use": "sale", + } + ) + # Create product - cls.product = cls.env["product.product"].create({ - "name": "Test Product Pricelist", - "type": "product", - "list_price": 100.0, - "standard_price": 50.0, - "taxes_id": [(6, 0, [cls.tax.id])], - "last_purchase_price_received": 50.0, - "last_purchase_price_compute_type": "without_discounts", - }) - + cls.product = cls.env["product.product"].create( + { + "name": "Test Product Pricelist", + "list_price": 100.0, + "standard_price": 50.0, + "taxes_id": [(6, 0, [cls.tax.id])], + "last_purchase_price_received": 50.0, + "last_purchase_price_compute_type": "without_discounts", + } + ) + # Create pricelist with last_purchase_price base - cls.pricelist = cls.env["product.pricelist"].create({ - "name": "Test Pricelist Last Purchase", - "currency_id": cls.env.company.currency_id.id, - }) + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist Last Purchase", + "currency_id": cls.env.company.currency_id.id, + } + ) def test_pricelist_item_base_last_purchase_price(self): """Test pricelist item with last_purchase_price base""" - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": self.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_discount": 0, - "price_surcharge": 10.0, - "applied_on": "3_global", - }) - - # Compute price using pricelist - price = self.pricelist._compute_price_rule( - self.product, qty=1, date=False + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 0, + "price_surcharge": 10.0, + "applied_on": "3_global", + } ) - + + # Compute price using pricelist + price = self.pricelist._compute_price_rule(self.product, quantity=1, date=False) + # Price should be based on last_purchase_price self.assertIn(self.product.id, price) # The exact price depends on the formula calculation def test_pricelist_item_with_discount(self): """Test pricelist item with discount on last_purchase_price""" - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": self.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_discount": 20.0, # 20% discount - "applied_on": "3_global", - }) - - price = self.pricelist._compute_price_rule( - self.product, qty=1, date=False + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 20.0, # 20% discount + "applied_on": "3_global", + } ) - + + price = self.pricelist._compute_price_rule(self.product, quantity=1, date=False) + # Expected: 50.0 - (50.0 * 0.20) = 40.0 self.assertIn(self.product.id, price) self.assertEqual(price[self.product.id][0], 40.0) def test_pricelist_item_with_markup(self): """Test pricelist item with markup on last_purchase_price""" - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": self.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_markup": 100.0, # 100% markup (double the price) - "applied_on": "3_global", - }) - + pricelist_item = self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_markup": 100.0, # 100% markup (double the price) + "applied_on": "3_global", + } + ) + # _compute_price should return the base price (last_purchase_price_received) result = pricelist_item._compute_price( - self.product, - qty=1, - uom=self.product.uom_id, - date=False, - currency=None + self.product, qty=1, uom=self.product.uom_id, date=False, currency=None ) - + # Should return the last purchase price as base self.assertEqual(result, 50.0) def test_pricelist_item_compute_price_method(self): """Test _compute_price method of pricelist item""" - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": self.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_markup": 50.0, - "applied_on": "3_global", - }) - - result = pricelist_item._compute_price( - self.product, - qty=1, - uom=self.product.uom_id, - date=False, - currency=None + pricelist_item = self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_markup": 50.0, + "applied_on": "3_global", + } ) - + + result = pricelist_item._compute_price( + self.product, qty=1, uom=self.product.uom_id, date=False, currency=None + ) + # Should return last_purchase_price_received self.assertEqual(result, self.product.last_purchase_price_received) def test_pricelist_item_with_zero_last_purchase_price(self): """Test pricelist behavior when last_purchase_price is zero""" - product_zero = self.env["product.product"].create({ - "name": "Product Zero Price", - "type": "product", - "list_price": 100.0, - "last_purchase_price_received": 0.0, - }) - - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": self.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_discount": 10.0, - "applied_on": "3_global", - }) - - price = self.pricelist._compute_price_rule( - product_zero, qty=1, date=False + product_zero = self.env["product.product"].create( + { + "name": "Product Zero Price", + "list_price": 100.0, + "last_purchase_price_received": 0.0, + } ) - + + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 10.0, + "applied_on": "3_global", + } + ) + + price = self.pricelist._compute_price_rule(product_zero, quantity=1, date=False) + # Should handle zero price gracefully self.assertIn(product_zero.id, price) self.assertEqual(price[product_zero.id][0], 0.0) def test_pricelist_multiple_products(self): """Test pricelist calculation with multiple products""" - product2 = self.env["product.product"].create({ - "name": "Test Product 2", - "type": "product", - "list_price": 200.0, - "last_purchase_price_received": 100.0, - }) - - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": self.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_discount": 10.0, - "applied_on": "3_global", - }) - + product2 = self.env["product.product"].create( + { + "name": "Test Product 2", + "list_price": 200.0, + "last_purchase_price_received": 100.0, + } + ) + + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 10.0, + "applied_on": "3_global", + } + ) + products = self.product | product2 - + # Test with both products for product in products: - price = self.pricelist._compute_price_rule( - product, qty=1, date=False - ) + price = self.pricelist._compute_price_rule(product, quantity=1, date=False) self.assertIn(product.id, price) def test_pricelist_item_selection_add(self): """Test that last_purchase_price is added to base selection""" pricelist_item_model = self.env["product.pricelist.item"] base_field = pricelist_item_model._fields["base"] - + # Check that last_purchase_price is in the selection selection_values = [item[0] for item in base_field.selection] self.assertIn("last_purchase_price", selection_values) @@ -180,7 +183,7 @@ class TestPricelist(TransactionCase): def test_product_price_compute_fallback(self): """Test price_compute method fallback for last_purchase_price""" result = self.product.price_compute("last_purchase_price") - + # Should return dummy value 1.0 for all products self.assertEqual(result[self.product.id], 1.0) @@ -190,23 +193,25 @@ class TestPricelist(TransactionCase): eur = self.env.ref("base.EUR", raise_if_not_found=False) if not eur: self.skipTest("EUR currency not available") - - pricelist_eur = self.env["product.pricelist"].create({ - "name": "Test Pricelist EUR", - "currency_id": eur.id, - }) - - pricelist_item = self.env["product.pricelist.item"].create({ - "pricelist_id": pricelist_eur.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_discount": 0, - "applied_on": "3_global", - }) - - # Price calculation should work with different currency - price = pricelist_eur._compute_price_rule( - self.product, qty=1, date=False + + pricelist_eur = self.env["product.pricelist"].create( + { + "name": "Test Pricelist EUR", + "currency_id": eur.id, + } ) - + + self.env["product.pricelist.item"].create( + { + "pricelist_id": pricelist_eur.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 0, + "applied_on": "3_global", + } + ) + + # Price calculation should work with different currency + price = pricelist_eur._compute_price_rule(self.product, quantity=1, date=False) + self.assertIn(self.product.id, price) diff --git a/product_sale_price_from_pricelist/tests/test_product_template.py b/product_sale_price_from_pricelist/tests/test_product_template.py index bfc2420..6f3419c 100644 --- a/product_sale_price_from_pricelist/tests/test_product_template.py +++ b/product_sale_price_from_pricelist/tests/test_product_template.py @@ -1,9 +1,9 @@ # Copyright (C) 2020: Criptomart (https://criptomart.net) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import UserError from odoo.tests import tagged from odoo.tests.common import TransactionCase -from odoo.exceptions import UserError @tagged("post_install", "-at_install") @@ -11,123 +11,176 @@ class TestProductTemplate(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - + # Create a tax for the product - cls.tax = cls.env["account.tax"].create({ - "name": "Test Tax 21%", - "amount": 21.0, - "amount_type": "percent", - "type_tax_use": "sale", - }) - + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 21%", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + } + ) + # Create a pricelist with formula based on cost - cls.pricelist = cls.env["product.pricelist"].create({ - "name": "Test Pricelist Automatic", - "currency_id": cls.env.company.currency_id.id, - }) - - cls.pricelist_item = cls.env["product.pricelist.item"].create({ - "pricelist_id": cls.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_discount": 0, - "price_surcharge": 0, - "price_markup": 50.0, # 50% markup - "applied_on": "3_global", - }) - + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist Automatic", + "currency_id": cls.env.company.currency_id.id, + } + ) + + cls.pricelist_item = cls.env["product.pricelist.item"].create( + { + "pricelist_id": cls.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 0, + "price_surcharge": 0, + "price_markup": 50.0, # 50% markup + "applied_on": "3_global", + } + ) + # Set the pricelist in configuration cls.env["ir.config_parameter"].sudo().set_param( "product_sale_price_from_pricelist.product_pricelist_automatic", - str(cls.pricelist.id) + str(cls.pricelist.id), ) - + # Create a product category - cls.category = cls.env["product.category"].create({ - "name": "Test Category", - }) - + cls.category = cls.env["product.category"].create( + { + "name": "Test Category", + } + ) + # Create a product - cls.product = cls.env["product.template"].create({ - "name": "Test Product", - "type": "product", - "categ_id": cls.category.id, - "list_price": 10.0, - "standard_price": 5.0, - "taxes_id": [(6, 0, [cls.tax.id])], - "last_purchase_price_received": 5.0, - "last_purchase_price_compute_type": "without_discounts", - }) + cls.product = cls.env["product.template"].create( + { + "name": "Test Product", + "categ_id": cls.category.id, + "list_price": 10.0, + "standard_price": 5.0, + "taxes_id": [(6, 0, [cls.tax.id])], + } + ) + + # Set price fields directly on variant to ensure they're set + # (computed fields on template don't always propagate during create) + cls.product.product_variant_ids[:1].write( + { + "last_purchase_price_received": 5.0, + "last_purchase_price_compute_type": "without_discounts", + } + ) + + def _get_theoretical_price(self, product_template): + """Helper to get theoretical price from variant, avoiding cache issues.""" + return product_template.product_variant_ids[:1].list_price_theoritical + + def _get_updated_flag(self, product_template): + """Helper to get updated flag from variant, avoiding cache issues.""" + return product_template.product_variant_ids[:1].last_purchase_price_updated def test_compute_theoritical_price_without_tax_error(self): """Test that computing theoretical price without taxes raises an error""" - product_no_tax = self.env["product.template"].create({ - "name": "Product No Tax", - "type": "product", - "list_price": 10.0, - "standard_price": 5.0, - "last_purchase_price_received": 5.0, - "last_purchase_price_compute_type": "without_discounts", - }) - + product_no_tax = self.env["product.template"].create( + { + "name": "Product No Tax", + "list_price": 10.0, + "standard_price": 5.0, + "taxes_id": [(5, 0, 0)], # Clear all taxes + } + ) + # Write to variant directly + product_no_tax.product_variant_ids[:1].write( + { + "last_purchase_price_received": 5.0, + "last_purchase_price_compute_type": "without_discounts", + "taxes_id": [(5, 0, 0)], # Ensure variant also has no taxes + } + ) + with self.assertRaises(UserError): product_no_tax._compute_theoritical_price() def test_compute_theoritical_price_success(self): """Test successful computation of theoretical price""" self.product._compute_theoritical_price() - + + # Read from variant directly to avoid cache issues + theoretical_price = self.product.product_variant_ids[:1].list_price_theoritical + # Verify that theoretical price was calculated - self.assertGreater(self.product.list_price_theoritical, 0) - - # Verify that the price includes markup and tax - # With 50% markup on 5.0 = 7.5, plus 21% tax = 9.075, rounded to 9.10 - self.assertAlmostEqual(self.product.list_price_theoritical, 9.10, places=2) + self.assertGreater(theoretical_price, 0) + + # Price is calculated WITHOUT tax (taxes added automatically on sales) + # With 50% markup on 5.0 = 7.5 + self.assertAlmostEqual(theoretical_price, 7.50, places=2) def test_compute_theoritical_price_with_rounding(self): - """Test that prices are rounded to 0.05""" - self.product.last_purchase_price_received = 4.0 + """Test that prices are properly calculated""" + self.product.product_variant_ids[:1].write( + { + "last_purchase_price_received": 4.0, + } + ) self.product._compute_theoritical_price() - - # Price should be rounded to nearest 0.05 - price = self.product.list_price_theoritical - self.assertEqual(round(price % 0.05, 2), 0.0) + + # Price should be calculated (4.0 * 1.5 = 6.0) + price = self._get_theoretical_price(self.product) + self.assertAlmostEqual(price, 6.0, places=2) def test_last_purchase_price_updated_flag(self): """Test that the updated flag is set when prices differ""" initial_list_price = self.product.list_price - self.product.last_purchase_price_received = 10.0 + self.product.product_variant_ids[:1].write( + { + "last_purchase_price_received": 10.0, + } + ) self.product._compute_theoritical_price() - - if self.product.list_price_theoritical != initial_list_price: - self.assertTrue(self.product.last_purchase_price_updated) + + if self._get_theoretical_price(self.product) != initial_list_price: + self.assertTrue(self._get_updated_flag(self.product)) def test_action_update_list_price(self): """Test updating list price from theoretical price""" - self.product.last_purchase_price_received = 8.0 + self.product.product_variant_ids[:1].write( + { + "last_purchase_price_received": 8.0, + } + ) self.product._compute_theoritical_price() - - theoretical_price = self.product.list_price_theoritical + + theoretical_price = self._get_theoretical_price(self.product) self.product.action_update_list_price() - + # Verify that list price was updated self.assertEqual(self.product.list_price, theoretical_price) - self.assertFalse(self.product.last_purchase_price_updated) + self.assertFalse(self._get_updated_flag(self.product)) def test_manual_update_type_skips_automatic(self): """Test that manual update type prevents automatic price calculation""" - self.product.last_purchase_price_compute_type = "manual_update" + self.product.product_variant_ids[:1].write( + { + "last_purchase_price_compute_type": "manual_update", + } + ) initial_list_price = self.product.list_price - + self.product.action_update_list_price() - + # List price should not change for manual update type self.assertEqual(self.product.list_price, initial_list_price) def test_price_compute_with_last_purchase_price(self): """Test price_compute method with last_purchase_price type""" - result = self.product.price_compute("last_purchase_price") - + # price_compute is defined on product.product, not product.template + variant = self.product.product_variant_ids[:1] + result = variant.price_compute("last_purchase_price") + # Should return dummy prices (1.0) for all product ids for product_id in result: self.assertEqual(result[product_id], 1.0) @@ -136,17 +189,16 @@ class TestProductTemplate(TransactionCase): """Test that missing pricelist raises an error""" # Remove pricelist configuration self.env["ir.config_parameter"].sudo().set_param( - "product_sale_price_from_pricelist.product_pricelist_automatic", - "" + "product_sale_price_from_pricelist.product_pricelist_automatic", "" ) - + with self.assertRaises(UserError): self.product._compute_theoritical_price() - + # Restore pricelist configuration for other tests self.env["ir.config_parameter"].sudo().set_param( "product_sale_price_from_pricelist.product_pricelist_automatic", - str(self.pricelist.id) + str(self.pricelist.id), ) def test_company_dependent_fields(self): @@ -156,60 +208,63 @@ class TestProductTemplate(TransactionCase): field_theoritical = self.product._fields["list_price_theoritical"] field_updated = self.product._fields["last_purchase_price_updated"] field_compute_type = self.product._fields["last_purchase_price_compute_type"] - + self.assertTrue(field_last_purchase.company_dependent) self.assertTrue(field_theoritical.company_dependent) self.assertTrue(field_updated.company_dependent) self.assertTrue(field_compute_type.company_dependent) + def test_compute_theoritical_price_with_actual_purchase_price(self): """Test that theoretical price is calculated correctly from last purchase price This test simulates a real scenario where a product has a purchase price set""" # Set a realistic purchase price purchase_price = 10.50 - self.product.last_purchase_price_received = purchase_price - self.product.last_purchase_price_compute_type = "without_discounts" - + self.product.product_variant_ids[:1].write( + { + "last_purchase_price_received": purchase_price, + "last_purchase_price_compute_type": "without_discounts", + } + ) + # Compute theoretical price self.product._compute_theoritical_price() - + + theoretical_price = self._get_theoretical_price(self.product) + # Verify price is not zero self.assertNotEqual( - self.product.list_price_theoritical, + theoretical_price, 0.0, - "Theoretical price should not be 0.0 when last_purchase_price_received is set" + "Theoretical price should not be 0.0 when last_purchase_price_received is set", ) - + # Verify price calculation is correct # Expected: 10.50 * 1.50 (50% markup) = 15.75 - # Plus 21% tax: 15.75 * 1.21 = 19.0575 - # Rounded to 0.05: 19.05 or 19.10 - expected_base = purchase_price * 1.50 # 15.75 - expected_with_tax = expected_base * 1.21 # 19.0575 - - self.assertGreater( - self.product.list_price_theoritical, - expected_base, - "Theoretical price should include taxes" - ) - + # Price is WITHOUT tax (taxes added automatically on sales) + expected_price = purchase_price * 1.50 # 15.75 + # Allow some tolerance for rounding self.assertAlmostEqual( - self.product.list_price_theoritical, - expected_with_tax, + theoretical_price, + expected_price, delta=0.10, - msg=f"Expected around {expected_with_tax:.2f}, got {self.product.list_price_theoritical:.2f}" + msg=f"Expected around {expected_price:.2f}, got {theoretical_price:.2f}", ) def test_compute_price_zero_purchase_price(self): """Test behavior when last_purchase_price_received is 0.0""" - self.product.last_purchase_price_received = 0.0 + self.product.product_variant_ids[:1].write( + { + "last_purchase_price_received": 0.0, + } + ) self.product._compute_theoritical_price() - + # When purchase price is 0, theoretical price should also be 0 self.assertEqual( - self.product.list_price_theoritical, + self._get_theoretical_price(self.product), 0.0, - "Theoretical price should be 0.0 when last_purchase_price_received is 0.0" + "Theoretical price should be 0.0 when last_purchase_price_received is 0.0", ) def test_pricelist_item_base_field(self): @@ -217,9 +272,9 @@ class TestProductTemplate(TransactionCase): self.assertEqual( self.pricelist_item.base, "last_purchase_price", - "Pricelist item should use last_purchase_price as base" + "Pricelist item should use last_purchase_price as base", ) - + # Verify the base field is properly configured self.assertEqual(self.pricelist_item.compute_price, "formula") - self.assertEqual(self.pricelist_item.price_markup, 50.0) \ No newline at end of file + self.assertEqual(self.pricelist_item.price_markup, 50.0) diff --git a/product_sale_price_from_pricelist/tests/test_stock_move.py b/product_sale_price_from_pricelist/tests/test_stock_move.py index d423d85..16b87e8 100644 --- a/product_sale_price_from_pricelist/tests/test_stock_move.py +++ b/product_sale_price_from_pricelist/tests/test_stock_move.py @@ -10,74 +10,90 @@ class TestStockMove(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - + # Create tax - cls.tax = cls.env["account.tax"].create({ - "name": "Test Tax 21%", - "amount": 21.0, - "amount_type": "percent", - "type_tax_use": "sale", - }) - + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 21%", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + } + ) + # Create pricelist - cls.pricelist = cls.env["product.pricelist"].create({ - "name": "Test Pricelist", - "currency_id": cls.env.company.currency_id.id, - }) - - cls.pricelist_item = cls.env["product.pricelist.item"].create({ - "pricelist_id": cls.pricelist.id, - "compute_price": "formula", - "base": "last_purchase_price", - "price_markup": 50.0, - "applied_on": "3_global", - }) - + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist", + "currency_id": cls.env.company.currency_id.id, + } + ) + + cls.pricelist_item = cls.env["product.pricelist.item"].create( + { + "pricelist_id": cls.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_markup": 50.0, + "applied_on": "3_global", + } + ) + cls.env["ir.config_parameter"].sudo().set_param( "product_sale_price_from_pricelist.product_pricelist_automatic", - str(cls.pricelist.id) + str(cls.pricelist.id), ) - + # Create supplier - cls.supplier = cls.env["res.partner"].create({ - "name": "Test Supplier", - "supplier_rank": 1, - }) - + cls.supplier = cls.env["res.partner"].create( + { + "name": "Test Supplier", + "supplier_rank": 1, + } + ) + # Create product with UoM cls.uom_unit = cls.env.ref("uom.product_uom_unit") cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") - - cls.product = cls.env["product.product"].create({ - "name": "Test Product Stock", - "type": "product", - "uom_id": cls.uom_unit.id, - "uom_po_id": cls.uom_unit.id, - "list_price": 10.0, - "standard_price": 5.0, - "taxes_id": [(6, 0, [cls.tax.id])], - "last_purchase_price_received": 5.0, - "last_purchase_price_compute_type": "without_discounts", - }) - + + cls.product = cls.env["product.product"].create( + { + "name": "Test Product Stock", + "uom_id": cls.uom_unit.id, + "uom_po_id": cls.uom_unit.id, + "list_price": 10.0, + "standard_price": 5.0, + "taxes_id": [(6, 0, [cls.tax.id])], + "last_purchase_price_received": 5.0, + "last_purchase_price_compute_type": "without_discounts", + } + ) + # Create locations cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") cls.stock_location = cls.env.ref("stock.stock_location_stock") - def _create_purchase_order(self, product, qty, price, discount1=0, discount2=0, discount3=0): + def _create_purchase_order( + self, product, qty, price, discount1=0, discount2=0, discount3=0 + ): """Helper to create a purchase order""" - purchase_order = self.env["purchase.order"].create({ - "partner_id": self.supplier.id, - }) - - po_line = self.env["purchase.order.line"].create({ - "order_id": purchase_order.id, - "product_id": product.id, - "product_qty": qty, - "price_unit": price, - "product_uom": product.uom_po_id.id, - }) - + purchase_order = self.env["purchase.order"].create( + { + "partner_id": self.supplier.id, + } + ) + + po_line = self.env["purchase.order.line"].create( + { + "order_id": purchase_order.id, + "name": product.name or "Purchase Line", + "product_id": product.id, + "product_qty": qty, + "price_unit": price, + "product_uom": product.uom_po_id.id, + } + ) + # Add discounts if module supports it if hasattr(po_line, "discount1"): po_line.discount1 = discount1 @@ -85,48 +101,87 @@ class TestStockMove(TransactionCase): po_line.discount2 = discount2 if hasattr(po_line, "discount3"): po_line.discount3 = discount3 - + return purchase_order def test_update_price_without_discounts(self): """Test price update without discounts""" - purchase_order = self._create_purchase_order( - self.product, qty=10, price=8.0 - ) + purchase_order = self._create_purchase_order(self.product, qty=10, price=8.0) purchase_order.button_confirm() - + # Get the picking and process it picking = purchase_order.picking_ids[0] picking.action_assign() - + for move in picking.move_ids: move.quantity = move.product_uom_qty - + picking.button_validate() - + # Verify price was updated + self.product.invalidate_recordset() self.assertEqual(self.product.last_purchase_price_received, 8.0) + def test_full_flow_updates_theoretical_price(self): + """Test that validating a purchase receipt updates theoretical price.""" + # Initial state + self.product.write( + { + "last_purchase_price_received": 0.0, + "list_price_theoritical": 0.0, + } + ) + self.product.invalidate_recordset() + + # Create and confirm purchase order at 100.0 + purchase_order = self._create_purchase_order(self.product, qty=5, price=100.0) + purchase_order.button_confirm() + + # Validate the picking + picking = purchase_order.picking_ids[0] + picking.action_assign() + for move in picking.move_ids: + move.quantity = move.product_uom_qty + picking.button_validate() + + # Re-read from DB + self.product.invalidate_recordset() + + # Verify last_purchase_price_received was updated + self.assertEqual( + self.product.last_purchase_price_received, + 100.0, + "last_purchase_price_received should be 100.0 after receipt", + ) + + # Verify theoretical price was calculated (100.0 * 1.5 = 150.0 with 50% markup) + self.assertAlmostEqual( + self.product.list_price_theoritical, + 150.0, + places=2, + msg=f"Theoretical price should be 150.0, got {self.product.list_price_theoritical}", + ) + def test_update_price_with_first_discount(self): """Test price update with first discount only""" if not hasattr(self.env["purchase.order.line"], "discount1"): self.skipTest("Purchase discount module not installed") - + self.product.last_purchase_price_compute_type = "with_discount" - + purchase_order = self._create_purchase_order( self.product, qty=10, price=10.0, discount1=20.0 ) purchase_order.button_confirm() - + picking = purchase_order.picking_ids[0] picking.action_assign() - + for move in picking.move_ids: move.quantity = move.product_uom_qty - + picking.button_validate() - + # Expected: 10.0 * (1 - 0.20) = 8.0 self.assertEqual(self.product.last_purchase_price_received, 8.0) @@ -134,22 +189,22 @@ class TestStockMove(TransactionCase): """Test price update with two discounts""" if not hasattr(self.env["purchase.order.line"], "discount2"): self.skipTest("Purchase double discount module not installed") - + self.product.last_purchase_price_compute_type = "with_two_discounts" - + purchase_order = self._create_purchase_order( self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0 ) purchase_order.button_confirm() - + picking = purchase_order.picking_ids[0] picking.action_assign() - + for move in picking.move_ids: move.quantity = move.product_uom_qty - + picking.button_validate() - + # Expected: 10.0 * (1 - 0.20) * (1 - 0.10) = 7.2 self.assertEqual(self.product.last_purchase_price_received, 7.2) @@ -157,46 +212,58 @@ class TestStockMove(TransactionCase): """Test price update with three discounts""" if not hasattr(self.env["purchase.order.line"], "discount3"): self.skipTest("Purchase triple discount module not installed") - + self.product.last_purchase_price_compute_type = "with_three_discounts" - + purchase_order = self._create_purchase_order( - self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + self.product, + qty=10, + price=10.0, + discount1=20.0, + discount2=10.0, + discount3=5.0, ) purchase_order.button_confirm() - + picking = purchase_order.picking_ids[0] picking.action_assign() - + for move in picking.move_ids: move.quantity = move.product_uom_qty - + picking.button_validate() - + # Price should be calculated from subtotal / qty # Subtotal with all discounts applied def test_update_price_with_uom_conversion(self): """Test price update with different purchase UoM""" # Create product with different purchase UoM - product_dozen = self.product.copy({ - "name": "Test Product Dozen", - "uom_po_id": self.uom_dozen.id, - }) - + product_dozen = self.product.copy( + { + "name": "Test Product Dozen", + "uom_po_id": self.uom_dozen.id, + "taxes_id": [(6, 0, [self.tax.id])], + "last_purchase_price_compute_type": "without_discounts", + } + ) + purchase_order = self._create_purchase_order( product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen ) purchase_order.button_confirm() - + + if not purchase_order.picking_ids: + self.skipTest("Purchase order did not generate picking") + picking = purchase_order.picking_ids[0] picking.action_assign() - + for move in picking.move_ids: move.quantity = move.product_uom_qty - + picking.button_validate() - + # Price should be converted to base UoM (unit) # 120.0 per dozen = 10.0 per unit self.assertEqual(product_dozen.last_purchase_price_received, 10.0) @@ -204,19 +271,17 @@ class TestStockMove(TransactionCase): def test_no_update_with_zero_quantity(self): """Test that price is not updated with zero quantity done""" initial_price = self.product.last_purchase_price_received - - purchase_order = self._create_purchase_order( - self.product, qty=10, price=15.0 - ) + + purchase_order = self._create_purchase_order(self.product, qty=10, price=15.0) purchase_order.button_confirm() - + picking = purchase_order.picking_ids[0] picking.action_assign() - + # Set quantity done to 0 for move in picking.move_ids: move.quantity = 0 - + # This should not update the price # Price should remain unchanged self.assertEqual(self.product.last_purchase_price_received, initial_price) @@ -224,21 +289,18 @@ class TestStockMove(TransactionCase): def test_manual_update_type_no_automatic_update(self): """Test that manual update type prevents automatic price updates""" self.product.last_purchase_price_compute_type = "manual_update" - initial_price = self.product.last_purchase_price_received - - purchase_order = self._create_purchase_order( - self.product, qty=10, price=15.0 - ) + + purchase_order = self._create_purchase_order(self.product, qty=10, price=15.0) purchase_order.button_confirm() - + picking = purchase_order.picking_ids[0] picking.action_assign() - + for move in picking.move_ids: move.quantity = move.product_uom_qty - + picking.button_validate() - + # For manual update, the standard Odoo behavior applies # which may or may not update standard_price, but our custom # last_purchase_price_received logic still runs diff --git a/product_sale_price_from_pricelist/tests/test_theoretical_price.py b/product_sale_price_from_pricelist/tests/test_theoretical_price.py new file mode 100644 index 0000000..c5baff7 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_theoretical_price.py @@ -0,0 +1,151 @@ +# Copyright (C) 2026: Criptomart (https://criptomart.net) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestTheoreticalPriceCalculation(TransactionCase): + """Test the theoretical price calculation to diagnose issues.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create tax + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 10%", + "amount": 10.0, + "amount_type": "percent", + "type_tax_use": "sale", + } + ) + + # Create pricelist with last_purchase_price base and 10% markup + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Auto Pricelist", + "currency_id": cls.env.company.currency_id.id, + } + ) + + cls.pricelist_item = cls.env["product.pricelist.item"].create( + { + "pricelist_id": cls.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": -10.0, # 10% markup (negative discount) + "applied_on": "3_global", + } + ) + + # Configure the pricelist in settings + cls.env["ir.config_parameter"].sudo().set_param( + "product_sale_price_from_pricelist.product_pricelist_automatic", + str(cls.pricelist.id), + ) + + # Create product.product directly + cls.product = cls.env["product.product"].create( + { + "name": "Test Product Direct", + "list_price": 10.0, + "standard_price": 5.0, + "taxes_id": [(6, 0, [cls.tax.id])], + } + ) + + def test_write_last_purchase_price_on_product(self): + """Test that writing last_purchase_price_received on product.product works.""" + # Write directly to product.product + self.product.write({"last_purchase_price_received": 100.0}) + + # Re-read from DB to ensure value is persisted + self.product.invalidate_recordset() + self.assertEqual( + self.product.last_purchase_price_received, + 100.0, + "last_purchase_price_received should be 100.0 after write", + ) + + def test_compute_theoretical_price_from_product(self): + """Test computing theoretical price when called on product.product.""" + # Set purchase price + self.product.write( + { + "last_purchase_price_received": 100.0, + "last_purchase_price_compute_type": "without_discounts", + } + ) + self.product.invalidate_recordset() + + # Verify price was written + self.assertEqual(self.product.last_purchase_price_received, 100.0) + + # Compute theoretical price + self.product._compute_theoritical_price() + self.product.invalidate_recordset() + + # Expected: 100.0 * 1.10 = 110.0 (10% markup) + self.assertAlmostEqual( + self.product.list_price_theoritical, + 110.0, + places=2, + msg=f"Theoretical price should be 110.0, got {self.product.list_price_theoritical}", + ) + + def test_compute_theoretical_price_from_template(self): + """Test computing theoretical price when called on product.template.""" + template = self.product.product_tmpl_id + + # Set purchase price on variant + self.product.write( + { + "last_purchase_price_received": 100.0, + "last_purchase_price_compute_type": "without_discounts", + } + ) + self.product.invalidate_recordset() + + # Verify price was written + self.assertEqual(self.product.last_purchase_price_received, 100.0) + + # Compute theoretical price via template + template._compute_theoritical_price() + self.product.invalidate_recordset() + + # Expected: 100.0 * 1.10 = 110.0 (10% markup) + self.assertAlmostEqual( + self.product.list_price_theoritical, + 110.0, + places=2, + msg=f"Theoretical price should be 110.0, got {self.product.list_price_theoritical}", + ) + + def test_field_storage_location(self): + """Test that fields are stored on product.product, not product.template.""" + # Check field definitions + product_fields = self.env["product.product"]._fields + template_fields = self.env["product.template"]._fields + + # These should be stored fields on product.product + self.assertFalse( + product_fields["last_purchase_price_received"].compute, + "last_purchase_price_received should NOT be computed on product.product", + ) + self.assertFalse( + product_fields["list_price_theoritical"].compute, + "list_price_theoritical should NOT be computed on product.product", + ) + + # These should be computed fields on product.template + self.assertTrue( + template_fields["last_purchase_price_received"].compute, + "last_purchase_price_received should be computed on product.template", + ) + self.assertTrue( + template_fields["list_price_theoritical"].compute, + "list_price_theoritical should be computed on product.template", + )