[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:
parent
e27cacd65b
commit
80c2617c40
6 changed files with 168 additions and 27 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue