From b484e3dc2e27d17611ad282559cef617d72138cd Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 12 Jun 2026 16:30:15 +0200 Subject: [PATCH] [ADD] product_sale_price_from_pricelist: server action to bulk-correct purchase price from supplierinfo - Add action_sync_purchase_price_from_supplierinfo on product.template: iterates variants, applies discount chain from main supplierinfo (according to last_purchase_price_compute_type), writes net price to last_purchase_price_received, recomputes theoretical sale price. Skips manual_update variants and products without a supplier. Returns a display_notification with updated/skipped counts. - Register it as ir.actions.server bound to product.template list view. - Add test_sync_action.py: 10 tests covering all discount types, skip cases, theoretical price recompute, notification type, idempotency, and bulk multi-template execution. - Fix test_no_update_with_zero_quantity: update assertion to reflect that PO confirmation now syncs the cost via supplierinfo (intended behavior). --- .../models/product_template.py | 50 +++++ .../tests/__init__.py | 1 + .../tests/test_stock_move.py | 16 +- .../tests/test_sync_action.py | 194 ++++++++++++++++++ .../views/actions.xml | 11 + 5 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 product_sale_price_from_pricelist/tests/test_sync_action.py diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 9de24a9..8deb43b 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -5,6 +5,7 @@ from odoo import api from odoo import fields from odoo import models +from odoo.tools import float_compare class ProductTemplate(models.Model): @@ -172,3 +173,52 @@ class ProductTemplate(models.Model): def action_update_list_price(self): """Delegate to product variants.""" return self.product_variant_ids.action_update_list_price() + + def action_sync_purchase_price_from_supplierinfo(self): + """Bulk-correct last_purchase_price_received from the main supplierinfo, + applying discounts according to last_purchase_price_compute_type.""" + updated = 0 + skipped_manual = 0 + skipped_no_seller = 0 + + for product in self.product_variant_ids: + if product.last_purchase_price_compute_type == "manual_update": + skipped_manual += 1 + continue + seller = product._get_main_supplierinfo() + if not seller: + skipped_no_seller += 1 + continue + net_price = product._compute_supplierinfo_net_price(seller) + if ( + float_compare( + product.last_purchase_price_received, net_price, precision_digits=2 + ) + != 0 + ): + product.write({"last_purchase_price_received": net_price}) + product._recompute_theoritical_price_safe() + updated += 1 + + parts = [] + if updated: + parts.append(self.env._("Updated: %(n)s", n=updated)) + if skipped_manual: + parts.append(self.env._("Skipped (manual update): %(n)s", n=skipped_manual)) + if skipped_no_seller: + parts.append( + self.env._("Skipped (no supplier): %(n)s", n=skipped_no_seller) + ) + message = "\n".join(parts) if parts else self.env._("Nothing to correct.") + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": self.env._("Sync Purchase Price"), + "message": message, + "type": "success" if updated else "warning", + "sticky": False, + "next": {"type": "ir.actions.act_window_close"}, + }, + } diff --git a/product_sale_price_from_pricelist/tests/__init__.py b/product_sale_price_from_pricelist/tests/__init__.py index 904aeb2..6955400 100644 --- a/product_sale_price_from_pricelist/tests/__init__.py +++ b/product_sale_price_from_pricelist/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_pricelist from . import test_res_config from . import test_theoretical_price from . import test_supplierinfo_sync +from . import test_sync_action 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 16b87e8..a4eacd6 100644 --- a/product_sale_price_from_pricelist/tests/test_stock_move.py +++ b/product_sale_price_from_pricelist/tests/test_stock_move.py @@ -269,22 +269,26 @@ class TestStockMove(TransactionCase): 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 + """Zero-quantity receipt must not change last_purchase_price_received. + Confirming the PO creates a supplierinfo line which syncs the cost to + 15.0 (no discounts). The stock-move path must NOT overwrite that value + when no units are actually received. + """ purchase_order = self._create_purchase_order(self.product, qty=10, price=15.0) purchase_order.button_confirm() + # Supplierinfo sync sets the price to 15.0 after PO confirmation. + price_after_confirm = self.product.last_purchase_price_received + 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) + # Stock move with qty=0 must leave the price untouched. + self.assertEqual(self.product.last_purchase_price_received, price_after_confirm) def test_manual_update_type_no_automatic_update(self): """Test that manual update type prevents automatic price updates""" diff --git a/product_sale_price_from_pricelist/tests/test_sync_action.py b/product_sale_price_from_pricelist/tests/test_sync_action.py new file mode 100644 index 0000000..55784c5 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_sync_action.py @@ -0,0 +1,194 @@ +# 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 TestSyncPurchasePriceAction(TransactionCase): + """Cover action_sync_purchase_price_from_supplierinfo on product.template. + + The action is used to bulk-correct last_purchase_price_received for products + whose cost was written without applying supplier discounts. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.has_discounts = hasattr(cls.env["product.supplierinfo"], "discount1") + + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 21% (Action)", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + } + ) + + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist (Action)", + "currency_id": cls.env.company.currency_id.id, + } + ) + 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), + ) + + cls.supplier = cls.env["res.partner"].create( + {"name": "Test Supplier (Action)", "supplier_rank": 1} + ) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + + def setUp(self): + super().setUp() + if not self.has_discounts: + self.skipTest("purchase_triple_discount module not installed") + + def _make_product(self, name, compute_type, cost=0.0): + product = self.env["product.product"].create( + { + "name": name, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "list_price": 10.0, + "standard_price": cost, + "taxes_id": [(6, 0, [self.tax.id])], + "last_purchase_price_received": cost, + "last_purchase_price_compute_type": compute_type, + } + ) + return product + + def _add_supplierinfo( + self, product, price, discount1=0.0, discount2=0.0, discount3=0.0 + ): + return ( + self.env["product.supplierinfo"] + .with_context(skip_last_purchase_price_sync=True) + .create( + { + "partner_id": self.supplier.id, + "product_tmpl_id": product.product_tmpl_id.id, + "price": price, + "discount1": discount1, + "discount2": discount2, + "discount3": discount3, + "sequence": 1, + } + ) + ) + + def _run_action(self, template): + return template.action_sync_purchase_price_from_supplierinfo() + + def test_action_corrects_gross_price_no_discounts(self): + """without_discounts: action keeps the gross price as-is.""" + product = self._make_product("P-NoDsc", "without_discounts", cost=999.0) + self._add_supplierinfo(product, price=20.0) + self._run_action(product.product_tmpl_id) + self.assertAlmostEqual(product.last_purchase_price_received, 20.0, places=2) + + def test_action_corrects_with_first_discount(self): + """with_discount: action applies discount1 only.""" + product = self._make_product("P-D1", "with_discount", cost=999.0) + self._add_supplierinfo(product, price=10.0, discount1=20.0, discount2=10.0) + self._run_action(product.product_tmpl_id) + # 10 * 0.80 = 8.0 + self.assertAlmostEqual(product.last_purchase_price_received, 8.0, places=2) + + def test_action_corrects_with_two_discounts(self): + """with_two_discounts: action applies discount1 and discount2.""" + product = self._make_product("P-D2", "with_two_discounts", cost=999.0) + self._add_supplierinfo( + product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + ) + self._run_action(product.product_tmpl_id) + # 10 * 0.80 * 0.90 = 7.2 + self.assertAlmostEqual(product.last_purchase_price_received, 7.2, places=2) + + def test_action_corrects_with_three_discounts(self): + """with_three_discounts: action applies all three discounts.""" + product = self._make_product("P-D3", "with_three_discounts", cost=999.0) + self._add_supplierinfo( + product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + ) + self._run_action(product.product_tmpl_id) + # 10 * 0.80 * 0.90 * 0.95 = 6.84 + self.assertAlmostEqual(product.last_purchase_price_received, 6.84, places=2) + + def test_action_skips_manual_update(self): + """manual_update: action never touches the cost.""" + product = self._make_product("P-Manual", "manual_update", cost=42.0) + self._add_supplierinfo(product, price=10.0, discount1=50.0) + self._run_action(product.product_tmpl_id) + self.assertAlmostEqual(product.last_purchase_price_received, 42.0, places=2) + + def test_action_skips_product_without_seller(self): + """Products with no supplierinfo are left untouched.""" + product = self._make_product("P-NoSeller", "with_three_discounts", cost=99.0) + result = self._run_action(product.product_tmpl_id) + self.assertAlmostEqual(product.last_purchase_price_received, 99.0, places=2) + self.assertIn("warning", result["params"]["type"]) + + def test_action_also_recomputes_theoretical_price(self): + """After correcting the cost the theoretical sale price is refreshed.""" + product = self._make_product( + "P-Theoretical", "with_three_discounts", cost=999.0 + ) + self._add_supplierinfo(product, price=1.53, discount1=15.0) + self._run_action(product.product_tmpl_id) + # net = 1.53 * 0.85 = 1.30, theoretical = 1.30 * 1.5 = 1.95 + self.assertAlmostEqual(product.last_purchase_price_received, 1.30, places=2) + self.assertAlmostEqual(product.list_price_theoritical, 1.95, places=2) + + def test_action_returns_success_notification(self): + """Return value is a display_notification client action with type success.""" + product = self._make_product("P-Notif", "with_three_discounts", cost=999.0) + self._add_supplierinfo(product, price=10.0, discount1=20.0) + result = self._run_action(product.product_tmpl_id) + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["type"], "success") + + def test_action_idempotent(self): + """Running the action twice leaves the price unchanged on the second run.""" + product = self._make_product("P-Idem", "with_three_discounts", cost=999.0) + self._add_supplierinfo(product, price=10.0, discount1=20.0) + self._run_action(product.product_tmpl_id) + price_after_first = product.last_purchase_price_received + result = self._run_action(product.product_tmpl_id) + self.assertAlmostEqual( + product.last_purchase_price_received, price_after_first, places=2 + ) + # No products were changed on the second run → warning (nothing updated) + self.assertEqual(result["params"]["type"], "warning") + + def test_action_bulk_multiple_products(self): + """Action works correctly when called on multiple templates at once.""" + p1 = self._make_product("P-Bulk1", "with_discount", cost=999.0) + p2 = self._make_product("P-Bulk2", "with_two_discounts", cost=999.0) + self._add_supplierinfo(p1, price=10.0, discount1=10.0) + self._add_supplierinfo(p2, price=10.0, discount1=10.0, discount2=10.0) + + templates = p1.product_tmpl_id | p2.product_tmpl_id + result = self._run_action(templates) + + # p1: 10 * 0.90 = 9.0 + self.assertAlmostEqual(p1.last_purchase_price_received, 9.0, places=2) + # p2: 10 * 0.90 * 0.90 = 8.1 + self.assertAlmostEqual(p2.last_purchase_price_received, 8.1, places=2) + self.assertEqual(result["params"]["type"], "success") diff --git a/product_sale_price_from_pricelist/views/actions.xml b/product_sale_price_from_pricelist/views/actions.xml index 813df8a..f073fb0 100644 --- a/product_sale_price_from_pricelist/views/actions.xml +++ b/product_sale_price_from_pricelist/views/actions.xml @@ -18,4 +18,15 @@ + + + Sync Purchase Price from Supplier + + + code + + records.action_sync_purchase_price_from_supplierinfo() + + +