[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": [
|
||||
"product_get_price_helper",
|
||||
"account_invoice_triple_discount_readonly",
|
||||
"sale",
|
||||
"sale_management",
|
||||
"purchase",
|
||||
"account",
|
||||
"stock_account",
|
||||
|
|
|
|||
|
|
@ -2,26 +2,39 @@
|
|||
# @author Santi Noreña (<santi@criptomart.net>)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
# import logging
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
# _logger = logging.getLogger(__name__)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductPricelist(models.Model):
|
||||
_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"]
|
||||
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(
|
||||
products,
|
||||
qty=qty,
|
||||
quantity,
|
||||
uom=uom,
|
||||
date=date,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"[PRICELIST DEBUG] super()._compute_price_rule returned: %s",
|
||||
res,
|
||||
)
|
||||
|
||||
new_res = res.copy()
|
||||
item_id = []
|
||||
for product_id, values in res.items():
|
||||
|
|
@ -29,10 +42,26 @@ class ProductPricelist(models.Model):
|
|||
item_id = values[1]
|
||||
if 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":
|
||||
price = ProductProduct.browse(
|
||||
product_id
|
||||
).last_purchase_price_received
|
||||
product = ProductProduct.browse(product_id)
|
||||
price = product.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
|
||||
new_res[product_id] = (price, item_id)
|
||||
_logger.info(
|
||||
"[PRICELIST DEBUG] Product %s: calculated price=%s",
|
||||
product_id,
|
||||
price,
|
||||
)
|
||||
return new_res
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
from odoo import models
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_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(
|
||||
self, price_type, uom=None, currency=None, company=None, date=False
|
||||
):
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@
|
|||
|
||||
from odoo import exceptions, models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
import math
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
|
@ -56,6 +59,15 @@ class ProductTemplate(models.Model):
|
|||
pricelist = pricelist_obj.browse(int(pricelist_id))
|
||||
if pricelist:
|
||||
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 (
|
||||
template.name
|
||||
and template.id
|
||||
|
|
@ -65,6 +77,14 @@ class ProductTemplate(models.Model):
|
|||
partial_price = template.product_variant_id._get_price(
|
||||
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
|
||||
if not template.taxes_id:
|
||||
raise UserError(
|
||||
|
|
@ -73,23 +93,33 @@ class ProductTemplate(models.Model):
|
|||
)
|
||||
% template.name
|
||||
)
|
||||
tax_price = template.taxes_id.compute_all(
|
||||
partial_price[template.product_variant_id.id]["value"] or 0.0,
|
||||
handle_price_include=False,
|
||||
|
||||
base_price = partial_price.get("value", 0.0) or 0.0
|
||||
_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"]
|
||||
+ partial_price[template.product_variant_id.id]["value"]
|
||||
|
||||
# Use base price without taxes (taxes will be calculated automatically on sales)
|
||||
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(
|
||||
{
|
||||
"list_price_theoritical": price_with_taxes,
|
||||
"list_price_theoritical": theoretical_price,
|
||||
"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):
|
||||
for template in self:
|
||||
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.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(
|
||||
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,
|
||||
price_updated,
|
||||
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(
|
||||
"Update last_purchase_price_received: %s for product %s Previous price: %s"
|
||||
% (
|
||||
price_updated,
|
||||
move.product_id.default_code,
|
||||
move.product_id.last_purchase_price_received,
|
||||
)
|
||||
"[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,
|
||||
move.product_id.id,
|
||||
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.company_id
|
||||
|
|
|
|||
|
|
@ -161,3 +161,65 @@ class TestProductTemplate(TransactionCase):
|
|||
self.assertTrue(field_theoritical.company_dependent)
|
||||
self.assertTrue(field_updated.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