[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).
|
||||
{ # noqa: B018
|
||||
"name": "Product Sale Price from Pricelist",
|
||||
"version": "18.0.2.0.0",
|
||||
"version": "18.0.2.1.0",
|
||||
"category": "product",
|
||||
"summary": "Set sale price from pricelist based on last purchase price",
|
||||
"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
|
||||
)
|
||||
|
||||
_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(
|
||||
move.product_id.last_purchase_price_received,
|
||||
price_updated,
|
||||
|
|
@ -82,5 +99,13 @@ class StockMove(models.Model):
|
|||
"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()
|
||||
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_stock_move
|
||||
from . import test_pricelist
|
||||
from . import test_res_config
|
||||
from . import test_theoretical_price
|
||||
|
|
|
|||
|
|
@ -119,8 +119,49 @@ class TestStockMove(TransactionCase):
|
|||
picking.button_validate()
|
||||
|
||||
# Verify price was updated
|
||||
self.product.invalidate_recordset()
|
||||
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):
|
||||
"""Test price update with first discount only"""
|
||||
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