Skip to content
Merged
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
53 changes: 42 additions & 11 deletions purchase_order_secondary_unit/README.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

=============================
Purchase Order Secondary Unit
=============================
Expand All @@ -17,7 +13,7 @@ Purchase Order Secondary Unit
.. |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/license-AGPL--3-blue.png
.. |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%2Fpurchase--workflow-lightgray.png?logo=github
Expand All @@ -35,11 +31,25 @@ Purchase Order Secondary Unit
This module extends the functionality of purchase orders to allow buy
products in secondary unit of distinct category.

Users can enter quantities and prices in secondary units on purchase
order lines. Vendor pricelist records are also extended to support
secondary unit pricing.

Purchase reports and the Purchase Order portal are adjusted to display
quantities and prices in secondary units based on company configuration.

**Table of contents**

.. contents::
:local:

Configuration
=============

For configuration of displaying secondary unit information in purchase
reports and the Purchase Order portal, see the guidelines provided in
product_secondary_unit.

Usage
=====

Expand All @@ -52,6 +62,24 @@ To use this module you need to:
5. Change secondary qty and secondary uom in line, and quantity
(product_qty) will be changed (according to the conversion factor).

**Vendor Pricelist Integration**

- When adding a vendor to a product's pricelist (via *Purchase tab >
Vendors*), the secondary unit of measure is automatically defaulted
from the product variant's purchase secondary UOM, or from the
product template if not set on the variant.
- When a new vendor pricelist record is created from purchase order
confirmation, the secondary UOM from the purchase order line is
automatically stored in the vendor pricelist entry.

Known issues / Roadmap
======================

Updating existing vendor pricelist records from purchase order
confirmation does not currently support secondary UOM or secondary UOM
pricing. This is not included in the current scope and may be considered
in future improvements.

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

Expand All @@ -73,14 +101,17 @@ Authors
Contributors
------------

- `Tecnativa <https://www.tecnativa.com>`__:
- `Tecnativa <https://www.tecnativa.com>`__:

- Sergio Teruel
- Ernesto Tejeda

- Sergio Teruel
- Ernesto Tejeda
- Nikul Chaudhary <nikulchaudhary2112@gmail.com>
- Pimolnat Suntian <pimolnats@ecosoft.co.th>
- Miguel Ángel Gómez <miguel.gomez@braintec.com>
- `Quartile <https://www.quartile.co>`__:

- Nikul Chaudhary <nikulchaudhary2112@gmail.com>
- Pimolnat Suntian <pimolnats@ecosoft.co.th>
- Miguel Ángel Gómez <miguel.gomez@braintec.com>
- Yoshi Tashiro

Maintainers
-----------
Expand Down
4 changes: 3 additions & 1 deletion purchase_order_secondary_unit/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"name": "Purchase Order Secondary Unit",
"summary": "Purchase product in a secondary unit",
"version": "18.0.1.0.1",
"version": "18.0.1.1.0",
"development_status": "Beta",
"category": "Purchase",
"website": "https://github.com/OCA/purchase-workflow",
Expand All @@ -15,6 +15,8 @@
"depends": ["purchase", "product_secondary_unit"],
"data": [
"views/product_views.xml",
"views/product_supplierinfo_views.xml",
"views/purchase_order_portal_templates.xml",
"views/purchase_order_views.xml",
"reports/purchase_order_templates.xml",
"reports/purchase_quotation_templates.xml",
Expand Down
17 changes: 17 additions & 0 deletions purchase_order_secondary_unit/migrations/18.0.1.1.0/pre-migrate.py
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bump should be "minor". i.e. 18.0.1.0.1 -> 18.0.1.1.0

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo.tools.sql import column_exists


def migrate(cr, version):
if not column_exists(cr, "purchase_order_line", "secondary_uom_price"):
cr.execute("""
ALTER TABLE purchase_order_line
ADD COLUMN secondary_uom_price double precision;

UPDATE purchase_order_line pol
SET secondary_uom_price = pol.price_unit * su.factor
FROM product_secondary_unit su
WHERE su.id = pol.secondary_uom_id;
""")
1 change: 1 addition & 0 deletions purchase_order_secondary_unit/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import product_product
from . import product_supplierinfo
from . import product_template
from . import purchase_order
42 changes: 42 additions & 0 deletions purchase_order_secondary_unit/models/product_supplierinfo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import api, fields, models


class ProductSupplierinfo(models.Model):
_inherit = "product.supplierinfo"

secondary_uom_id = fields.Many2one(
comodel_name="product.secondary.unit",
string="Secondary Unit",
domain="[('product_tmpl_id', '=', product_tmpl_id)]",
)
secondary_uom_price = fields.Float(
string="Secondary Price",
digits="Product Price",
compute="_compute_secondary_uom_price",
inverse="_inverse_secondary_uom_price",
store=True,
)

@api.depends("price", "secondary_uom_id", "secondary_uom_id.factor")
def _compute_secondary_uom_price(self):
for rec in self:
if rec.secondary_uom_id:
rec.secondary_uom_price = rec.price * rec.secondary_uom_id.factor
else:
rec.secondary_uom_price = 0.0

@api.onchange("secondary_uom_price")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@api.onchange("secondary_uom_price")

I think we don't have to use @api.onchane for _inverse function.

Copy link
Contributor

@nobuQuartile nobuQuartile Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the onchange myself and tested the behavior, and found that the value isn’t updated dynamically until the record is saved.
With onchange in place, it seems the value updates dynamically as soon as it’s written, even before saving.
So, we should have api.onchane for this _inverse function.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you can directly update the price in _onchange_product_id_secondary_uom and skip adding the onchange in the inverse.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a new onchange method that replicates the logic of this inverse (I guess _onchange_product_id_secondary_uom is not the one to work on) if we should be strict about not using onchange with an inverse.

I see a lot of these patterns, though, in the standard codebase, so I thought it was a widely accepted practice.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reusing the onchange_product_id_secondary_uom? You update secondary_uom_id and price which is related with it in a single transaction

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could make it work but I don't feel there is a good reason to do so as the intents are different.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, not blocking the decision, let me know when we can move it forward, appreciate that the module has been functional tested deeply :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! You can go ahead and merge this if you are okay with the current code, or should we add new onchange methods to remove the onchange from inverses?

def _inverse_secondary_uom_price(self):
for rec in self:
if rec.secondary_uom_id:
rec.price = rec.secondary_uom_price / rec.secondary_uom_id.factor

@api.onchange("product_tmpl_id", "product_id")
def _onchange_product_id_secondary_uom(self):
self.secondary_uom_id = (
self.product_id.purchase_secondary_uom_id
or self.product_tmpl_id.purchase_secondary_uom_id
)
40 changes: 40 additions & 0 deletions purchase_order_secondary_unit/models/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
from odoo import api, fields, models


class PurchaseOrder(models.Model):
_inherit = "purchase.order"

def _prepare_supplier_info(self, partner, line, price, currency):
vals = super()._prepare_supplier_info(partner, line, price, currency)
vals["secondary_uom_id"] = line.secondary_uom_id.id
return vals


class PurchaseOrderLine(models.Model):
_inherit = ["purchase.order.line", "product.secondary.unit.mixin"]
_name = "purchase.order.line"
Expand All @@ -25,12 +34,34 @@ class PurchaseOrderLine(models.Model):
product_packaging_id = fields.Many2one(
compute="_compute_product_packaging_id", store=True, precompute=True
)
secondary_uom_price = fields.Float(
string="Secondary Price",
digits="Product Price",
aggregator="avg",
compute="_compute_secondary_uom_price",
inverse="_inverse_secondary_uom_price",
store=True,
)
Comment on lines +37 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My next concern is that this compute store can slowdown the update of the module for big databases.

Maybe we need a migration script where we create the column and fill it manually with a sql query.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the Héctor's concern is legit. About being computed or use onchanges, I usually prefer computed fields for not depending on UI, but sometimes the things get messy due to dependencies, and a simple onchange resolves the usability question.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @pedrobaeza , I think Héctor is concerned about adding @api.onchange to inverse methods. Do you have any thoughts on this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I meant about making it computed stored. About reusing inverse methods as onchange may be legit for reflecting things in UI, but it depends on the logic of inverse method.


@api.depends("secondary_uom_qty", "secondary_uom_id")
def _compute_product_qty(self):
self._compute_helper_target_field_qty()
return super()._compute_product_qty()

@api.depends("price_unit", "secondary_uom_id", "secondary_uom_id.factor")
def _compute_secondary_uom_price(self):
for rec in self:
if rec.secondary_uom_id:
rec.secondary_uom_price = rec.price_unit * rec.secondary_uom_id.factor
else:
rec.secondary_uom_price = 0.0

@api.onchange("secondary_uom_price")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@api.onchange("secondary_uom_price")

I think we don't have to use @api.onchane for _inverse function.

Copy link
Contributor

@nobuQuartile nobuQuartile Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the onchange myself and tested the behavior, and found that the value isn’t updated dynamically until the record is saved.
With onchange in place, it seems the value updates dynamically as soon as it’s written, even before saving.
So, we should have api.onchane for this _inverse function.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

def _inverse_secondary_uom_price(self):
for rec in self:
if rec.secondary_uom_id:
rec.price_unit = rec.secondary_uom_price / rec.secondary_uom_id.factor

@api.onchange("product_uom")
def onchange_product_uom_for_secondary(self):
self._onchange_helper_product_uom_for_secondary()
Expand All @@ -54,3 +85,12 @@ def onchange_product_id(self):
if self.secondary_uom_id:
self.secondary_uom_qty = 1.0
return res

def _prepare_account_move_line(self, move=False):
# Set secondary UoM values only when account_move_secondary_unit is installed
# (i.e., the fields exist on account.move.line).
res = super()._prepare_account_move_line(move)
aml_fields = self.env["account.move.line"]._fields
if "secondary_uom_id" in aml_fields and self.secondary_uom_id:
res["secondary_uom_id"] = self.secondary_uom_id.id
return res
2 changes: 2 additions & 0 deletions purchase_order_secondary_unit/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
For configuration of displaying secondary unit information in purchase reports and
the Purchase Order portal, see the guidelines provided in product_secondary_unit.
2 changes: 2 additions & 0 deletions purchase_order_secondary_unit/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
- Nikul Chaudhary \<<nikulchaudhary2112@gmail.com>\>
- Pimolnat Suntian \<<pimolnats@ecosoft.co.th>\>
- Miguel Ángel Gómez \<<miguel.gomez@braintec.com>\>
- [Quartile](https://www.quartile.co):
- Yoshi Tashiro
6 changes: 6 additions & 0 deletions purchase_order_secondary_unit/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
This module extends the functionality of purchase orders to allow buy
products in secondary unit of distinct category.

Users can enter quantities and prices in secondary units on purchase order lines. Vendor
pricelist records are also extended to support secondary unit pricing.

Purchase reports and the Purchase Order portal are adjusted to display quantities and prices
in secondary units based on company configuration.
3 changes: 3 additions & 0 deletions purchase_order_secondary_unit/readme/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Updating existing vendor pricelist records from purchase order confirmation does not
currently support secondary UOM or secondary UOM pricing. This is not included in the
current scope and may be considered in future improvements.
9 changes: 9 additions & 0 deletions purchase_order_secondary_unit/readme/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@ To use this module you need to:
4. Go to *Purchase \> Quotation \> Create*.
5. Change secondary qty and secondary uom in line, and quantity
(product_qty) will be changed (according to the conversion factor).

**Vendor Pricelist Integration**

- When adding a vendor to a product's pricelist (via *Purchase tab > Vendors*), the
secondary unit of measure is automatically defaulted from the product variant's
purchase secondary UOM, or from the product template if not set on the variant.
- When a new vendor pricelist record is created from purchase order confirmation, the
secondary UOM from the purchase order line is automatically stored in the vendor
pricelist entry.
52 changes: 50 additions & 2 deletions purchase_order_secondary_unit/reports/purchase_order_templates.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,64 @@
>
<!-- Header data -->
<th name="th_quantity" position="before">
<th name="th_secondary_unit" class="text-end">
<th
name="th_secondary_unit"
class="text-end"
t-if="not o.company_id.hide_secondary_uom_column(o)"
>
<strong>Second Qty</strong>
</th>
</th>
<!-- Content data -->
<xpath expr="//span[@t-field='line.product_qty']/.." position="before">
<td id="secondary_unit" class="text-end">
<td
id="secondary_unit"
class="text-end"
t-if="not o.company_id.hide_secondary_uom_column(o)"
>
<span t-field="line.secondary_uom_qty" />
<span t-field="line.secondary_uom_id" />
</td>
</xpath>
<!-- Quantity: hide primary when secondary is prioritized -->
<xpath expr="//span[@t-field='line.product_qty']" position="attributes">
<attribute
name="t-if"
>line.get_secondary_uom_display_mode() != 'secondary'</attribute>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yostashiro Where is this function? We updated and getting errors as not existing, can't find anywhere.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR depends on OCA/product-attribute#2182, and the function is introduced in that PR , as I mentioned in #2932 (comment) . Unfortunately, this PR was merged before the dependency PR was merged. @yostashiro also explained the situation in the comment above: #2932 (comment).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, we have pinned to old version anyway after review as we really don't want these changes. In my opinion this should have been a seperate extension module, not because of the breakage, but it is for a feature that not everyone will want, its not really fixing an obvious oversight or error in functionality. In my experience this module is mostly used for straight conversions of qty's and adding price features just makes harder.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gdgellatly That's a fair point and we did consider a split. The tradeoff is four additional modules across the core apps (xxx_secondary_unit_price for product, purchase, sale, and account), and we thought the maintenance overhead would outweigh the benefit. Our clients who need the secondary unit conversion also tend to need pricing per secondary unit, and all the added features here are effectively opt-in. That said, we are happy to split the module if the PSC opts for it. You can also leave a comment on OCA/product-attribute#2182 as it gets harder to change direction after merge.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yostashiro Honestly it is no big deal, we have pinned now and that will get us through the v18 lifecycle which is only a few more months. For us a secondary unit is just a conversion to a primary unit, so price_subtotal is always identical and secondary_price_unit if we had would always just be price_subtotal/secondary_qty

And then look again during migration to decide what to do.

</xpath>
<xpath expr="//span[@t-field='line.product_uom.name']" position="attributes">
<attribute
name="t-if"
>line.get_secondary_uom_display_mode() != 'secondary'</attribute>
</xpath>
<!-- Quantity: add secondary qty ('secondary' mode) -->
<xpath expr="//span[@t-field='line.product_uom.name']" position="after">
<t t-if="line.get_secondary_uom_display_mode() == 'secondary'">
<span t-field="line.secondary_uom_qty" />
<span t-field="line.secondary_uom_id.name" />
</t>
</xpath>
<!-- Quantity: add secondary qty ('both' mode) -->
<xpath expr="//span[@t-field='line.product_qty']/.." position="inside">
<t t-if="line.get_secondary_uom_display_mode() == 'both'">
<br />
<span class="text-muted">
(<span t-field="line.secondary_uom_qty" />
<span
t-field="line.secondary_uom_id.name"
groups="uom.group_uom"
/>)
</span>
</t>
</xpath>
<!-- Unit price: hide primary ('secondary' mode) -->
<xpath expr="//span[@t-field='line.price_unit']" position="attributes">
<attribute
name="t-if"
>line.get_secondary_uom_display_mode() != 'secondary'</attribute>
</xpath>
<xpath expr="//span[@t-field='line.price_unit']" position="after">
<t t-call="purchase_order_secondary_unit.purchase_uom_price" />
</xpath>
</template>
</odoo>
Loading