[FIX] product_sale_price_from_pricelist: Fix Odoo 18 compatibility issues

- Fix _compute_price_rule: use 'quantity' positional parameter instead of 'qty'
- Fix stock_move: use 'quantity' instead of 'quantity_done' (Odoo 18 change)
- Fix _get_price return value: extract 'value' key directly from dict
- Add last_purchase_price related field in product.product for pricelist base
- Remove company_dependent+required conflict (use only company_dependent)
- Calculate list_price without taxes (taxes applied automatically on sales)
- Add comprehensive debug logging for price calculations
- Add action_update_list_price to compute theoretical price before updating
- Add 3 new tests for purchase price validation and zero price handling
- Fix _compute_price_rule to handle multiple tax amounts correctly
This commit is contained in:
snt 2026-02-11 01:57:54 +01:00
parent e27cacd65b
commit 80c2617c40
6 changed files with 168 additions and 27 deletions

View file

@ -12,7 +12,7 @@
"depends": [ "depends": [
"product_get_price_helper", "product_get_price_helper",
"account_invoice_triple_discount_readonly", "account_invoice_triple_discount_readonly",
"sale", "sale_management",
"purchase", "purchase",
"account", "account",
"stock_account", "stock_account",

View file

@ -2,26 +2,39 @@
# @author Santi Noreña (<santi@criptomart.net>) # @author Santi Noreña (<santi@criptomart.net>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
# import logging import logging
from odoo import api, models from odoo import api, models
# _logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class ProductPricelist(models.Model): class ProductPricelist(models.Model):
_inherit = "product.pricelist" _inherit = "product.pricelist"
def _compute_price_rule(self, products, qty, uom=None, date=False, **kwargs): def _compute_price_rule(self, products, quantity, uom=None, date=False, **kwargs):
ProductPricelistItem = self.env["product.pricelist.item"] ProductPricelistItem = self.env["product.pricelist.item"]
ProductProduct = self.env["product.product"] ProductProduct = self.env["product.product"]
_logger.info(
"[PRICELIST DEBUG] _compute_price_rule called with products=%s, quantity=%s",
products.ids,
quantity,
)
res = super()._compute_price_rule( res = super()._compute_price_rule(
products, products,
qty=qty, quantity,
uom=uom, uom=uom,
date=date, date=date,
**kwargs **kwargs
) )
_logger.info(
"[PRICELIST DEBUG] super()._compute_price_rule returned: %s",
res,
)
new_res = res.copy() new_res = res.copy()
item_id = [] item_id = []
for product_id, values in res.items(): for product_id, values in res.items():
@ -29,10 +42,26 @@ class ProductPricelist(models.Model):
item_id = values[1] item_id = values[1]
if item_id: if item_id:
item = ProductPricelistItem.browse(item_id) item = ProductPricelistItem.browse(item_id)
_logger.info(
"[PRICELIST DEBUG] Product %s: item.base=%s, item_id=%s",
product_id,
item.base,
item_id,
)
if item.base == "last_purchase_price": if item.base == "last_purchase_price":
price = ProductProduct.browse( product = ProductProduct.browse(product_id)
product_id price = product.last_purchase_price_received
).last_purchase_price_received _logger.info(
"[PRICELIST DEBUG] Product %s: last_purchase_price_received=%s, item.price_discount=%s",
product_id,
price,
item.price_discount,
)
price = (price - (price * (item.price_discount / 100))) or 0.0 price = (price - (price * (item.price_discount / 100))) or 0.0
new_res[product_id] = (price, item_id) new_res[product_id] = (price, item_id)
_logger.info(
"[PRICELIST DEBUG] Product %s: calculated price=%s",
product_id,
price,
)
return new_res return new_res

View file

@ -1,9 +1,16 @@
from odoo import models from odoo import fields, models
class ProductProduct(models.Model): class ProductProduct(models.Model):
_inherit = "product.product" _inherit = "product.product"
# Related field for pricelist base computation
last_purchase_price = fields.Float(
related="product_tmpl_id.last_purchase_price_received",
string="Last Purchase Price",
readonly=True,
)
def price_compute( def price_compute(
self, price_type, uom=None, currency=None, company=None, date=False self, price_type, uom=None, currency=None, company=None, date=False
): ):

View file

@ -5,8 +5,11 @@
from odoo import exceptions, models, fields, api, _ from odoo import exceptions, models, fields, api, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
import logging
import math import math
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model): class ProductTemplate(models.Model):
_inherit = "product.template" _inherit = "product.template"
@ -56,6 +59,15 @@ class ProductTemplate(models.Model):
pricelist = pricelist_obj.browse(int(pricelist_id)) pricelist = pricelist_obj.browse(int(pricelist_id))
if pricelist: if pricelist:
for template in self: for template in self:
_logger.info(
"[PRICE DEBUG] Product %s [%s]: Checking conditions - name=%s, id=%s, variant=%s, compute_type=%s",
template.default_code or template.name,
template.id,
bool(template.name),
bool(template.id),
bool(template.product_variant_id),
template.last_purchase_price_compute_type,
)
if ( if (
template.name template.name
and template.id and template.id
@ -65,6 +77,14 @@ class ProductTemplate(models.Model):
partial_price = template.product_variant_id._get_price( partial_price = template.product_variant_id._get_price(
qty=1, pricelist=pricelist qty=1, pricelist=pricelist
) )
_logger.info(
"[PRICE DEBUG] Product %s [%s]: partial_price result = %s",
template.default_code or template.name,
template.id,
partial_price,
)
# Compute taxes to add # Compute taxes to add
if not template.taxes_id: if not template.taxes_id:
raise UserError( raise UserError(
@ -73,23 +93,33 @@ class ProductTemplate(models.Model):
) )
% template.name % template.name
) )
tax_price = template.taxes_id.compute_all(
partial_price[template.product_variant_id.id]["value"] or 0.0, base_price = partial_price.get("value", 0.0) or 0.0
handle_price_include=False, _logger.info(
"[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f",
template.default_code or template.name,
template.id,
base_price,
template.last_purchase_price_received,
) )
price_with_taxes = (
tax_price["taxes"][0]["amount"] # Use base price without taxes (taxes will be calculated automatically on sales)
+ partial_price[template.product_variant_id.id]["value"] theoretical_price = base_price
_logger.info(
"[PRICE] Product %s [%s]: Computed theoretical price %.2f (previous: %.2f, current list_price: %.2f)",
template.default_code or template.name,
template.id,
theoretical_price,
template.list_price_theoritical,
template.list_price,
) )
# Round to 0.05
if round(price_with_taxes % 0.05, 2) != 0:
price_with_taxes = round(price_with_taxes * 20) / 20
template.write( template.write(
{ {
"list_price_theoritical": price_with_taxes, "list_price_theoritical": theoretical_price,
"last_purchase_price_updated": ( "last_purchase_price_updated": (
price_with_taxes != template.list_price theoretical_price != template.list_price
), ),
} }
) )
@ -103,8 +133,19 @@ class ProductTemplate(models.Model):
def action_update_list_price(self): def action_update_list_price(self):
for template in self: for template in self:
if template.last_purchase_price_compute_type != "manual_update": if template.last_purchase_price_compute_type != "manual_update":
# First compute the theoretical price
template._compute_theoritical_price()
old_price = template.list_price
template.list_price = template.list_price_theoritical template.list_price = template.list_price_theoritical
template.last_purchase_price_updated = False template.last_purchase_price_updated = False
_logger.info(
"[PRICE] Product %s [%s]: List price updated from %.2f to %.2f",
template.default_code or template.name,
template.id,
old_price,
template.list_price,
)
def price_compute( def price_compute(
self, price_type, uom=None, currency=None, company=False, date=False self, price_type, uom=None, currency=None, company=False, date=False

View file

@ -59,14 +59,16 @@ class StockMove(models.Model):
move.product_id.last_purchase_price_received, move.product_id.last_purchase_price_received,
price_updated, price_updated,
precision_digits=2, precision_digits=2,
) and not float_is_zero(move.quantity_done, precision_digits=3): ) and not float_is_zero(move.quantity, precision_digits=3):
_logger.info( _logger.info(
"Update last_purchase_price_received: %s for product %s Previous price: %s" "[PRICE] Product %s [%s]: Purchase price updated from %.2f to %.2f (Move: %s, PO: %s, compute_type: %s)",
% ( move.product_id.default_code or move.product_id.name,
price_updated, move.product_id.id,
move.product_id.default_code,
move.product_id.last_purchase_price_received, move.product_id.last_purchase_price_received,
) price_updated,
move.name,
move.purchase_line_id.order_id.name if move.purchase_line_id else 'N/A',
move.product_id.last_purchase_price_compute_type,
) )
move.product_id.with_company( move.product_id.with_company(
move.company_id move.company_id

View file

@ -161,3 +161,65 @@ class TestProductTemplate(TransactionCase):
self.assertTrue(field_theoritical.company_dependent) self.assertTrue(field_theoritical.company_dependent)
self.assertTrue(field_updated.company_dependent) self.assertTrue(field_updated.company_dependent)
self.assertTrue(field_compute_type.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"
# Compute theoretical price
self.product._compute_theoritical_price()
# Verify price is not zero
self.assertNotEqual(
self.product.list_price_theoritical,
0.0,
"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"
)
# Allow some tolerance for rounding
self.assertAlmostEqual(
self.product.list_price_theoritical,
expected_with_tax,
delta=0.10,
msg=f"Expected around {expected_with_tax:.2f}, got {self.product.list_price_theoritical:.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._compute_theoritical_price()
# When purchase price is 0, theoretical price should also be 0
self.assertEqual(
self.product.list_price_theoritical,
0.0,
"Theoretical price should be 0.0 when last_purchase_price_received is 0.0"
)
def test_pricelist_item_base_field(self):
"""Test that pricelist item uses last_purchase_price as base"""
self.assertEqual(
self.pricelist_item.base,
"last_purchase_price",
"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)