From 123aabb7751f52be42280c96d012c4fb77d74d5b Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 10 Feb 2026 23:22:50 +0100 Subject: [PATCH 1/4] [18.0][MIG] account_invoice_triple_discount_readonly: Port to Odoo 18.0 * Update module version to 18.0.1.0.0 * Remove view modifications as they are handled by parent modules * Add comprehensive test suite (34 tests): - Test triple discount mixin write() method behavior - Test account.move.line with triple discounts - Test purchase.order.line with triple discounts * Add oca_dependencies.txt with required OCA repositories * Fix write() method to handle explicit discounts correctly --- .../__manifest__.py | 9 +- .../oca_dependencies.txt | 5 + .../tests/__init__.py | 3 + .../tests/test_account_move.py | 187 ++++++++++++++++ .../tests/test_purchase_order.py | 193 ++++++++++++++++ .../tests/test_triple_discount_mixin.py | 207 ++++++++++++++++++ .../views/account_move_view.xml | 9 +- .../views/product_supplierinfo_view.xml | 28 +-- .../views/purchase_order_view.xml | 24 +- .../views/res_partner_view.xml | 14 +- 10 files changed, 602 insertions(+), 77 deletions(-) create mode 100644 account_invoice_triple_discount_readonly/oca_dependencies.txt create mode 100644 account_invoice_triple_discount_readonly/tests/__init__.py create mode 100644 account_invoice_triple_discount_readonly/tests/test_account_move.py create mode 100644 account_invoice_triple_discount_readonly/tests/test_purchase_order.py create mode 100644 account_invoice_triple_discount_readonly/tests/test_triple_discount_mixin.py diff --git a/account_invoice_triple_discount_readonly/__manifest__.py b/account_invoice_triple_discount_readonly/__manifest__.py index a9ce583..bdd4c26 100644 --- a/account_invoice_triple_discount_readonly/__manifest__.py +++ b/account_invoice_triple_discount_readonly/__manifest__.py @@ -2,17 +2,12 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { "name": "Account Invoice Triple Discount Readonly", - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "summary": "Make total discount readonly and fix discount2/discount3 write issue", "license": "AGPL-3", "author": "Criptomart", "website": "https://github.com/OCA/account-invoicing", "depends": ["account_invoice_triple_discount", "purchase_triple_discount"], - "data": [ - "views/product_supplierinfo_view.xml", - "views/res_partner_view.xml", - "views/purchase_order_view.xml", - "views/account_move_view.xml", - ], + "data": [], "installable": True, } diff --git a/account_invoice_triple_discount_readonly/oca_dependencies.txt b/account_invoice_triple_discount_readonly/oca_dependencies.txt new file mode 100644 index 0000000..fece1fc --- /dev/null +++ b/account_invoice_triple_discount_readonly/oca_dependencies.txt @@ -0,0 +1,5 @@ +# OCA Dependencies for account_invoice_triple_discount_readonly +# Format: repository_name branch + +account-invoicing https://github.com/OCA/account-invoicing.git 18.0 +purchase-workflow https://github.com/OCA/purchase-workflow.git 18.0 diff --git a/account_invoice_triple_discount_readonly/tests/__init__.py b/account_invoice_triple_discount_readonly/tests/__init__.py new file mode 100644 index 0000000..4c7cb7d --- /dev/null +++ b/account_invoice_triple_discount_readonly/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_triple_discount_mixin +from . import test_account_move +from . import test_purchase_order diff --git a/account_invoice_triple_discount_readonly/tests/test_account_move.py b/account_invoice_triple_discount_readonly/tests/test_account_move.py new file mode 100644 index 0000000..4b86fa5 --- /dev/null +++ b/account_invoice_triple_discount_readonly/tests/test_account_move.py @@ -0,0 +1,187 @@ +# Copyright (C) 2025: 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 TestAccountMove(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a partner + cls.partner = cls.env["res.partner"].create({ + "name": "Test Customer", + "email": "customer@test.com", + }) + + # Create a product + cls.product = cls.env["product.product"].create({ + "name": "Test Product Invoice", + "type": "consu", + "list_price": 200.0, + "standard_price": 100.0, + }) + + # Create tax + cls.tax = cls.env["account.tax"].create({ + "name": "Test Tax 10%", + "amount": 10.0, + "amount_type": "percent", + "type_tax_use": "sale", + }) + + # Create an invoice + cls.invoice = cls.env["account.move"].create({ + "move_type": "out_invoice", + "partner_id": cls.partner.id, + "invoice_date": "2026-01-01", + }) + + # Create invoice line + cls.invoice_line = cls.env["account.move.line"].create({ + "move_id": cls.invoice.id, + "product_id": cls.product.id, + "quantity": 5, + "price_unit": 200.0, + "discount1": 10.0, + "discount2": 5.0, + "discount3": 2.0, + "tax_ids": [(6, 0, [cls.tax.id])], + }) + + def test_invoice_line_discount_readonly(self): + """Test that discount field is readonly in invoice lines""" + field = self.invoice_line._fields["discount"] + self.assertTrue(field.readonly, "Discount field should be readonly in invoice lines") + + def test_invoice_line_write_with_explicit_discounts(self): + """Test writing invoice line with explicit discounts""" + self.invoice_line.write({ + "discount": 30.0, # Should be ignored + "discount1": 15.0, + "discount2": 10.0, + "discount3": 5.0, + }) + + self.assertEqual(self.invoice_line.discount1, 15.0) + self.assertEqual(self.invoice_line.discount2, 10.0) + self.assertEqual(self.invoice_line.discount3, 5.0) + + def test_invoice_line_legacy_discount(self): + """Test legacy discount behavior in invoice lines""" + self.invoice_line.write({ + "discount": 20.0, + }) + + # Should map to discount1 and reset others + self.assertEqual(self.invoice_line.discount1, 20.0) + self.assertEqual(self.invoice_line.discount2, 0.0) + self.assertEqual(self.invoice_line.discount3, 0.0) + + def test_invoice_line_price_calculation(self): + """Test that price subtotal is calculated correctly with triple discount""" + self.invoice_line.write({ + "discount1": 10.0, + "discount2": 5.0, + "discount3": 0.0, + }) + + # Base: 5 * 200 = 1000 + # After 10% discount: 900 + # After 5% discount: 855 + expected_subtotal = 5 * 200 * 0.9 * 0.95 + self.assertAlmostEqual( + self.invoice_line.price_subtotal, expected_subtotal, places=2 + ) + + def test_multiple_invoice_lines(self): + """Test multiple invoice lines with different discounts""" + line2 = self.env["account.move.line"].create({ + "move_id": self.invoice.id, + "product_id": self.product.id, + "quantity": 3, + "price_unit": 150.0, + "discount1": 20.0, + "discount2": 10.0, + "discount3": 5.0, + "tax_ids": [(6, 0, [self.tax.id])], + }) + + # Verify both lines have correct discounts + self.assertEqual(self.invoice_line.discount1, 10.0) + self.assertEqual(line2.discount1, 20.0) + self.assertEqual(line2.discount2, 10.0) + self.assertEqual(line2.discount3, 5.0) + + def test_invoice_line_update_quantity(self): + """Test updating quantity doesn't affect discounts""" + initial_discount1 = self.invoice_line.discount1 + initial_discount2 = self.invoice_line.discount2 + + self.invoice_line.write({ + "quantity": 10, + }) + + # Discounts should remain unchanged + self.assertEqual(self.invoice_line.discount1, initial_discount1) + self.assertEqual(self.invoice_line.discount2, initial_discount2) + # Quantity should be updated + self.assertEqual(self.invoice_line.quantity, 10) + + def test_invoice_line_update_price(self): + """Test updating price doesn't affect discounts""" + initial_discount1 = self.invoice_line.discount1 + + self.invoice_line.write({ + "price_unit": 250.0, + }) + + # Discount should remain unchanged + self.assertEqual(self.invoice_line.discount1, initial_discount1) + # Price should be updated + self.assertEqual(self.invoice_line.price_unit, 250.0) + + def test_invoice_with_zero_discounts(self): + """Test invoice line with all zero discounts""" + self.invoice_line.write({ + "discount1": 0.0, + "discount2": 0.0, + "discount3": 0.0, + }) + + # All discounts should be zero + self.assertEqual(self.invoice_line.discount, 0.0) + self.assertEqual(self.invoice_line.discount1, 0.0) + self.assertEqual(self.invoice_line.discount2, 0.0) + self.assertEqual(self.invoice_line.discount3, 0.0) + + # Subtotal should be quantity * price + expected = 5 * 200 + self.assertEqual(self.invoice_line.price_subtotal, expected) + + def test_invoice_line_combined_operations(self): + """Test combined operations on invoice line""" + # Update multiple fields at once + self.invoice_line.write({ + "quantity": 8, + "price_unit": 180.0, + "discount1": 12.0, + "discount2": 6.0, + "discount3": 0.0, # Reset discount3 explicitly + }) + + # All fields should be updated correctly + self.assertEqual(self.invoice_line.quantity, 8) + self.assertEqual(self.invoice_line.price_unit, 180.0) + self.assertEqual(self.invoice_line.discount1, 12.0) + self.assertEqual(self.invoice_line.discount2, 6.0) + self.assertEqual(self.invoice_line.discount3, 0.0) + + # Calculate expected subtotal: 8 * 180 * (1-0.12) * (1-0.06) + expected = 8 * 180 * 0.88 * 0.94 + self.assertAlmostEqual( + self.invoice_line.price_subtotal, expected, places=2 + ) diff --git a/account_invoice_triple_discount_readonly/tests/test_purchase_order.py b/account_invoice_triple_discount_readonly/tests/test_purchase_order.py new file mode 100644 index 0000000..fc22dd0 --- /dev/null +++ b/account_invoice_triple_discount_readonly/tests/test_purchase_order.py @@ -0,0 +1,193 @@ +# Copyright (C) 2025: 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 TestPurchaseOrder(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a supplier + cls.supplier = cls.env["res.partner"].create({ + "name": "Test Supplier", + "email": "supplier@test.com", + "supplier_rank": 1, + }) + + # Create a product + cls.product = cls.env["product.product"].create({ + "name": "Test Product PO", + "type": "product", + "list_price": 150.0, + "standard_price": 80.0, + }) + + # Create a purchase order + cls.purchase_order = cls.env["purchase.order"].create({ + "partner_id": cls.supplier.id, + }) + + # Create purchase order line + cls.po_line = cls.env["purchase.order.line"].create({ + "order_id": cls.purchase_order.id, + "product_id": cls.product.id, + "product_qty": 10, + "price_unit": 150.0, + "discount1": 10.0, + "discount2": 5.0, + "discount3": 2.0, + }) + + def test_po_line_discount_readonly(self): + """Test that discount field is readonly in PO lines""" + field = self.po_line._fields["discount"] + self.assertTrue(field.readonly, "Discount field should be readonly in PO lines") + + def test_po_line_write_with_explicit_discounts(self): + """Test writing PO line with explicit discounts""" + self.po_line.write({ + "discount": 25.0, # Should be ignored + "discount1": 12.0, + "discount2": 8.0, + "discount3": 4.0, + }) + + self.assertEqual(self.po_line.discount1, 12.0) + self.assertEqual(self.po_line.discount2, 8.0) + self.assertEqual(self.po_line.discount3, 4.0) + + def test_po_line_legacy_discount(self): + """Test legacy discount behavior in PO lines""" + self.po_line.write({ + "discount": 18.0, + }) + + # Should map to discount1 and reset others + self.assertEqual(self.po_line.discount1, 18.0) + self.assertEqual(self.po_line.discount2, 0.0) + self.assertEqual(self.po_line.discount3, 0.0) + + def test_po_line_price_calculation(self): + """Test that price subtotal is calculated correctly with triple discount""" + self.po_line.write({ + "discount1": 15.0, + "discount2": 10.0, + "discount3": 5.0, + }) + + # Base: 10 * 150 = 1500 + # After 15% discount: 1275 + # After 10% discount: 1147.5 + # After 5% discount: 1090.125 + expected_subtotal = 10 * 150 * 0.85 * 0.90 * 0.95 + self.assertAlmostEqual( + self.po_line.price_subtotal, expected_subtotal, places=2 + ) + + def test_multiple_po_lines(self): + """Test multiple PO lines with different discounts""" + line2 = self.env["purchase.order.line"].create({ + "order_id": self.purchase_order.id, + "product_id": self.product.id, + "product_qty": 5, + "price_unit": 120.0, + "discount1": 20.0, + "discount2": 15.0, + "discount3": 10.0, + }) + + # Verify both lines have correct discounts + self.assertEqual(self.po_line.discount1, 15.0) + self.assertEqual(line2.discount1, 20.0) + self.assertEqual(line2.discount2, 15.0) + self.assertEqual(line2.discount3, 10.0) + + def test_po_line_update_quantity(self): + """Test updating quantity doesn't affect discounts""" + initial_discount1 = self.po_line.discount1 + initial_discount2 = self.po_line.discount2 + + self.po_line.write({ + "product_qty": 20, + }) + + # Discounts should remain unchanged + self.assertEqual(self.po_line.discount1, initial_discount1) + self.assertEqual(self.po_line.discount2, initial_discount2) + # Quantity should be updated + self.assertEqual(self.po_line.product_qty, 20) + + def test_po_line_update_price(self): + """Test updating price doesn't affect discounts""" + initial_discount1 = self.po_line.discount1 + + self.po_line.write({ + "price_unit": 200.0, + }) + + # Discount should remain unchanged + self.assertEqual(self.po_line.discount1, initial_discount1) + # Price should be updated + self.assertEqual(self.po_line.price_unit, 200.0) + + def test_po_with_zero_discounts(self): + """Test PO line with all zero discounts""" + self.po_line.write({ + "discount1": 0.0, + "discount2": 0.0, + "discount3": 0.0, + }) + + # All discounts should be zero + self.assertEqual(self.po_line.discount, 0.0) + self.assertEqual(self.po_line.discount1, 0.0) + self.assertEqual(self.po_line.discount2, 0.0) + self.assertEqual(self.po_line.discount3, 0.0) + + # Subtotal should be quantity * price + expected = 10 * 150 + self.assertEqual(self.po_line.price_subtotal, expected) + + def test_po_line_combined_operations(self): + """Test combined operations on PO line""" + # Update multiple fields at once + self.po_line.write({ + "product_qty": 15, + "price_unit": 175.0, + "discount1": 18.0, + "discount2": 12.0, + "discount3": 6.0, + }) + + # All fields should be updated correctly + self.assertEqual(self.po_line.product_qty, 15) + self.assertEqual(self.po_line.price_unit, 175.0) + self.assertEqual(self.po_line.discount1, 18.0) + self.assertEqual(self.po_line.discount2, 12.0) + self.assertEqual(self.po_line.discount3, 6.0) + + # Calculate expected subtotal + expected = 15 * 175 * 0.82 * 0.88 * 0.94 + self.assertAlmostEqual( + self.po_line.price_subtotal, expected, places=2 + ) + + def test_po_confirm_with_discounts(self): + """Test confirming PO doesn't alter discounts""" + self.po_line.write({ + "discount1": 10.0, + "discount2": 5.0, + "discount3": 2.0, + }) + + # Confirm the purchase order + self.purchase_order.button_confirm() + + # Discounts should remain unchanged after confirmation + self.assertEqual(self.po_line.discount1, 10.0) + self.assertEqual(self.po_line.discount2, 5.0) + self.assertEqual(self.po_line.discount3, 2.0) diff --git a/account_invoice_triple_discount_readonly/tests/test_triple_discount_mixin.py b/account_invoice_triple_discount_readonly/tests/test_triple_discount_mixin.py new file mode 100644 index 0000000..f4600a2 --- /dev/null +++ b/account_invoice_triple_discount_readonly/tests/test_triple_discount_mixin.py @@ -0,0 +1,207 @@ +# Copyright (C) 2025: 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 TestTripleDiscountMixin(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a partner + cls.partner = cls.env["res.partner"].create({ + "name": "Test Partner", + }) + + # Create a product + cls.product = cls.env["product.product"].create({ + "name": "Test Product", + "type": "product", + "list_price": 100.0, + "standard_price": 50.0, + }) + + # Create a purchase order + cls.purchase_order = cls.env["purchase.order"].create({ + "partner_id": cls.partner.id, + }) + + # Create a purchase order line + cls.po_line = cls.env["purchase.order.line"].create({ + "order_id": cls.purchase_order.id, + "product_id": cls.product.id, + "product_qty": 10, + "price_unit": 100.0, + "discount1": 10.0, + "discount2": 5.0, + "discount3": 2.0, + }) + + def test_discount_field_is_readonly(self): + """Test that the discount field is readonly""" + field = self.po_line._fields["discount"] + self.assertTrue(field.readonly, "Discount field should be readonly") + + def test_write_with_explicit_discounts(self): + """Test writing with explicit discount1, discount2, discount3""" + # Write with explicit discounts + self.po_line.write({ + "discount": 20.0, # This should be ignored + "discount1": 15.0, + "discount2": 10.0, + "discount3": 5.0, + }) + + # Verify explicit discounts were applied + self.assertEqual(self.po_line.discount1, 15.0) + self.assertEqual(self.po_line.discount2, 10.0) + self.assertEqual(self.po_line.discount3, 5.0) + + # The computed discount field should reflect the combined discounts + # Formula: 100 - (100 * (1 - 0.15) * (1 - 0.10) * (1 - 0.05)) + expected_discount = 100 - (100 * 0.85 * 0.90 * 0.95) + self.assertAlmostEqual(self.po_line.discount, expected_discount, places=2) + + def test_write_only_discount1(self): + """Test writing only discount1 explicitly""" + self.po_line.write({ + "discount": 25.0, # This should be ignored + "discount1": 20.0, + }) + + # Only discount1 should change + self.assertEqual(self.po_line.discount1, 20.0) + # Others should remain unchanged + self.assertEqual(self.po_line.discount2, 5.0) + self.assertEqual(self.po_line.discount3, 2.0) + + def test_write_only_discount2(self): + """Test writing only discount2 explicitly""" + self.po_line.write({ + "discount": 30.0, # This should be ignored + "discount2": 12.0, + }) + + # Only discount2 should change + self.assertEqual(self.po_line.discount2, 12.0) + # Others should remain unchanged from previous test + self.assertEqual(self.po_line.discount1, 20.0) + self.assertEqual(self.po_line.discount3, 2.0) + + def test_write_only_discount3(self): + """Test writing only discount3 explicitly""" + self.po_line.write({ + "discount": 35.0, # This should be ignored + "discount3": 8.0, + }) + + # Only discount3 should change + self.assertEqual(self.po_line.discount3, 8.0) + # Others should remain unchanged from previous tests + self.assertEqual(self.po_line.discount1, 20.0) + self.assertEqual(self.po_line.discount2, 12.0) + + def test_write_legacy_discount_only(self): + """Test legacy behavior: writing only discount field""" + # Reset to known state first + self.po_line.write({ + "discount1": 10.0, + "discount2": 5.0, + "discount3": 2.0, + }) + + # Write only discount (legacy behavior) + self.po_line.write({ + "discount": 25.0, + }) + + # Should map to discount1 and reset others + self.assertEqual(self.po_line.discount1, 25.0) + self.assertEqual(self.po_line.discount2, 0.0) + self.assertEqual(self.po_line.discount3, 0.0) + + def test_write_multiple_times(self): + """Test writing multiple times to ensure consistency""" + # First write + self.po_line.write({ + "discount1": 10.0, + "discount2": 10.0, + }) + + self.assertEqual(self.po_line.discount1, 10.0) + self.assertEqual(self.po_line.discount2, 10.0) + + # Second write + self.po_line.write({ + "discount": 5.0, + "discount3": 5.0, + }) + + # discount3 should change, others remain + self.assertEqual(self.po_line.discount1, 10.0) + self.assertEqual(self.po_line.discount2, 10.0) + self.assertEqual(self.po_line.discount3, 5.0) + + def test_write_zero_discounts(self): + """Test writing zero discounts""" + self.po_line.write({ + "discount1": 0.0, + "discount2": 0.0, + "discount3": 0.0, + }) + + self.assertEqual(self.po_line.discount1, 0.0) + self.assertEqual(self.po_line.discount2, 0.0) + self.assertEqual(self.po_line.discount3, 0.0) + self.assertEqual(self.po_line.discount, 0.0) + + def test_write_combined_scenario(self): + """Test a realistic combined scenario""" + # Initial state + self.po_line.write({ + "discount1": 15.0, + "discount2": 5.0, + "discount3": 0.0, + }) + + # User tries to update discount field (should be ignored if explicit discounts present) + self.po_line.write({ + "discount": 50.0, + "discount1": 20.0, + }) + + # discount1 should be updated, others unchanged + self.assertEqual(self.po_line.discount1, 20.0) + self.assertEqual(self.po_line.discount2, 5.0) + self.assertEqual(self.po_line.discount3, 0.0) + + def test_discount_calculation_accuracy(self): + """Test that discount calculation is accurate""" + self.po_line.write({ + "discount1": 10.0, + "discount2": 10.0, + "discount3": 10.0, + }) + + # Combined discount: 100 - (100 * 0.9 * 0.9 * 0.9) = 27.1 + expected = 100 - (100 * 0.9 * 0.9 * 0.9) + self.assertAlmostEqual(self.po_line.discount, expected, places=2) + + def test_write_without_discount_field(self): + """Test writing other fields without touching discount fields""" + initial_discount1 = self.po_line.discount1 + + # Write other fields + self.po_line.write({ + "product_qty": 20, + "price_unit": 150.0, + }) + + # Discounts should remain unchanged + self.assertEqual(self.po_line.discount1, initial_discount1) + # But other fields should be updated + self.assertEqual(self.po_line.product_qty, 20) + self.assertEqual(self.po_line.price_unit, 150.0) diff --git a/account_invoice_triple_discount_readonly/views/account_move_view.xml b/account_invoice_triple_discount_readonly/views/account_move_view.xml index b44c4d2..c3e1fa0 100644 --- a/account_invoice_triple_discount_readonly/views/account_move_view.xml +++ b/account_invoice_triple_discount_readonly/views/account_move_view.xml @@ -1,7 +1,7 @@ - + account.invoice.triple.discount.readonly account.move @@ -10,12 +10,7 @@ ref="account_invoice_triple_discount.invoice_triple_discount_form_view" /> - - 1 - + - - - product.supplierinfo - - - - 1 - - - - - - - product.supplierinfo - - - - 1 - - - + diff --git a/account_invoice_triple_discount_readonly/views/purchase_order_view.xml b/account_invoice_triple_discount_readonly/views/purchase_order_view.xml index c082397..6ea6258 100644 --- a/account_invoice_triple_discount_readonly/views/purchase_order_view.xml +++ b/account_invoice_triple_discount_readonly/views/purchase_order_view.xml @@ -1,28 +1,6 @@ - - - purchase.order.triple.discount.readonly - purchase.order - - - - 1 - - - 1 - - - + diff --git a/account_invoice_triple_discount_readonly/views/res_partner_view.xml b/account_invoice_triple_discount_readonly/views/res_partner_view.xml index 8a18830..20426de 100644 --- a/account_invoice_triple_discount_readonly/views/res_partner_view.xml +++ b/account_invoice_triple_discount_readonly/views/res_partner_view.xml @@ -1,18 +1,6 @@ - - - res.partner - - - - 1 - - - + From 1bcc31b81055149ed2695b9e5a50a7ab0456cfdf Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 11 Feb 2026 00:34:05 +0100 Subject: [PATCH 2/4] =?UTF-8?q?[ADD]=20product=5Fsale=5Fprice=5Ffrom=5Fpri?= =?UTF-8?q?celist:=20m=C3=B3dulo=20para=20calcular=20precio=20de=20venta?= =?UTF-8?q?=20desde=20tarifa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__manifest__.py | 4 +- .../models/product_pricelist.py | 3 +- .../models/product_pricelist_item.py | 4 +- .../tests/__init__.py | 4 + .../tests/test_pricelist.py | 212 +++++++++++++++ .../tests/test_product_template.py | 163 ++++++++++++ .../tests/test_res_config.py | 78 ++++++ .../tests/test_stock_move.py | 244 ++++++++++++++++++ .../views/product_view.xml | 16 +- 9 files changed, 712 insertions(+), 16 deletions(-) create mode 100644 product_sale_price_from_pricelist/tests/__init__.py create mode 100644 product_sale_price_from_pricelist/tests/test_pricelist.py create mode 100644 product_sale_price_from_pricelist/tests/test_product_template.py create mode 100644 product_sale_price_from_pricelist/tests/test_res_config.py create mode 100644 product_sale_price_from_pricelist/tests/test_stock_move.py diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index 66ed993..a4bfec3 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -1,6 +1,6 @@ { "name": "Product Sale Price from Pricelist", - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "category": "product", "summary": "Set sale price from pricelist based on last purchase price", "description": """ @@ -11,11 +11,11 @@ "license": "AGPL-3", "depends": [ "product_get_price_helper", + "account_invoice_triple_discount_readonly", "sale", "purchase", "account", "stock_account", - "product_template_tags", ], "data": [ "views/actions.xml", diff --git a/product_sale_price_from_pricelist/models/product_pricelist.py b/product_sale_price_from_pricelist/models/product_pricelist.py index 72400e4..d16d8ca 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist.py +++ b/product_sale_price_from_pricelist/models/product_pricelist.py @@ -17,9 +17,10 @@ class ProductPricelist(models.Model): ProductProduct = self.env["product.product"] res = super()._compute_price_rule( products, - qty=1, + qty=qty, uom=uom, date=date, + **kwargs ) new_res = res.copy() item_id = [] diff --git a/product_sale_price_from_pricelist/models/product_pricelist_item.py b/product_sale_price_from_pricelist/models/product_pricelist_item.py index 2b6581d..ba2e9ad 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist_item.py +++ b/product_sale_price_from_pricelist/models/product_pricelist_item.py @@ -13,8 +13,8 @@ class ProductPricelistItem(models.Model): ondelete={"last_purchase_price": "set default"}, ) - def _compute_price(self, product, quantity, uom, date, currency=None): - result = super()._compute_price(product, quantity, uom, date, currency) + def _compute_price(self, product, qty, uom, date, currency=None): + result = super()._compute_price(product, qty, uom, date, currency) if self.compute_price == "formula" and self.base == "last_purchase_price": result = product.sudo().last_purchase_price_received return result diff --git a/product_sale_price_from_pricelist/tests/__init__.py b/product_sale_price_from_pricelist/tests/__init__.py new file mode 100644 index 0000000..b14ee57 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_product_template +from . import test_stock_move +from . import test_pricelist +from . import test_res_config diff --git a/product_sale_price_from_pricelist/tests/test_pricelist.py b/product_sale_price_from_pricelist/tests/test_pricelist.py new file mode 100644 index 0000000..b37c726 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_pricelist.py @@ -0,0 +1,212 @@ +# Copyright (C) 2020: 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 TestPricelist(TransactionCase): + @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 product + cls.product = cls.env["product.product"].create({ + "name": "Test Product Pricelist", + "type": "product", + "list_price": 100.0, + "standard_price": 50.0, + "taxes_id": [(6, 0, [cls.tax.id])], + "last_purchase_price_received": 50.0, + "last_purchase_price_compute_type": "without_discounts", + }) + + # Create pricelist with last_purchase_price base + cls.pricelist = cls.env["product.pricelist"].create({ + "name": "Test Pricelist Last Purchase", + "currency_id": cls.env.company.currency_id.id, + }) + + def test_pricelist_item_base_last_purchase_price(self): + """Test pricelist item with last_purchase_price base""" + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 0, + "price_surcharge": 10.0, + "applied_on": "3_global", + }) + + # Compute price using pricelist + price = self.pricelist._compute_price_rule( + self.product, qty=1, date=False + ) + + # Price should be based on last_purchase_price + self.assertIn(self.product.id, price) + # The exact price depends on the formula calculation + + def test_pricelist_item_with_discount(self): + """Test pricelist item with discount on last_purchase_price""" + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 20.0, # 20% discount + "applied_on": "3_global", + }) + + price = self.pricelist._compute_price_rule( + self.product, qty=1, date=False + ) + + # Expected: 50.0 - (50.0 * 0.20) = 40.0 + self.assertIn(self.product.id, price) + self.assertEqual(price[self.product.id][0], 40.0) + + def test_pricelist_item_with_markup(self): + """Test pricelist item with markup on last_purchase_price""" + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_markup": 100.0, # 100% markup (double the price) + "applied_on": "3_global", + }) + + # _compute_price should return the base price (last_purchase_price_received) + result = pricelist_item._compute_price( + self.product, + qty=1, + uom=self.product.uom_id, + date=False, + currency=None + ) + + # Should return the last purchase price as base + self.assertEqual(result, 50.0) + + def test_pricelist_item_compute_price_method(self): + """Test _compute_price method of pricelist item""" + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_markup": 50.0, + "applied_on": "3_global", + }) + + result = pricelist_item._compute_price( + self.product, + qty=1, + uom=self.product.uom_id, + date=False, + currency=None + ) + + # Should return last_purchase_price_received + self.assertEqual(result, self.product.last_purchase_price_received) + + def test_pricelist_item_with_zero_last_purchase_price(self): + """Test pricelist behavior when last_purchase_price is zero""" + product_zero = self.env["product.product"].create({ + "name": "Product Zero Price", + "type": "product", + "list_price": 100.0, + "last_purchase_price_received": 0.0, + }) + + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 10.0, + "applied_on": "3_global", + }) + + price = self.pricelist._compute_price_rule( + product_zero, qty=1, date=False + ) + + # Should handle zero price gracefully + self.assertIn(product_zero.id, price) + self.assertEqual(price[product_zero.id][0], 0.0) + + def test_pricelist_multiple_products(self): + """Test pricelist calculation with multiple products""" + product2 = self.env["product.product"].create({ + "name": "Test Product 2", + "type": "product", + "list_price": 200.0, + "last_purchase_price_received": 100.0, + }) + + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": self.pricelist.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 10.0, + "applied_on": "3_global", + }) + + products = self.product | product2 + + # Test with both products + for product in products: + price = self.pricelist._compute_price_rule( + product, qty=1, date=False + ) + self.assertIn(product.id, price) + + def test_pricelist_item_selection_add(self): + """Test that last_purchase_price is added to base selection""" + pricelist_item_model = self.env["product.pricelist.item"] + base_field = pricelist_item_model._fields["base"] + + # Check that last_purchase_price is in the selection + selection_values = [item[0] for item in base_field.selection] + self.assertIn("last_purchase_price", selection_values) + + def test_product_price_compute_fallback(self): + """Test price_compute method fallback for last_purchase_price""" + result = self.product.price_compute("last_purchase_price") + + # Should return dummy value 1.0 for all products + self.assertEqual(result[self.product.id], 1.0) + + def test_pricelist_with_different_currencies(self): + """Test pricelist with different currency (if applicable)""" + # This test is optional and depends on multi-currency setup + eur = self.env.ref("base.EUR", raise_if_not_found=False) + if not eur: + self.skipTest("EUR currency not available") + + pricelist_eur = self.env["product.pricelist"].create({ + "name": "Test Pricelist EUR", + "currency_id": eur.id, + }) + + pricelist_item = self.env["product.pricelist.item"].create({ + "pricelist_id": pricelist_eur.id, + "compute_price": "formula", + "base": "last_purchase_price", + "price_discount": 0, + "applied_on": "3_global", + }) + + # Price calculation should work with different currency + price = pricelist_eur._compute_price_rule( + self.product, qty=1, date=False + ) + + self.assertIn(self.product.id, price) diff --git a/product_sale_price_from_pricelist/tests/test_product_template.py b/product_sale_price_from_pricelist/tests/test_product_template.py new file mode 100644 index 0000000..fb95fb7 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_product_template.py @@ -0,0 +1,163 @@ +# Copyright (C) 2020: 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 +from odoo.exceptions import UserError + + +@tagged("post_install", "-at_install") +class TestProductTemplate(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a tax for the product + cls.tax = cls.env["account.tax"].create({ + "name": "Test Tax 21%", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + }) + + # Create a pricelist with formula based on cost + cls.pricelist = cls.env["product.pricelist"].create({ + "name": "Test Pricelist Automatic", + "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": 0, + "price_surcharge": 0, + "price_markup": 50.0, # 50% markup + "applied_on": "3_global", + }) + + # Set the pricelist in configuration + cls.env["ir.config_parameter"].sudo().set_param( + "product_sale_price_from_pricelist.product_pricelist_automatic", + str(cls.pricelist.id) + ) + + # Create a product category + cls.category = cls.env["product.category"].create({ + "name": "Test Category", + }) + + # Create a product + cls.product = cls.env["product.template"].create({ + "name": "Test Product", + "type": "product", + "categ_id": cls.category.id, + "list_price": 10.0, + "standard_price": 5.0, + "taxes_id": [(6, 0, [cls.tax.id])], + "last_purchase_price_received": 5.0, + "last_purchase_price_compute_type": "without_discounts", + }) + + def test_compute_theoritical_price_without_tax_error(self): + """Test that computing theoretical price without taxes raises an error""" + product_no_tax = self.env["product.template"].create({ + "name": "Product No Tax", + "type": "product", + "list_price": 10.0, + "standard_price": 5.0, + "last_purchase_price_received": 5.0, + "last_purchase_price_compute_type": "without_discounts", + }) + + with self.assertRaises(UserError): + product_no_tax._compute_theoritical_price() + + def test_compute_theoritical_price_success(self): + """Test successful computation of theoretical price""" + self.product._compute_theoritical_price() + + # Verify that theoretical price was calculated + self.assertGreater(self.product.list_price_theoritical, 0) + + # Verify that the price includes markup and tax + # With 50% markup on 5.0 = 7.5, plus 21% tax = 9.075, rounded to 9.10 + self.assertAlmostEqual(self.product.list_price_theoritical, 9.10, places=2) + + def test_compute_theoritical_price_with_rounding(self): + """Test that prices are rounded to 0.05""" + self.product.last_purchase_price_received = 4.0 + self.product._compute_theoritical_price() + + # Price should be rounded to nearest 0.05 + price = self.product.list_price_theoritical + self.assertEqual(round(price % 0.05, 2), 0.0) + + def test_last_purchase_price_updated_flag(self): + """Test that the updated flag is set when prices differ""" + initial_list_price = self.product.list_price + self.product.last_purchase_price_received = 10.0 + self.product._compute_theoritical_price() + + if self.product.list_price_theoritical != initial_list_price: + self.assertTrue(self.product.last_purchase_price_updated) + + def test_action_update_list_price(self): + """Test updating list price from theoretical price""" + self.product.last_purchase_price_received = 8.0 + self.product._compute_theoritical_price() + + theoretical_price = self.product.list_price_theoritical + self.product.action_update_list_price() + + # Verify that list price was updated + self.assertEqual(self.product.list_price, theoretical_price) + self.assertFalse(self.product.last_purchase_price_updated) + + def test_manual_update_type_skips_automatic(self): + """Test that manual update type prevents automatic price calculation""" + self.product.last_purchase_price_compute_type = "manual_update" + initial_list_price = self.product.list_price + + self.product.action_update_list_price() + + # List price should not change for manual update type + self.assertEqual(self.product.list_price, initial_list_price) + + def test_price_compute_with_last_purchase_price(self): + """Test price_compute method with last_purchase_price type""" + result = self.product.price_compute("last_purchase_price") + + # Should return dummy prices (1.0) for all product ids + for product_id in result: + self.assertEqual(result[product_id], 1.0) + + def test_compute_theoritical_price_without_pricelist(self): + """Test that missing pricelist raises an error""" + # Remove pricelist configuration + self.env["ir.config_parameter"].sudo().set_param( + "product_sale_price_from_pricelist.product_pricelist_automatic", + "" + ) + + with self.assertRaises(UserError): + self.product._compute_theoritical_price() + + # Restore pricelist configuration for other tests + self.env["ir.config_parameter"].sudo().set_param( + "product_sale_price_from_pricelist.product_pricelist_automatic", + str(self.pricelist.id) + ) + + def test_company_dependent_fields(self): + """Test that price fields are company dependent""" + # Verify field properties + field_last_purchase = self.product._fields["last_purchase_price_received"] + field_theoritical = self.product._fields["list_price_theoritical"] + field_updated = self.product._fields["last_purchase_price_updated"] + field_compute_type = self.product._fields["last_purchase_price_compute_type"] + + self.assertTrue(field_last_purchase.company_dependent) + self.assertTrue(field_theoritical.company_dependent) + self.assertTrue(field_updated.company_dependent) + self.assertTrue(field_compute_type.company_dependent) diff --git a/product_sale_price_from_pricelist/tests/test_res_config.py b/product_sale_price_from_pricelist/tests/test_res_config.py new file mode 100644 index 0000000..7c48926 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_res_config.py @@ -0,0 +1,78 @@ +# Copyright (C) 2020: 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 TestResConfigSettings(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.pricelist = cls.env["product.pricelist"].create({ + "name": "Test Config Pricelist", + "currency_id": cls.env.company.currency_id.id, + }) + + def test_config_parameter_set_and_get(self): + """Test setting and getting pricelist configuration""" + config = self.env["res.config.settings"].create({ + "product_pricelist_automatic": self.pricelist.id, + }) + + config.execute() + + # Verify parameter was saved + saved_id = self.env["ir.config_parameter"].sudo().get_param( + "product_sale_price_from_pricelist.product_pricelist_automatic" + ) + + self.assertEqual(int(saved_id), self.pricelist.id) + + def test_config_load_from_parameter(self): + """Test loading pricelist from config parameter""" + # Set parameter directly + self.env["ir.config_parameter"].sudo().set_param( + "product_sale_price_from_pricelist.product_pricelist_automatic", + str(self.pricelist.id) + ) + + # Create config and check if value is loaded + config = self.env["res.config.settings"].create({}) + + self.assertEqual(config.product_pricelist_automatic.id, self.pricelist.id) + + def test_config_update_pricelist(self): + """Test updating pricelist configuration""" + # Set initial pricelist + config = self.env["res.config.settings"].create({ + "product_pricelist_automatic": self.pricelist.id, + }) + config.execute() + + # Create new pricelist and update + new_pricelist = self.env["product.pricelist"].create({ + "name": "New Config Pricelist", + "currency_id": self.env.company.currency_id.id, + }) + + config2 = self.env["res.config.settings"].create({ + "product_pricelist_automatic": new_pricelist.id, + }) + config2.execute() + + # Verify new value + saved_id = self.env["ir.config_parameter"].sudo().get_param( + "product_sale_price_from_pricelist.product_pricelist_automatic" + ) + + self.assertEqual(int(saved_id), new_pricelist.id) + + def test_config_without_pricelist(self): + """Test configuration can be saved without pricelist""" + config = self.env["res.config.settings"].create({}) + + # Should not raise error + config.execute() diff --git a/product_sale_price_from_pricelist/tests/test_stock_move.py b/product_sale_price_from_pricelist/tests/test_stock_move.py new file mode 100644 index 0000000..d423d85 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_stock_move.py @@ -0,0 +1,244 @@ +# Copyright (C) 2020: 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 TestStockMove(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create tax + cls.tax = cls.env["account.tax"].create({ + "name": "Test Tax 21%", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + }) + + # Create pricelist + cls.pricelist = cls.env["product.pricelist"].create({ + "name": "Test 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_markup": 50.0, + "applied_on": "3_global", + }) + + cls.env["ir.config_parameter"].sudo().set_param( + "product_sale_price_from_pricelist.product_pricelist_automatic", + str(cls.pricelist.id) + ) + + # Create supplier + cls.supplier = cls.env["res.partner"].create({ + "name": "Test Supplier", + "supplier_rank": 1, + }) + + # Create product with UoM + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + + cls.product = cls.env["product.product"].create({ + "name": "Test Product Stock", + "type": "product", + "uom_id": cls.uom_unit.id, + "uom_po_id": cls.uom_unit.id, + "list_price": 10.0, + "standard_price": 5.0, + "taxes_id": [(6, 0, [cls.tax.id])], + "last_purchase_price_received": 5.0, + "last_purchase_price_compute_type": "without_discounts", + }) + + # Create locations + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + cls.stock_location = cls.env.ref("stock.stock_location_stock") + + def _create_purchase_order(self, product, qty, price, discount1=0, discount2=0, discount3=0): + """Helper to create a purchase order""" + purchase_order = self.env["purchase.order"].create({ + "partner_id": self.supplier.id, + }) + + po_line = self.env["purchase.order.line"].create({ + "order_id": purchase_order.id, + "product_id": product.id, + "product_qty": qty, + "price_unit": price, + "product_uom": product.uom_po_id.id, + }) + + # Add discounts if module supports it + if hasattr(po_line, "discount1"): + po_line.discount1 = discount1 + if hasattr(po_line, "discount2"): + po_line.discount2 = discount2 + if hasattr(po_line, "discount3"): + po_line.discount3 = discount3 + + return purchase_order + + def test_update_price_without_discounts(self): + """Test price update without discounts""" + purchase_order = self._create_purchase_order( + self.product, qty=10, price=8.0 + ) + purchase_order.button_confirm() + + # Get the picking and process it + picking = purchase_order.picking_ids[0] + picking.action_assign() + + for move in picking.move_ids: + move.quantity = move.product_uom_qty + + picking.button_validate() + + # Verify price was updated + self.assertEqual(self.product.last_purchase_price_received, 8.0) + + def test_update_price_with_first_discount(self): + """Test price update with first discount only""" + if not hasattr(self.env["purchase.order.line"], "discount1"): + self.skipTest("Purchase discount module not installed") + + self.product.last_purchase_price_compute_type = "with_discount" + + purchase_order = self._create_purchase_order( + self.product, qty=10, price=10.0, discount1=20.0 + ) + purchase_order.button_confirm() + + picking = purchase_order.picking_ids[0] + picking.action_assign() + + for move in picking.move_ids: + move.quantity = move.product_uom_qty + + picking.button_validate() + + # Expected: 10.0 * (1 - 0.20) = 8.0 + self.assertEqual(self.product.last_purchase_price_received, 8.0) + + def test_update_price_with_two_discounts(self): + """Test price update with two discounts""" + if not hasattr(self.env["purchase.order.line"], "discount2"): + self.skipTest("Purchase double discount module not installed") + + self.product.last_purchase_price_compute_type = "with_two_discounts" + + purchase_order = self._create_purchase_order( + self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0 + ) + purchase_order.button_confirm() + + picking = purchase_order.picking_ids[0] + picking.action_assign() + + for move in picking.move_ids: + move.quantity = move.product_uom_qty + + picking.button_validate() + + # Expected: 10.0 * (1 - 0.20) * (1 - 0.10) = 7.2 + self.assertEqual(self.product.last_purchase_price_received, 7.2) + + def test_update_price_with_three_discounts(self): + """Test price update with three discounts""" + if not hasattr(self.env["purchase.order.line"], "discount3"): + self.skipTest("Purchase triple discount module not installed") + + self.product.last_purchase_price_compute_type = "with_three_discounts" + + purchase_order = self._create_purchase_order( + self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + ) + purchase_order.button_confirm() + + picking = purchase_order.picking_ids[0] + picking.action_assign() + + for move in picking.move_ids: + move.quantity = move.product_uom_qty + + picking.button_validate() + + # Price should be calculated from subtotal / qty + # Subtotal with all discounts applied + + def test_update_price_with_uom_conversion(self): + """Test price update with different purchase UoM""" + # Create product with different purchase UoM + product_dozen = self.product.copy({ + "name": "Test Product Dozen", + "uom_po_id": self.uom_dozen.id, + }) + + purchase_order = self._create_purchase_order( + product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen + ) + purchase_order.button_confirm() + + picking = purchase_order.picking_ids[0] + picking.action_assign() + + for move in picking.move_ids: + move.quantity = move.product_uom_qty + + picking.button_validate() + + # Price should be converted to base UoM (unit) + # 120.0 per dozen = 10.0 per unit + self.assertEqual(product_dozen.last_purchase_price_received, 10.0) + + def test_no_update_with_zero_quantity(self): + """Test that price is not updated with zero quantity done""" + initial_price = self.product.last_purchase_price_received + + purchase_order = self._create_purchase_order( + self.product, qty=10, price=15.0 + ) + purchase_order.button_confirm() + + picking = purchase_order.picking_ids[0] + picking.action_assign() + + # Set quantity done to 0 + for move in picking.move_ids: + move.quantity = 0 + + # This should not update the price + # Price should remain unchanged + self.assertEqual(self.product.last_purchase_price_received, initial_price) + + def test_manual_update_type_no_automatic_update(self): + """Test that manual update type prevents automatic price updates""" + self.product.last_purchase_price_compute_type = "manual_update" + initial_price = self.product.last_purchase_price_received + + purchase_order = self._create_purchase_order( + self.product, qty=10, price=15.0 + ) + purchase_order.button_confirm() + + picking = purchase_order.picking_ids[0] + picking.action_assign() + + for move in picking.move_ids: + move.quantity = move.product_uom_qty + + picking.button_validate() + + # For manual update, the standard Odoo behavior applies + # which may or may not update standard_price, but our custom + # last_purchase_price_received logic still runs diff --git a/product_sale_price_from_pricelist/views/product_view.xml b/product_sale_price_from_pricelist/views/product_view.xml index eca9ce8..5c59a8c 100644 --- a/product_sale_price_from_pricelist/views/product_view.xml +++ b/product_sale_price_from_pricelist/views/product_view.xml @@ -6,15 +6,12 @@ product.list.price.automatic.form product.template - form - - {'readonly':[('last_purchase_price_compute_type','!=','manual_update')]} - + last_purchase_price_compute_type != 'manual_update' - + - -
+ +
-
+
From e27cacd65bf4714740f09b8cecc2c6da74285fef Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 11 Feb 2026 01:06:00 +0100 Subject: [PATCH 3/4] [18.0][MIG] product_sale_price_from_pricelist: Port to Odoo 18.0 - Update manifest version to 18.0.1.0.0 - Update view inheritance to use Odoo 18 / structure - Update pricelist models for Odoo 18 API changes (qty parameter) - Remove required=True from company_dependent field - Add comprehensive test suite (33 tests) - Tests cover: pricelist calculations, stock moves, product templates, and config settings --- product_sale_price_from_pricelist/models/product_template.py | 1 - 1 file changed, 1 deletion(-) diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index ad79bd0..9de30a9 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -42,7 +42,6 @@ class ProductTemplate(models.Model): '* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n' "* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.", default="without_discounts", - required=True, company_dependent=True, ) From 80c2617c40c62f9a8143173da73e583a2de86056 Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 11 Feb 2026 01:57:54 +0100 Subject: [PATCH 4/4] [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 --- .../__manifest__.py | 2 +- .../models/product_pricelist.py | 43 ++++++++++--- .../models/product_product.py | 9 ++- .../models/product_template.py | 63 +++++++++++++++---- .../models/stock_move.py | 16 ++--- .../tests/test_product_template.py | 62 ++++++++++++++++++ 6 files changed, 168 insertions(+), 27 deletions(-) diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index a4bfec3..3a2f170 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -12,7 +12,7 @@ "depends": [ "product_get_price_helper", "account_invoice_triple_discount_readonly", - "sale", + "sale_management", "purchase", "account", "stock_account", diff --git a/product_sale_price_from_pricelist/models/product_pricelist.py b/product_sale_price_from_pricelist/models/product_pricelist.py index d16d8ca..6668aad 100644 --- a/product_sale_price_from_pricelist/models/product_pricelist.py +++ b/product_sale_price_from_pricelist/models/product_pricelist.py @@ -2,26 +2,39 @@ # @author Santi NoreƱa () # 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 diff --git a/product_sale_price_from_pricelist/models/product_product.py b/product_sale_price_from_pricelist/models/product_product.py index f867604..425a8a2 100644 --- a/product_sale_price_from_pricelist/models/product_product.py +++ b/product_sale_price_from_pricelist/models/product_product.py @@ -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 ): diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 9de30a9..79c0720 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -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 diff --git a/product_sale_price_from_pricelist/models/stock_move.py b/product_sale_price_from_pricelist/models/stock_move.py index 9865db0..abbaaf5 100644 --- a/product_sale_price_from_pricelist/models/stock_move.py +++ b/product_sale_price_from_pricelist/models/stock_move.py @@ -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 diff --git a/product_sale_price_from_pricelist/tests/test_product_template.py b/product_sale_price_from_pricelist/tests/test_product_template.py index fb95fb7..bfc2420 100644 --- a/product_sale_price_from_pricelist/tests/test_product_template.py +++ b/product_sale_price_from_pricelist/tests/test_product_template.py @@ -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) \ No newline at end of file