[FIX] product_sale_price_from_pricelist: migrate data and add diagnostic tests

Migration (18.0.2.1.0):
- Migrate price fields from product.template to product.product
- Fields were previously stored in template during initial refactoring
- Data now properly located in product variant storage

Changes:
- Add migration pre-migrate.py to handle data migration automatically
- Add test_theoretical_price.py with comprehensive diagnostic tests
- Add test_full_flow_updates_theoretical_price to verify complete workflow
- Enhance stock_move.py with additional debug logging to diagnose issues
- Update __manifest__.py version to 18.0.2.1.0
- Update tests/__init__.py to include new test module

Fixes:
- last_purchase_price_received was stored in product.template but read from product.product
- Causes theoretical price calculation to show 0.0 instead of correct value
- Migration script copies data to correct model with company_dependent JSON format
This commit is contained in:
snt 2026-02-12 19:51:23 +01:00
parent f3a258766b
commit 6d94484710
6 changed files with 293 additions and 1 deletions

View file

@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018 { # noqa: B018
"name": "Product Sale Price from Pricelist", "name": "Product Sale Price from Pricelist",
"version": "18.0.2.0.0", "version": "18.0.2.1.0",
"category": "product", "category": "product",
"summary": "Set sale price from pricelist based on last purchase price", "summary": "Set sale price from pricelist based on last purchase price",
"author": "Odoo Community Association (OCA), Criptomart", "author": "Odoo Community Association (OCA), Criptomart",

View file

@ -0,0 +1,72 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Migrate price fields from product.template to product.product.
In version 18.0.2.1.0, these fields were moved from product.template
to product.product for proper variant handling:
- last_purchase_price_received
- list_price_theoritical
- last_purchase_price_updated
- last_purchase_price_compute_type
"""
if not version:
return
_logger.info("Migrating price fields from product.template to product.product...")
# Migrate last_purchase_price_received
cr.execute("""
UPDATE product_product pp
SET last_purchase_price_received = pt.last_purchase_price_received
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.last_purchase_price_received IS NOT NULL
AND (pp.last_purchase_price_received IS NULL
OR pp.last_purchase_price_received = '{}')
""")
_logger.info("Migrated last_purchase_price_received: %d rows", cr.rowcount)
# Migrate list_price_theoritical
cr.execute("""
UPDATE product_product pp
SET list_price_theoritical = pt.list_price_theoritical
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.list_price_theoritical IS NOT NULL
AND (pp.list_price_theoritical IS NULL
OR pp.list_price_theoritical = '{}')
""")
_logger.info("Migrated list_price_theoritical: %d rows", cr.rowcount)
# Migrate last_purchase_price_updated
cr.execute("""
UPDATE product_product pp
SET last_purchase_price_updated = pt.last_purchase_price_updated
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.last_purchase_price_updated IS NOT NULL
AND (pp.last_purchase_price_updated IS NULL
OR pp.last_purchase_price_updated = '{}')
""")
_logger.info("Migrated last_purchase_price_updated: %d rows", cr.rowcount)
# Migrate last_purchase_price_compute_type
cr.execute("""
UPDATE product_product pp
SET last_purchase_price_compute_type = pt.last_purchase_price_compute_type
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.last_purchase_price_compute_type IS NOT NULL
AND (pp.last_purchase_price_compute_type IS NULL
OR pp.last_purchase_price_compute_type = '{}')
""")
_logger.info("Migrated last_purchase_price_compute_type: %d rows", cr.rowcount)
_logger.info("Migration of price fields completed.")

View file

@ -56,6 +56,23 @@ class StockMove(models.Model):
price_updated, move.product_id.uom_id price_updated, move.product_id.uom_id
) )
_logger.info(
"[PRICE DEBUG] Product %s [%s]: price_updated=%.2f, current_price=%.2f, quantity=%.2f, will_update=%s",
move.product_id.default_code or move.product_id.name,
move.product_id.id,
price_updated,
move.product_id.last_purchase_price_received,
move.quantity,
bool(
float_compare(
move.product_id.last_purchase_price_received,
price_updated,
precision_digits=2,
)
and not float_is_zero(move.quantity, precision_digits=3)
),
)
if float_compare( if float_compare(
move.product_id.last_purchase_price_received, move.product_id.last_purchase_price_received,
price_updated, price_updated,
@ -82,5 +99,13 @@ class StockMove(models.Model):
"last_purchase_price_received": price_updated, "last_purchase_price_received": price_updated,
} }
) )
# Verify write was successful
product_company.invalidate_recordset()
_logger.info(
"[PRICE DEBUG] Product %s [%s]: After write, last_purchase_price_received=%.2f",
product_company.default_code or product_company.name,
product_company.id,
product_company.last_purchase_price_received,
)
product_company._compute_theoritical_price() product_company._compute_theoritical_price()
return res return res

View file

@ -1,4 +1,7 @@
# flake8: noqa: F401
# Imports are used by Odoo test framework to register tests
from . import test_product_template from . import test_product_template
from . import test_stock_move from . import test_stock_move
from . import test_pricelist from . import test_pricelist
from . import test_res_config from . import test_res_config
from . import test_theoretical_price

View file

@ -119,8 +119,49 @@ class TestStockMove(TransactionCase):
picking.button_validate() picking.button_validate()
# Verify price was updated # Verify price was updated
self.product.invalidate_recordset()
self.assertEqual(self.product.last_purchase_price_received, 8.0) self.assertEqual(self.product.last_purchase_price_received, 8.0)
def test_full_flow_updates_theoretical_price(self):
"""Test that validating a purchase receipt updates theoretical price."""
# Initial state
self.product.write(
{
"last_purchase_price_received": 0.0,
"list_price_theoritical": 0.0,
}
)
self.product.invalidate_recordset()
# Create and confirm purchase order at 100.0
purchase_order = self._create_purchase_order(self.product, qty=5, price=100.0)
purchase_order.button_confirm()
# Validate the picking
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Re-read from DB
self.product.invalidate_recordset()
# Verify last_purchase_price_received was updated
self.assertEqual(
self.product.last_purchase_price_received,
100.0,
"last_purchase_price_received should be 100.0 after receipt",
)
# Verify theoretical price was calculated (100.0 * 1.5 = 150.0 with 50% markup)
self.assertAlmostEqual(
self.product.list_price_theoritical,
150.0,
places=2,
msg=f"Theoretical price should be 150.0, got {self.product.list_price_theoritical}",
)
def test_update_price_with_first_discount(self): def test_update_price_with_first_discount(self):
"""Test price update with first discount only""" """Test price update with first discount only"""
if not hasattr(self.env["purchase.order.line"], "discount1"): if not hasattr(self.env["purchase.order.line"], "discount1"):

View file

@ -0,0 +1,151 @@
# Copyright (C) 2026: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestTheoreticalPriceCalculation(TransactionCase):
"""Test the theoretical price calculation to diagnose issues."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 10%",
"amount": 10.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
# Create pricelist with last_purchase_price base and 10% markup
cls.pricelist = cls.env["product.pricelist"].create(
{
"name": "Test Auto Pricelist",
"currency_id": cls.env.company.currency_id.id,
}
)
cls.pricelist_item = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": -10.0, # 10% markup (negative discount)
"applied_on": "3_global",
}
)
# Configure the pricelist in settings
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id),
)
# Create product.product directly
cls.product = cls.env["product.product"].create(
{
"name": "Test Product Direct",
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
}
)
def test_write_last_purchase_price_on_product(self):
"""Test that writing last_purchase_price_received on product.product works."""
# Write directly to product.product
self.product.write({"last_purchase_price_received": 100.0})
# Re-read from DB to ensure value is persisted
self.product.invalidate_recordset()
self.assertEqual(
self.product.last_purchase_price_received,
100.0,
"last_purchase_price_received should be 100.0 after write",
)
def test_compute_theoretical_price_from_product(self):
"""Test computing theoretical price when called on product.product."""
# Set purchase price
self.product.write(
{
"last_purchase_price_received": 100.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
self.product.invalidate_recordset()
# Verify price was written
self.assertEqual(self.product.last_purchase_price_received, 100.0)
# Compute theoretical price
self.product._compute_theoritical_price()
self.product.invalidate_recordset()
# Expected: 100.0 * 1.10 = 110.0 (10% markup)
self.assertAlmostEqual(
self.product.list_price_theoritical,
110.0,
places=2,
msg=f"Theoretical price should be 110.0, got {self.product.list_price_theoritical}",
)
def test_compute_theoretical_price_from_template(self):
"""Test computing theoretical price when called on product.template."""
template = self.product.product_tmpl_id
# Set purchase price on variant
self.product.write(
{
"last_purchase_price_received": 100.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
self.product.invalidate_recordset()
# Verify price was written
self.assertEqual(self.product.last_purchase_price_received, 100.0)
# Compute theoretical price via template
template._compute_theoritical_price()
self.product.invalidate_recordset()
# Expected: 100.0 * 1.10 = 110.0 (10% markup)
self.assertAlmostEqual(
self.product.list_price_theoritical,
110.0,
places=2,
msg=f"Theoretical price should be 110.0, got {self.product.list_price_theoritical}",
)
def test_field_storage_location(self):
"""Test that fields are stored on product.product, not product.template."""
# Check field definitions
product_fields = self.env["product.product"]._fields
template_fields = self.env["product.template"]._fields
# These should be stored fields on product.product
self.assertFalse(
product_fields["last_purchase_price_received"].compute,
"last_purchase_price_received should NOT be computed on product.product",
)
self.assertFalse(
product_fields["list_price_theoritical"].compute,
"list_price_theoritical should NOT be computed on product.product",
)
# These should be computed fields on product.template
self.assertTrue(
template_fields["last_purchase_price_received"].compute,
"last_purchase_price_received should be computed on product.template",
)
self.assertTrue(
template_fields["list_price_theoritical"].compute,
"list_price_theoritical should be computed on product.template",
)