diff --git a/product_uom_factor/README.rst b/product_uom_factor/README.rst new file mode 100644 index 00000000000..1c5d0d245d0 --- /dev/null +++ b/product_uom_factor/README.rst @@ -0,0 +1,116 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================= +Product UoM Conversion Factor +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e4fc656789bb785888bc779bf2be3607062c97ce46ee17bc1b09cc5d34999203 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/19.0/product_uom_factor + :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-19-0/product-attribute-19-0-product_uom_factor + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Odoo 19.0 allows adding UoMs from any category to a product's +"Packagings" (``uom_ids`` field), enabling users to sell or purchase in +units from a different category than the product's base UoM (e.g., +selling ink by the liter when it is stocked by weight in grams). + +However, Odoo's ``_compute_quantity()`` and ``_compute_price()`` methods +on ``uom.uom`` no longer enforce that the source and destination UoMs +belong to the same category. When converting between UoMs of different +categories, the conversion silently produces incorrect results — +typically a 1:1 ratio between base units of their respective categories +— because the standard UoM factor is relative to the category's +reference unit, not to the product. + +This module adds a **conversion factor** field to the ``product.uom`` +model (the per-product/per-UoM link table). This factor represents the +product-specific relationship between the product's base UoM and the +cross-category UoM. For example, an ink product stocked in grams with a +density of 1.05 g/mL would have a factor of 1.05 on its liter packaging +UoM, meaning 1 liter = 1050 grams. + +The module overrides ``_compute_quantity()`` and ``_compute_price()`` so +that when a product is available in context, cross-category conversions +use the product-specific factor instead of producing potentially +incoherent results. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Bemade Inc. + +Contributors +------------ + +- Marc Durepos marc@bemade.org, Bemade Inc. + +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-mdurepos| image:: https://github.com/mdurepos.png?size=40px + :target: https://github.com/mdurepos + :alt: mdurepos + +Current `maintainer `__: + +|maintainer-mdurepos| + +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_uom_factor/__init__.py b/product_uom_factor/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_uom_factor/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_uom_factor/__manifest__.py b/product_uom_factor/__manifest__.py new file mode 100644 index 00000000000..bbedd73086d --- /dev/null +++ b/product_uom_factor/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Bemade Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Product UoM Conversion Factor", + "summary": "Product-specific conversion factors for cross-category UoM conversions", + "version": "19.0.1.0.0", + "development_status": "Alpha", + "license": "LGPL-3", + "author": "Bemade Inc., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "maintainers": ["mdurepos"], + "depends": [ + "product", + ], + "data": [ + "security/ir.model.access.csv", + "views/product_product_views.xml", + "views/product_template_views.xml", + ], +} diff --git a/product_uom_factor/models/__init__.py b/product_uom_factor/models/__init__.py new file mode 100644 index 00000000000..efb28161f98 --- /dev/null +++ b/product_uom_factor/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_product +from . import product_template +from . import product_uom_factor +from . import uom_uom diff --git a/product_uom_factor/models/product_product.py b/product_uom_factor/models/product_product.py new file mode 100644 index 00000000000..e5caab5f751 --- /dev/null +++ b/product_uom_factor/models/product_product.py @@ -0,0 +1,26 @@ +from odoo import fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + uom_factor_ids = fields.Many2many( + "product.uom.factor", + string="UoM Conversion Factors", + compute="_compute_uom_factor_ids", + ) + + def _compute_uom_factor_ids(self): + Factor = self.env["product.uom.factor"] + for product in self: + if not product.id or not product.product_tmpl_id.id: + product.uom_factor_ids = Factor + continue + product.uom_factor_ids = Factor.search( + [ + ("product_tmpl_id", "=", product.product_tmpl_id.id), + "|", + ("product_ids", "=", False), + ("product_ids", "in", product.id), + ] + ) diff --git a/product_uom_factor/models/product_template.py b/product_uom_factor/models/product_template.py new file mode 100644 index 00000000000..1b623c161d2 --- /dev/null +++ b/product_uom_factor/models/product_template.py @@ -0,0 +1,67 @@ +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + uom_factor_ids = fields.One2many( + "product.uom.factor", + "product_tmpl_id", + string="UoM Conversion Factors", + ) + + def write(self, vals): + res = super().write(vals) + if "uom_ids" in vals: + self._sync_uom_factor_ids() + return res + + def _sync_uom_factor_ids(self): + Factor = self.env["product.uom.factor"] + for template in self: + base_uom = template.uom_id + cross_uoms = template.uom_ids.filtered( + lambda u, bu=base_uom: not bu._has_common_reference(u) + ) + existing = template.uom_factor_ids + # Create missing factors for cross-category UoMs + existing_uom_ids = existing.mapped("uom_id") + for uom in cross_uoms - existing_uom_ids: + Factor.create( + { + "product_tmpl_id": template.id, + "uom_id": uom.id, + } + ) + # Delete factors whose UoM was removed from uom_ids + to_delete = existing.filtered(lambda f, cu=cross_uoms: f.uom_id not in cu) + to_delete.unlink() + + @api.constrains("uom_factor_ids") + def _check_uom_factor_coverage(self): + """Ensure variant coverage for each cross-category UoM. + Auto-cleans duplicate catch-all lines and auto-creates missing + catch-alls when not all variants are explicitly covered. + Uses uom_ids (the template's allowed UoMs) as the source of truth + for which UoMs need coverage, not just existing factor lines.""" + Factor = self.env["product.uom.factor"] + for template in self: + base_uom = template.uom_id + cross_uoms = template.uom_ids.filtered( + lambda u, bu=base_uom: not bu._has_common_reference(u) + ) + for uom in cross_uoms: + siblings = template.uom_factor_ids.filtered( + lambda f, u=uom: f.uom_id == u + ) + # Auto-create catch-all if needed + catchalls = siblings.filtered(lambda f: not f.product_ids) + if not catchalls: + covered = siblings.mapped("product_ids") + if covered != template.product_variant_ids: + Factor.create( + { + "product_tmpl_id": template.id, + "uom_id": uom.id, + } + ) diff --git a/product_uom_factor/models/product_uom_factor.py b/product_uom_factor/models/product_uom_factor.py new file mode 100644 index 00000000000..ddecd03369c --- /dev/null +++ b/product_uom_factor/models/product_uom_factor.py @@ -0,0 +1,105 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class ProductUomFactor(models.Model): + _name = "product.uom.factor" + _description = "Product-specific UoM Conversion Factor" + + product_tmpl_id = fields.Many2one( + "product.template", + string="Product Template", + required=True, + readonly=True, + index=True, + ondelete="cascade", + ) + product_ids = fields.Many2many( + "product.product", + string="Variants", + help="Specific variants this factor applies to. " + "Leave empty to apply to all variants.", + ) + uom_id = fields.Many2one( + "uom.uom", + "Unit of Measure", + required=True, + readonly=True, + index=True, + ondelete="cascade", + ) + factor = fields.Float( + "Conversion Factor", + default=1.0, + required=True, + help="Product-specific conversion factor for cross-category UoM " + "conversions. Represents how many of the product's base UoM equal " + "one unit of this UoM. For example, for ink with a specific " + "gravity of 1.05 and base UoM of grams, the factor on the mL " + "UoM would be 1.05 (1 mL = 1.05 g).", + ) + + def write(self, vals): + res = super().write(vals) + if "product_ids" in vals: + self.mapped("product_tmpl_id")._check_uom_factor_coverage() + return res + + def unlink(self): + templates = self.mapped("product_tmpl_id") + res = super().unlink() + templates._check_uom_factor_coverage() + return res + + @api.constrains("product_ids", "product_tmpl_id") + def _check_product_ids_same_template(self): + for rec in self: + if rec.product_ids and any( + p.product_tmpl_id != rec.product_tmpl_id for p in rec.product_ids + ): + raise ValidationError( + self.env._("All variants must belong to the same product template.") + ) + + @api.constrains("product_ids", "uom_id", "product_tmpl_id") + def _check_no_duplicate_factors(self): + """Auto-clean duplicate factor lines, then raise if any remain. + A duplicate is defined as two records with the same + (uom_id, product_tmpl_id, frozenset(product_ids)).""" + tmpl_uom_pairs = {(rec.product_tmpl_id.id, rec.uom_id.id) for rec in self} + for tmpl_id, uom_id in tmpl_uom_pairs: + siblings = self.search( + [ + ("product_tmpl_id", "=", tmpl_id), + ("uom_id", "=", uom_id), + ] + ) + keys = [] + to_delete = self.browse() + for sib in siblings: + key = (uom_id, tmpl_id, frozenset(sib.product_ids.ids)) + if key in keys: + to_delete |= sib + else: + keys.append(key) + if to_delete: + to_delete.unlink() + + @api.constrains("factor", "uom_id", "product_tmpl_id") + def _check_same_category_factor(self): + for rec in self: + base_uom = rec.product_tmpl_id.uom_id + if base_uom._has_common_reference(rec.uom_id): + expected = rec.uom_id.factor / base_uom.factor + if rec.factor != expected: + raise ValidationError( + self.env._( + "The conversion factor for %(uom)s cannot be " + "customized because it is in the same category " + "as the product's base UoM (%(base_uom)s). " + "Expected factor: %(expected)s.", + uom=rec.uom_id.name, + base_uom=base_uom.name, + expected=expected, + ) + ) diff --git a/product_uom_factor/models/uom_uom.py b/product_uom_factor/models/uom_uom.py new file mode 100644 index 00000000000..0858129a991 --- /dev/null +++ b/product_uom_factor/models/uom_uom.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from odoo import models, tools + +if TYPE_CHECKING: + from odoo.tools.float_utils import RoundingMethod + + +class UomUom(models.Model): + _inherit = "uom.uom" + + def _get_product_uom_factor(self, product_id, from_uom, to_uom): + """Look up the product-specific cross-category conversion factor. + + Returns the factor to multiply the quantity by when converting from + from_uom to to_uom for the given product, or None if no + product-specific factor applies. + + Strategy: convert from_uom → product base UoM → to_uom. + For each leg, if the UoM is in the same category as the base UoM, + use the standard UoM factor. If cross-category, find the + product.uom.factor record whose uom_id shares a category with + that UoM, and use its + factor combined with the standard intra-category conversion. + """ + product = self.env["product.product"].browse(product_id) + if not product.exists(): + return None + base_uom = product.uom_id + + from_is_cross = not base_uom._has_common_reference(from_uom) + to_is_cross = not base_uom._has_common_reference(to_uom) + + # Leg 1: from_uom → base_uom (in base_uom units) + if from_is_cross: + puom = self._find_product_uom_for_category(product_id, from_uom) + # qty_base = qty * (from_uom.factor / puom.uom_id.factor) * puom.factor + # i.e. convert from_uom to the packaging reference, then apply factor + from_to_base = from_uom.factor / puom.uom_id.factor * puom.factor + else: + # Standard intra-category: from_uom → base_uom + from_to_base = from_uom.factor / base_uom.factor + + # Leg 2: base_uom → to_uom (multiplier to get to_uom units) + if to_is_cross: + puom = self._find_product_uom_for_category(product_id, to_uom) + # base_to_to = (puom.uom_id.factor / to_uom.factor) / puom.factor + base_to_to = puom.uom_id.factor / to_uom.factor / puom.factor + else: + # Standard intra-category: base_uom → to_uom + base_to_to = base_uom.factor / to_uom.factor + + return from_to_base * base_to_to + + def _find_product_uom_for_category(self, product_id, uom): + """Find the product.uom.factor record whose uom_id is in the same + category as the given uom. + + Searches for factors that apply to the given product variant: + either variant-specific (product_ids contains the variant) or + template-wide (product_ids is empty). Variant-specific factors + take precedence. + """ + if not isinstance(product_id, int): + return None + product = self.env["product.product"].browse(product_id) + Factor = self.env["product.uom.factor"] + domain = [ + ("product_tmpl_id", "=", product.product_tmpl_id.id), + "|", + ("product_ids", "=", False), + ("product_ids", "in", product_id), + ] + # First try exact UoM match + exact = Factor.search( + domain + [("uom_id", "=", uom.id)], + limit=1, + ) + if exact: + return exact + # Find any factor in the same category + for f in Factor.search(domain): + if f.uom_id._has_common_reference(uom): + return f + + def _compute_quantity( + self, + qty, + to_unit, + round=True, + rounding_method: RoundingMethod = "UP", + raise_if_failure=True, + ): + if self != to_unit: + self.ensure_one() + product_id = self.env.context.get("product_id") + if product_id and not self._has_common_reference(to_unit): + conversion = self._get_product_uom_factor(product_id, self, to_unit) + if conversion is not None: + amount = qty * conversion + if to_unit and round: + amount = tools.float_round( + amount, + precision_rounding=to_unit.rounding, + rounding_method=rounding_method, + ) + return amount + return super()._compute_quantity( + qty, + to_unit, + round=round, + rounding_method=rounding_method, + raise_if_failure=raise_if_failure, + ) + + def _compute_price(self, price, to_unit): + if self != to_unit: + self.ensure_one() + product_id = self.env.context.get("product_id") + if product_id and not self._has_common_reference(to_unit): + conversion = self._get_product_uom_factor(product_id, self, to_unit) + if conversion is not None: + return price / conversion + return super()._compute_price(price, to_unit) diff --git a/product_uom_factor/pyproject.toml b/product_uom_factor/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/product_uom_factor/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_uom_factor/readme/CONTRIBUTORS.md b/product_uom_factor/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..a98220c928b --- /dev/null +++ b/product_uom_factor/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Marc Durepos , Bemade Inc. \ No newline at end of file diff --git a/product_uom_factor/readme/DESCRIPTION.md b/product_uom_factor/readme/DESCRIPTION.md new file mode 100644 index 00000000000..a5fd2b52bb4 --- /dev/null +++ b/product_uom_factor/readme/DESCRIPTION.md @@ -0,0 +1,21 @@ +Odoo 19.0 allows adding UoMs from any category to a product's "Packagings" +(`uom_ids` field), enabling users to sell or purchase in units from a different +category than the product's base UoM (e.g., selling ink by the liter when it is +stocked by weight in grams). + +However, Odoo's `_compute_quantity()` and `_compute_price()` methods on `uom.uom` +no longer enforce that the source and destination UoMs belong to the same category. +When converting between UoMs of different categories, the conversion silently +produces incorrect results — typically a 1:1 ratio between base units of their +respective categories — because the standard UoM factor is relative to the +category's reference unit, not to the product. + +This module adds a **conversion factor** field to the `product.uom` model (the +per-product/per-UoM link table). This factor represents the product-specific +relationship between the product's base UoM and the cross-category UoM. For +example, an ink product stocked in grams with a density of 1.05 g/mL would have +a factor of 1.05 on its liter packaging UoM, meaning 1 liter = 1050 grams. + +The module overrides `_compute_quantity()` and `_compute_price()` so that when a +product is available in context, cross-category conversions use the product-specific +factor instead of producing potentially incoherent results. diff --git a/product_uom_factor/security/ir.model.access.csv b/product_uom_factor/security/ir.model.access.csv new file mode 100644 index 00000000000..9647cd2b69d --- /dev/null +++ b/product_uom_factor/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_uom_factor_user,product.uom.factor.user,model_product_uom_factor,base.group_user,1,0,0,0 +access_product_uom_factor_manager,product.uom.factor.manager,model_product_uom_factor,product.group_product_manager,1,1,1,1 diff --git a/product_uom_factor/static/description/index.html b/product_uom_factor/static/description/index.html new file mode 100644 index 00000000000..641b461f1fb --- /dev/null +++ b/product_uom_factor/static/description/index.html @@ -0,0 +1,457 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Product UoM Conversion Factor

+ +

Alpha License: LGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

Odoo 19.0 allows adding UoMs from any category to a product’s +“Packagings” (uom_ids field), enabling users to sell or purchase in +units from a different category than the product’s base UoM (e.g., +selling ink by the liter when it is stocked by weight in grams).

+

However, Odoo’s _compute_quantity() and _compute_price() methods +on uom.uom no longer enforce that the source and destination UoMs +belong to the same category. When converting between UoMs of different +categories, the conversion silently produces incorrect results — +typically a 1:1 ratio between base units of their respective categories +— because the standard UoM factor is relative to the category’s +reference unit, not to the product.

+

This module adds a conversion factor field to the product.uom +model (the per-product/per-UoM link table). This factor represents the +product-specific relationship between the product’s base UoM and the +cross-category UoM. For example, an ink product stocked in grams with a +density of 1.05 g/mL would have a factor of 1.05 on its liter packaging +UoM, meaning 1 liter = 1050 grams.

+

The module overrides _compute_quantity() and _compute_price() so +that when a product is available in context, cross-category conversions +use the product-specific factor instead of producing potentially +incoherent results.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

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

+
    +
  • Bemade Inc.
  • +
+
+
+

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 maintainer:

+

mdurepos

+

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_uom_factor/tests/__init__.py b/product_uom_factor/tests/__init__.py new file mode 100644 index 00000000000..733f3aa0a5c --- /dev/null +++ b/product_uom_factor/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_uc1_product_uom_factor +from . import test_uc2_cross_category_conversion +from . import test_uc3_product_uom_factor_view diff --git a/product_uom_factor/tests/test_uc1_product_uom_factor.py b/product_uom_factor/tests/test_uc1_product_uom_factor.py new file mode 100644 index 00000000000..3be16fd6f6f --- /dev/null +++ b/product_uom_factor/tests/test_uc1_product_uom_factor.py @@ -0,0 +1,333 @@ +""" +Test UC1: Product-Specific Cross-Category Conversion Factor + +As a product manager, I want to define a conversion factor on a product's +packaging UoM so that the system can correctly convert between UoMs of +different categories (e.g. volume to weight) for that specific product. + +Acceptance Criteria: +- A factor field exists on product.uom.factor and defaults to 1.0 +- The factor can store a custom value (e.g. 1.05 for specific gravity) +- The factor represents: 1 unit of the packaging UoM = factor units of the + product's base UoM (e.g. 1 mL = 1.05 g for ink with SG 1.05) +- The factor for units within the same category cannot be set to a value + different from the unit's default +- A product.uom.factor record is linked to a product.template via + product_tmpl_id, with an optional product_ids M2M to restrict to + specific variants (empty = applies to all variants) +- A variant can only appear on one product.uom.factor per uom_id +- All product_ids must belong to the same product_tmpl_id +- When a cross-category UoM is added to a product's uom_ids, a + product.uom.factor record is auto-created with factor=1.0 and no + variant discriminator (applies to all variants) +- When a cross-category UoM is removed from uom_ids, the corresponding + product.uom.factor record is deleted +- Adding a same-category UoM to uom_ids does not create a factor record +- When variants are assigned to a factor line but not all variants are + covered, a no-discriminator (catch-all) line is preserved or re-created + so that uncovered variants still have a conversion factor +""" + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestUC1ProductUomFactor(TransactionCase): + """Test UC1: Product-Specific Cross-Category Conversion Factor""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_gram = cls.env.ref("uom.product_uom_gram") + cls.uom_ml = cls.env.ref("uom.product_uom_milliliter") + cls.product = cls.env["product.product"].create( + { + "name": "Test Ink - Cyan", + "uom_id": cls.uom_gram.id, + } + ) + + def test_factor_field_exists_with_default(self): + """The factor field should exist on product.uom.factor and default + to 1.0.""" + product_uom = self.env["product.uom.factor"].create( + { + "product_tmpl_id": self.product.product_tmpl_id.id, + "uom_id": self.uom_ml.id, + } + ) + self.assertEqual( + product_uom.factor, + 1.0, + "The factor field should default to 1.0", + ) + + def test_product_ids_must_belong_to_same_template(self): + """Assigning a variant from a different template should raise.""" + other_product = self.env["product.product"].create( + {"name": "Other Product", "uom_id": self.uom_gram.id} + ) + factor = self.env["product.uom.factor"].create( + { + "product_tmpl_id": self.product.product_tmpl_id.id, + "uom_id": self.uom_ml.id, + } + ) + with self._assertRaises(ValidationError): + factor.product_ids = [(4, other_product.id)] + + def test_same_category_factor_cannot_differ(self): + """A packaging UoM in the same category as the product's base UoM + should not allow a factor different from the standard UoM ratio. + E.g. kg on a product with base UoM g must have factor = 1000.""" + uom_kg = self.env.ref("uom.product_uom_kgm") + with self._assertRaises(Exception): + self.env["product.uom.factor"].create( + { + "product_tmpl_id": self.product.product_tmpl_id.id, + "uom_id": uom_kg.id, + "factor": 500.0, + } + ) + + def test_same_category_factor_with_correct_ratio(self): + """A same-category factor with the correct standard ratio should + be accepted without error.""" + uom_kg = self.env.ref("uom.product_uom_kgm") + factor = self.env["product.uom.factor"].create( + { + "product_tmpl_id": self.product.product_tmpl_id.id, + "uom_id": uom_kg.id, + "factor": uom_kg.factor / self.uom_gram.factor, + } + ) + self.assertTrue(factor) + + def test_auto_create_factor_on_cross_category_uom_add(self): + """Adding a cross-category UoM to uom_ids should auto-create a + product.uom.factor record with factor=1.0 and no discriminator.""" + template = self.product.product_tmpl_id + template.uom_ids = [(4, self.uom_ml.id)] + factor = self.env["product.uom.factor"].search( + [ + ("product_tmpl_id", "=", template.id), + ("uom_id", "=", self.uom_ml.id), + ] + ) + self.assertEqual(len(factor), 1) + self.assertEqual(factor.factor, 1.0) + self.assertFalse(factor.product_ids) + + def test_auto_delete_factor_on_cross_category_uom_remove(self): + """Removing a cross-category UoM from uom_ids should delete the + corresponding product.uom.factor record.""" + template = self.product.product_tmpl_id + template.uom_ids = [(4, self.uom_ml.id)] + factor = self.env["product.uom.factor"].search( + [ + ("product_tmpl_id", "=", template.id), + ("uom_id", "=", self.uom_ml.id), + ] + ) + self.assertEqual(len(factor), 1) + template.uom_ids = [(3, self.uom_ml.id)] + factor = self.env["product.uom.factor"].search( + [ + ("product_tmpl_id", "=", template.id), + ("uom_id", "=", self.uom_ml.id), + ] + ) + self.assertEqual(len(factor), 0) + + def test_same_category_uom_no_factor_created(self): + """Adding a same-category UoM to uom_ids should not create a + product.uom.factor record.""" + template = self.product.product_tmpl_id + uom_kg = self.env.ref("uom.product_uom_kgm") + template.uom_ids = [(4, uom_kg.id)] + factor = self.env["product.uom.factor"].search( + [ + ("product_tmpl_id", "=", template.id), + ("uom_id", "=", uom_kg.id), + ] + ) + self.assertEqual(len(factor), 0) + + def test_catchall_line_when_not_all_variants_covered(self): + """When a user assigns specific variants to a factor line but not + all variants are covered, a no-discriminator catch-all line must + exist so uncovered variants still have a conversion factor.""" + # Create a template with two variants + attr = self.env["product.attribute"].create({"name": "Color"}) + val_red = self.env["product.attribute.value"].create( + {"name": "Red", "attribute_id": attr.id} + ) + val_blue = self.env["product.attribute.value"].create( + {"name": "Blue", "attribute_id": attr.id} + ) + template = self.env["product.template"].create( + { + "name": "Test Ink", + "uom_id": self.uom_gram.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attr.id, + "value_ids": [(6, 0, [val_red.id, val_blue.id])], + }, + ) + ], + } + ) + self.assertEqual(len(template.product_variant_ids), 2) + variant_red = template.product_variant_ids[0] + + # Add cross-category UoM → creates catch-all factor line + template.uom_ids = [(4, self.uom_ml.id)] + factors = self.env["product.uom.factor"].search( + [("product_tmpl_id", "=", template.id), ("uom_id", "=", self.uom_ml.id)] + ) + self.assertEqual(len(factors), 1) + catchall = factors.filtered(lambda f: not f.product_ids) + self.assertEqual(len(catchall), 1) + + # Assign one variant to the existing line → should create a new + # catch-all for the uncovered variant + catchall.product_ids = [(4, variant_red.id)] + factors = self.env["product.uom.factor"].search( + [("product_tmpl_id", "=", template.id), ("uom_id", "=", self.uom_ml.id)] + ) + self.assertEqual(len(factors), 2) + new_catchall = factors.filtered(lambda f: not f.product_ids) + self.assertEqual( + len(new_catchall), + 1, + "A catch-all line should exist for uncovered variants", + ) + + def test_catchall_recreated_on_discriminated_line_delete(self): + """Deleting a discriminated factor line should re-create a catch-all + if not all variants are still covered.""" + attr = self.env["product.attribute"].create({"name": "Size"}) + val_s = self.env["product.attribute.value"].create( + {"name": "S", "attribute_id": attr.id} + ) + val_m = self.env["product.attribute.value"].create( + {"name": "M", "attribute_id": attr.id} + ) + template = self.env["product.template"].create( + { + "name": "Test Ink", + "uom_id": self.uom_gram.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attr.id, + "value_ids": [(6, 0, [val_s.id, val_m.id])], + }, + ) + ], + } + ) + variant_s, variant_m = template.product_variant_ids + Factor = self.env["product.uom.factor"] + + # Add cross-category UoM → auto-creates catch-all + template.uom_ids = [(4, self.uom_ml.id)] + catchall = template.uom_factor_ids.filtered( + lambda f: f.uom_id == self.uom_ml and not f.product_ids + ) + self.assertEqual(len(catchall), 1) + + # Assign each variant to its own line, covering all variants + catchall.product_ids = [(4, variant_s.id)] + catchall.factor = 1.05 + f_s = catchall + f_m = Factor.search( + [ + ("product_tmpl_id", "=", template.id), + ("uom_id", "=", self.uom_ml.id), + ("id", "!=", f_s.id), + ("product_ids", "=", False), + ] + ) + f_m.product_ids = [(4, variant_m.id)] + f_m.factor = 1.10 + + # Delete one → variant_m is no longer covered + f_m.unlink() + factors = Factor.search( + [("product_tmpl_id", "=", template.id), ("uom_id", "=", self.uom_ml.id)] + ) + catchall = factors.filtered(lambda f: not f.product_ids) + self.assertEqual( + len(catchall), + 1, + "A catch-all should be re-created after deleting a discriminated line", + ) + + def test_duplicate_catchall_removed_when_variants_cleared(self): + """Clearing product_ids on a discriminated line makes it a catch-all. + If another catch-all already exists for the same (template, uom), + the duplicate must be removed.""" + attr = self.env["product.attribute"].create({"name": "Finish"}) + val_a = self.env["product.attribute.value"].create( + {"name": "Matte", "attribute_id": attr.id} + ) + val_b = self.env["product.attribute.value"].create( + {"name": "Gloss", "attribute_id": attr.id} + ) + template = self.env["product.template"].create( + { + "name": "Test Ink", + "uom_id": self.uom_gram.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attr.id, + "value_ids": [(6, 0, [val_a.id, val_b.id])], + }, + ) + ], + } + ) + variant_a = template.product_variant_ids[0] + Factor = self.env["product.uom.factor"] + + # Auto-create catch-all via uom_ids + template.uom_ids = [(4, self.uom_ml.id)] + catchall = Factor.search( + [ + ("product_tmpl_id", "=", template.id), + ("uom_id", "=", self.uom_ml.id), + ("product_ids", "=", False), + ] + ) + self.assertEqual(len(catchall), 1) + + # Assign a variant → creates a second catch-all for uncovered + catchall.product_ids = [(4, variant_a.id)] + factors = Factor.search( + [("product_tmpl_id", "=", template.id), ("uom_id", "=", self.uom_ml.id)] + ) + self.assertEqual(len(factors), 2) + + # Now clear the variant → this line becomes a catch-all again + # The other catch-all must be removed to avoid duplicates + discriminated = factors.filtered(lambda f: f.product_ids) + discriminated.product_ids = [(5,)] + factors = Factor.search( + [("product_tmpl_id", "=", template.id), ("uom_id", "=", self.uom_ml.id)] + ) + catchalls = factors.filtered(lambda f: not f.product_ids) + self.assertEqual( + len(catchalls), + 1, + "Only one catch-all line should remain after clearing variants", + ) diff --git a/product_uom_factor/tests/test_uc2_cross_category_conversion.py b/product_uom_factor/tests/test_uc2_cross_category_conversion.py new file mode 100644 index 00000000000..cc4497e8770 --- /dev/null +++ b/product_uom_factor/tests/test_uc2_cross_category_conversion.py @@ -0,0 +1,249 @@ +""" +Test UC2: Cross-Category Quantity and Price Conversion + +As a salesperson or purchaser, I want quantity and price conversions between +UoMs of different categories (e.g. mL to g) to use the product-specific +conversion factor so that when I sell or purchase in a cross-category UoM, +the resulting quantities and prices are correct. + +The cross-category UoM may come from the product's packaging UoMs (uom_ids) +or from a vendor pricelist (product.supplierinfo.product_uom_id). + +Conversion factors are stored in product.uom.factor records linked to the +product template (product_tmpl_id), with an optional product_ids M2M to +restrict to specific variants. + +Acceptance Criteria: +- Converting mL to g uses the product's factor (e.g. 500 mL * 1.05 = 525 g) +- Converting g to mL uses the inverse (e.g. 525 g / 1.05 = 500 mL) +- Different products with different factors produce different results +- Same-category conversions (e.g. g to kg) are unaffected by the factor +- Conversions between non-base units work (e.g. L to kg) +- Price per g converted to price per mL accounts for density + (e.g. $0.10/g * 1.05 g/mL = $0.105/mL) +- Price per mL converted to price per g is the inverse +- Same-category price conversions (e.g. g to kg) are unaffected +- Without a product in context, standard behavior is preserved +""" + +from odoo.tests.common import TransactionCase + + +class TestUC2CrossCategoryConversion(TransactionCase): + """Test UC2: Cross-Category Quantity and Price Conversion""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_gram = cls.env.ref("uom.product_uom_gram") + cls.uom_ml = cls.env.ref("uom.product_uom_milliliter") + + cls.product_cyan = cls.env["product.product"].create( + { + "name": "Ink - Cyan", + "uom_id": cls.uom_gram.id, + } + ) + # Specific gravity 1.05: 1 mL = 1.05 g + cls.env["product.uom.factor"].create( + { + "product_tmpl_id": cls.product_cyan.product_tmpl_id.id, + "uom_id": cls.uom_ml.id, + "factor": 1.05, + } + ) + + def test_ml_to_gram_conversion(self): + """500 mL of cyan ink (SG 1.05) should convert to 525 g.""" + result = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_quantity(500, self.uom_gram, round=False) + self.assertAlmostEqual( + result, + 525.0, + places=2, + msg="500 mL of cyan ink (SG 1.05) should be 525 g", + ) + + def test_gram_to_ml_conversion(self): + """525 g of cyan ink (SG 1.05) should convert to 500 mL.""" + result = self.uom_gram.with_context( + product_id=self.product_cyan.id + )._compute_quantity(525, self.uom_ml, round=False) + self.assertAlmostEqual( + result, + 500.0, + places=2, + msg="525 g of cyan ink (SG 1.05) should be 500 mL", + ) + + def test_different_products_different_factors(self): + """Two products with different densities should produce different + conversion results for the same quantity.""" + product_magenta = self.env["product.product"].create( + { + "name": "Ink - Magenta", + "uom_id": self.uom_gram.id, + } + ) + # Magenta ink: SG 1.10 (denser than cyan's 1.05) + self.env["product.uom.factor"].create( + { + "product_tmpl_id": product_magenta.product_tmpl_id.id, + "uom_id": self.uom_ml.id, + "factor": 1.10, + } + ) + cyan_result = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_quantity(500, self.uom_gram, round=False) + magenta_result = self.uom_ml.with_context( + product_id=product_magenta.id + )._compute_quantity(500, self.uom_gram, round=False) + self.assertAlmostEqual(cyan_result, 525.0, places=2) + self.assertAlmostEqual(magenta_result, 550.0, places=2) + self.assertNotAlmostEqual( + cyan_result, + magenta_result, + places=2, + msg="Different densities should produce different results", + ) + + def test_same_category_conversion_unaffected(self): + """Same-category conversions (g to kg) should use standard UoM + factors and not be affected by the cross-category factor.""" + uom_kg = self.env.ref("uom.product_uom_kgm") + result = self.uom_gram.with_context( + product_id=self.product_cyan.id + )._compute_quantity(1000, uom_kg, round=False) + self.assertAlmostEqual( + result, + 1.0, + places=5, + msg="1000 g should be 1 kg regardless of cross-category factor", + ) + + def test_non_base_unit_conversion(self): + """Conversions between non-base units (L to kg) should work by + chaining the standard UoM factor with the cross-category factor. + 1 L = 1000 mL, factor 1.05 g/mL => 1 L = 1050 g = 1.05 kg.""" + uom_liter = self.env.ref("uom.product_uom_litre") + uom_kg = self.env.ref("uom.product_uom_kgm") + result = uom_liter.with_context( + product_id=self.product_cyan.id + )._compute_quantity(1, uom_kg, round=False) + self.assertAlmostEqual( + result, + 1.05, + places=5, + msg="1 L of cyan ink (SG 1.05) should be 1.05 kg", + ) + + def test_price_gram_to_ml(self): + """Price per g converted to price per mL should account for density. + $0.10/g * 1.05 g/mL = $0.105/mL.""" + result = self.uom_gram.with_context( + product_id=self.product_cyan.id + )._compute_price(0.10, self.uom_ml) + self.assertAlmostEqual( + result, + 0.105, + places=5, + msg="$0.10/g should be $0.105/mL for ink with SG 1.05", + ) + + def test_price_ml_to_gram(self): + """Price per mL converted to price per g is the inverse. + $0.105/mL / 1.05 g/mL = $0.10/g.""" + result = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_price(0.105, self.uom_gram) + self.assertAlmostEqual( + result, + 0.10, + places=5, + msg="$0.105/mL should be $0.10/g for ink with SG 1.05", + ) + + def test_same_category_price_unaffected(self): + """Same-category price conversions (g to kg) should use standard + UoM factors and not be affected by the cross-category factor.""" + uom_kg = self.env.ref("uom.product_uom_kgm") + result = self.uom_gram.with_context( + product_id=self.product_cyan.id + )._compute_price(0.10, uom_kg) + self.assertAlmostEqual( + result, + 100.0, + places=5, + msg="$0.10/g should be $100/kg regardless of cross-category factor", + ) + + def test_no_product_in_context_standard_behavior(self): + """Without a product_id in context, cross-category conversions + should fall back to standard Odoo behavior (1:1 between base units).""" + result_with = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_quantity(500, self.uom_gram, round=False) + result_without = self.uom_ml._compute_quantity(500, self.uom_gram, round=False) + self.assertAlmostEqual(result_with, 525.0, places=2) + self.assertAlmostEqual( + result_without, + 500.0, + places=2, + msg="Without product in context, standard 1:1 base-unit conversion", + ) + + def test_compute_quantity_with_rounding(self): + """Cross-category conversion with round=True should round result.""" + result = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_quantity(500, self.uom_gram, round=True) + self.assertAlmostEqual(result, 525.0, places=0) + + def test_compute_quantity_nonexistent_product(self): + """A nonexistent product_id in context should fall back to super.""" + result = self.uom_ml.with_context(product_id=99999)._compute_quantity( + 500, self.uom_gram, round=False + ) + self.assertAlmostEqual(result, 500.0, places=2) + + def test_find_product_uom_for_category_newid(self): + """_find_product_uom_for_category returns None for non-int IDs.""" + from odoo.orm.identifiers import NewId + + result = self.uom_ml._find_product_uom_for_category(NewId(), self.uom_gram) + self.assertIsNone(result) + + def test_find_product_uom_for_category_fallback(self): + """When the exact UoM isn't on a factor line but a same-category + UoM is, _find_product_uom_for_category should return it.""" + uom_litre = self.env.ref("uom.product_uom_litre") + # Factor is defined for mL; searching for L should find it via + # the category fallback path. + result = self.uom_gram._find_product_uom_for_category( + self.product_cyan.id, uom_litre + ) + self.assertTrue(result) + self.assertTrue(result.uom_id._has_common_reference(uom_litre)) + + def test_compute_quantity_same_uom(self): + """Converting a UoM to itself should return the original qty.""" + result = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_quantity(500, self.uom_ml, round=False) + self.assertAlmostEqual(result, 500.0, places=2) + + def test_compute_price_same_uom(self): + """Converting a price to the same UoM should return the original.""" + result = self.uom_ml.with_context( + product_id=self.product_cyan.id + )._compute_price(10.0, self.uom_ml) + self.assertAlmostEqual(result, 10.0, places=2) + + def test_compute_price_nonexistent_product(self): + """A nonexistent product_id in context should fall back to super.""" + result = self.uom_ml.with_context(product_id=99999)._compute_price( + 10.0, self.uom_gram + ) + self.assertAlmostEqual(result, 10.0, places=2) diff --git a/product_uom_factor/tests/test_uc3_product_uom_factor_view.py b/product_uom_factor/tests/test_uc3_product_uom_factor_view.py new file mode 100644 index 00000000000..08bfea93f59 --- /dev/null +++ b/product_uom_factor/tests/test_uc3_product_uom_factor_view.py @@ -0,0 +1,126 @@ +""" +Test UC3: Cross-Category UoM Conversion Factors on Product Form + +As a product manager, when I add a UoM from a different category to my +product's allowed UoMs, I want a "Unit Conversions" section to appear on +the product form showing which cross-category UoMs need conversion +factors, so that I can configure them without navigating away. + +Conversion factors are stored in product.uom.factor records linked to the +product template (product_tmpl_id), with an optional product_ids M2M to +restrict to specific variants. + +Acceptance Criteria: +- The product template form has a "Unit Conversions" section (under the + Inventory tab) showing the uom_factor_ids one2many with uom_id (readonly), + product_ids, and factor columns +- Factor lines are auto-created when cross-category UoMs are added to + uom_ids; the factor field is editable inline +- The uom_id column is readonly since lines are auto-managed +""" + +from odoo.tests import Form +from odoo.tests.common import TransactionCase + + +class TestUC3ProductUomFactorView(TransactionCase): + """Test UC3: Cross-Category UoM Conversion Factors on Product Form""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.config.settings"].create({"group_uom": True}).execute() + cls.uom_gram = cls.env.ref("uom.product_uom_gram") + cls.uom_ml = cls.env.ref("uom.product_uom_milliliter") + + def test_factor_editable_on_template_form(self): + """When a cross-category UoM is added to uom_ids, the auto-created + factor line appears on the template form and its factor can be + edited inline.""" + template = self.env["product.template"].create( + { + "name": "Test Ink", + "uom_id": self.uom_gram.id, + } + ) + # Add cross-category UoM → auto-creates factor line + template.uom_ids = [(4, self.uom_ml.id)] + self.assertEqual(len(template.uom_factor_ids), 1) + + # Open form and edit the factor value + form = Form(template, view="product.product_template_only_form_view") + with form.uom_factor_ids.edit(0) as line: + line.factor = 1.05 + template = form.save() + puom = template.uom_factor_ids.filtered(lambda r: r.uom_id == self.uom_ml) + self.assertEqual(len(puom), 1) + self.assertAlmostEqual( + puom.factor, + 1.05, + places=5, + msg="Factor should be editable via the product template form", + ) + + def test_variant_uom_factor_ids_shows_applicable_factors(self): + """The computed uom_factor_ids on product.product should return + only factors that apply to this variant: catch-all lines (no + product_ids) and lines where this variant is in product_ids.""" + attr = self.env["product.attribute"].create({"name": "Color"}) + val_red = self.env["product.attribute.value"].create( + {"name": "Red", "attribute_id": attr.id} + ) + val_blue = self.env["product.attribute.value"].create( + {"name": "Blue", "attribute_id": attr.id} + ) + template = self.env["product.template"].create( + { + "name": "Test Ink", + "uom_id": self.uom_gram.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attr.id, + "value_ids": [(6, 0, [val_red.id, val_blue.id])], + }, + ) + ], + } + ) + variant_red, variant_blue = template.product_variant_ids + + # Add cross-category UoM → catch-all factor + template.uom_ids = [(4, self.uom_ml.id)] + catchall = template.uom_factor_ids + self.assertEqual(len(catchall), 1) + + # Both variants see the catch-all + self.assertEqual(variant_red.uom_factor_ids, catchall) + self.assertEqual(variant_blue.uom_factor_ids, catchall) + + # Discriminate: assign red variant → catch-all re-created for blue + catchall.product_ids = [(4, variant_red.id)] + catchall.factor = 1.10 + template.invalidate_recordset(["uom_factor_ids"]) + factors = template.uom_factor_ids + red_line = factors.filtered(lambda f: variant_red in f.product_ids) + new_catchall = factors.filtered(lambda f: not f.product_ids) + + # Red sees its discriminated line + catch-all + variant_red.invalidate_recordset(["uom_factor_ids"]) + variant_blue.invalidate_recordset(["uom_factor_ids"]) + self.assertEqual(variant_red.uom_factor_ids, red_line | new_catchall) + # Blue sees only the catch-all + self.assertEqual(variant_blue.uom_factor_ids, new_catchall) + + # Verify via variant form view + form = Form(variant_red, view="product.product_normal_form_view") + self.assertEqual(len(form.uom_factor_ids), 2) + form = Form(variant_blue, view="product.product_normal_form_view") + self.assertEqual(len(form.uom_factor_ids), 1) + + def test_new_product_uom_factor_ids_empty(self): + """A new (unsaved) product should return empty uom_factor_ids.""" + new_product = self.env["product.product"].new({"name": "Unsaved"}) + self.assertFalse(new_product.uom_factor_ids) diff --git a/product_uom_factor/views/product_product_views.xml b/product_uom_factor/views/product_product_views.xml new file mode 100644 index 00000000000..8ed985cce77 --- /dev/null +++ b/product_uom_factor/views/product_product_views.xml @@ -0,0 +1,22 @@ + + + + + product.product.form.uom.factor + product.product + + + + + + + + + + + + + + + + diff --git a/product_uom_factor/views/product_template_views.xml b/product_uom_factor/views/product_template_views.xml new file mode 100644 index 00000000000..7f0441341c9 --- /dev/null +++ b/product_uom_factor/views/product_template_views.xml @@ -0,0 +1,28 @@ + + + + + product.template.form.uom.factor + product.template + + + + + + + + + + + + + + + + +