[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:
parent
f3a258766b
commit
6d94484710
6 changed files with 293 additions and 1 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue