Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions product_uom_factor/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://odoo-community.org/page/development-status>`_

**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/product-attribute/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 <https://github.com/OCA/product-attribute/issues/new?body=module:%20product_uom_factor%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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 <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-mdurepos|

This module is part of the `OCA/product-attribute <https://github.com/OCA/product-attribute/tree/19.0/product_uom_factor>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions product_uom_factor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
21 changes: 21 additions & 0 deletions product_uom_factor/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
],
}
4 changes: 4 additions & 0 deletions product_uom_factor/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from . import product_product
from . import product_template
from . import product_uom_factor
from . import uom_uom
26 changes: 26 additions & 0 deletions product_uom_factor/models/product_product.py
Original file line number Diff line number Diff line change
@@ -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),
]
)
67 changes: 67 additions & 0 deletions product_uom_factor/models/product_template.py
Original file line number Diff line number Diff line change
@@ -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,
}
)
105 changes: 105 additions & 0 deletions product_uom_factor/models/product_uom_factor.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
Loading