diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index 3303455..760feee 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -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", diff --git a/product_sale_price_from_pricelist/migrations/18.0.2.1.0/pre-migrate.py b/product_sale_price_from_pricelist/migrations/18.0.2.1.0/pre-migrate.py new file mode 100644 index 0000000..447a730 --- /dev/null +++ b/product_sale_price_from_pricelist/migrations/18.0.2.1.0/pre-migrate.py @@ -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.") diff --git a/product_sale_price_from_pricelist/models/stock_move.py b/product_sale_price_from_pricelist/models/stock_move.py index 9e5a408..ca933c9 100644 --- a/product_sale_price_from_pricelist/models/stock_move.py +++ b/product_sale_price_from_pricelist/models/stock_move.py @@ -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 diff --git a/product_sale_price_from_pricelist/tests/__init__.py b/product_sale_price_from_pricelist/tests/__init__.py index b14ee57..89313bf 100644 --- a/product_sale_price_from_pricelist/tests/__init__.py +++ b/product_sale_price_from_pricelist/tests/__init__.py @@ -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 diff --git a/product_sale_price_from_pricelist/tests/test_stock_move.py b/product_sale_price_from_pricelist/tests/test_stock_move.py index 1c747f8..16b87e8 100644 --- a/product_sale_price_from_pricelist/tests/test_stock_move.py +++ b/product_sale_price_from_pricelist/tests/test_stock_move.py @@ -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"): diff --git a/product_sale_price_from_pricelist/tests/test_theoretical_price.py b/product_sale_price_from_pricelist/tests/test_theoretical_price.py new file mode 100644 index 0000000..c5baff7 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_theoretical_price.py @@ -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", + )