From d35c3383b2e803c9fe166401fd1e4b1e3ded023b Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 4 Jun 2026 13:51:48 +0200 Subject: [PATCH] [FIX] product_sale_price_from_pricelist: apply supplier discounts from supplierinfo The sale price was computed from a gross last_purchase_price_received when the product relied on its supplierinfo (no stock receipt), ignoring the vendor discounts even with last_purchase_price_compute_type set. Sync last_purchase_price_received from the main vendor's supplierinfo on create/write, applying its discounts according to compute_type (mirrors the stock.move receipt logic): without_discounts keeps the gross price, with_* discounts apply 1/2/all three, manual_update is left untouched. The theoretical price is refreshed (and last_purchase_price_updated flagged) without overwriting lst_price until the user confirms. Also drop the redundant _compute_price_rule call in _compute_theoritical_price by asking the pricelist for the price directly instead of going through _get_price (which computed the price twice). Add purchase_triple_discount as an explicit dependency and tests covering every discount type, manual_update, write re-sync and the main-vendor rule. Co-Authored-By: Claude Opus 4.8 --- .../__manifest__.py | 3 +- .../models/__init__.py | 1 + .../models/product_product.py | 53 ++++- .../models/product_supplierinfo.py | 83 ++++++++ .../tests/__init__.py | 1 + .../tests/test_supplierinfo_sync.py | 199 ++++++++++++++++++ 6 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 product_sale_price_from_pricelist/models/product_supplierinfo.py create mode 100644 product_sale_price_from_pricelist/tests/test_supplierinfo_sync.py diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index 5c18831..266f9d6 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.7.0", + "version": "18.0.2.8.0", "category": "product", "summary": "Set sale price from pricelist based on last purchase price", "author": "Odoo Community Association (OCA), Criptomart", @@ -12,6 +12,7 @@ "depends": [ "product_get_price_helper", "account_invoice_triple_discount_readonly", + "purchase_triple_discount", "sale_management", "purchase", "account", diff --git a/product_sale_price_from_pricelist/models/__init__.py b/product_sale_price_from_pricelist/models/__init__.py index 97fb5c6..fbf28b9 100644 --- a/product_sale_price_from_pricelist/models/__init__.py +++ b/product_sale_price_from_pricelist/models/__init__.py @@ -1,6 +1,7 @@ from . import product_pricelist # noqa: F401 from . import product_pricelist_item # noqa: F401 from . import product_product # noqa: F401 +from . import product_supplierinfo # noqa: F401 from . import product_template # noqa: F401 from . import res_config # noqa: F401 from . import stock_move # noqa: F401 diff --git a/product_sale_price_from_pricelist/models/product_product.py b/product_sale_price_from_pricelist/models/product_product.py index 2458633..dc65e67 100644 --- a/product_sale_price_from_pricelist/models/product_product.py +++ b/product_sale_price_from_pricelist/models/product_product.py @@ -3,6 +3,7 @@ import logging from odoo import fields from odoo import models from odoo.exceptions import UserError +from odoo.tools import float_round _logger = logging.getLogger(__name__) @@ -73,15 +74,6 @@ class ProductProduct(models.Model): and product.id and product.last_purchase_price_compute_type != "manual_update" ): - partial_price = product._get_price(qty=1, pricelist=pricelist) - - _logger.info( - "[PRICE DEBUG] Product %s [%s]: partial_price result = %s", - product.default_code or product.name, - product.id, - partial_price, - ) - # Compute taxes to add if not product.taxes_id: raise UserError( @@ -91,7 +83,11 @@ class ProductProduct(models.Model): ) ) - base_price = partial_price.get("value", 0.0) or 0.0 + # Ask the pricelist for the price directly. Going through + # _get_price would compute the price twice (once for the + # value and once more to resolve the discount rule, which we + # don't use here), triggering a redundant _compute_price_rule. + base_price = pricelist._get_product_price(product, 1.0) or 0.0 _logger.info( "[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f", product.default_code or product.name, @@ -127,6 +123,43 @@ class ProductProduct(models.Model): ) ) + def _recompute_theoritical_price_safe(self): + """Recompute the theoretical price without raising when the automatic + pricelist is not configured yet (e.g. while editing supplierinfo lines + during data imports).""" + pricelist_id = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("product_sale_price_from_pricelist.product_pricelist_automatic") + ) + if not pricelist_id: + return + self._compute_theoritical_price() + + def _get_main_supplierinfo(self): + """Return the main vendor supplierinfo line for this variant (first + active vendor by sequence, same selection Odoo uses for purchasing).""" + self.ensure_one() + return fields.first(self._prepare_sellers()) + + def _compute_supplierinfo_net_price(self, seller): + """Net purchase price from a supplierinfo line, applying its discounts + according to last_purchase_price_compute_type. Mirrors the receipt + logic in stock.move so both paths produce the same cost basis.""" + self.ensure_one() + price = seller.price or 0.0 + compute_type = self.last_purchase_price_compute_type + if compute_type == "with_discount": + price *= 1 - (seller.discount1 or 0.0) / 100 + elif compute_type == "with_two_discounts": + price *= (1 - (seller.discount1 or 0.0) / 100) * ( + 1 - (seller.discount2 or 0.0) / 100 + ) + elif compute_type == "with_three_discounts": + # seller.discount aggregates the three discounts (triple discount). + price *= 1 - (seller.discount or 0.0) / 100 + return float_round(price, precision_digits=2) + def action_update_list_price(self): updated_products = [] skipped_products = [] diff --git a/product_sale_price_from_pricelist/models/product_supplierinfo.py b/product_sale_price_from_pricelist/models/product_supplierinfo.py new file mode 100644 index 0000000..832c175 --- /dev/null +++ b/product_sale_price_from_pricelist/models/product_supplierinfo.py @@ -0,0 +1,83 @@ +# Copyright (C) 2020: Criptomart (https://criptomart.net) +# @author Santi NoreƱa () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api +from odoo import models +from odoo.tools import float_compare + +_logger = logging.getLogger(__name__) + +# Fields whose change can alter the net purchase price of the main vendor. +PRICE_FIELDS = { + "price", + "discount", + "discount1", + "discount2", + "discount3", + "sequence", + "partner_id", + "product_id", + "product_tmpl_id", + "company_id", +} + + +class ProductSupplierInfo(models.Model): + _inherit = "product.supplierinfo" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._sync_last_purchase_price_received() + return records + + def write(self, vals): + res = super().write(vals) + if PRICE_FIELDS & set(vals): + self._sync_last_purchase_price_received() + return res + + def _sync_last_purchase_price_received(self): + """Keep last_purchase_price_received in sync with the main vendor's + supplierinfo price, applying its discounts according to each variant's + last_purchase_price_compute_type. The actual sale price is only refreshed + in last_purchase_price_updated/list_price_theoritical; lst_price is left + untouched until the user confirms with the update button (same behaviour + as a stock receipt).""" + if self.env.context.get("skip_last_purchase_price_sync"): + return + products = self.env["product.product"] + for seller in self: + if seller.product_id: + products |= seller.product_id + elif seller.product_tmpl_id: + products |= seller.product_tmpl_id.product_variant_ids + for product in products.exists(): + if product.last_purchase_price_compute_type == "manual_update": + continue + seller = product._get_main_supplierinfo() + if not seller: + continue + net_price = product._compute_supplierinfo_net_price(seller) + if ( + float_compare( + product.last_purchase_price_received, + net_price, + precision_digits=2, + ) + != 0 + ): + _logger.info( + "[PRICE] Product %s [%s]: last_purchase_price_received synced " + "from supplierinfo %.2f -> %.2f (compute_type: %s)", + product.default_code or product.name, + product.id, + product.last_purchase_price_received, + net_price, + product.last_purchase_price_compute_type, + ) + product.write({"last_purchase_price_received": net_price}) + product._recompute_theoritical_price_safe() diff --git a/product_sale_price_from_pricelist/tests/__init__.py b/product_sale_price_from_pricelist/tests/__init__.py index 89313bf..904aeb2 100644 --- a/product_sale_price_from_pricelist/tests/__init__.py +++ b/product_sale_price_from_pricelist/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_stock_move from . import test_pricelist from . import test_res_config from . import test_theoretical_price +from . import test_supplierinfo_sync diff --git a/product_sale_price_from_pricelist/tests/test_supplierinfo_sync.py b/product_sale_price_from_pricelist/tests/test_supplierinfo_sync.py new file mode 100644 index 0000000..723ace2 --- /dev/null +++ b/product_sale_price_from_pricelist/tests/test_supplierinfo_sync.py @@ -0,0 +1,199 @@ +# 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 TestSupplierinfoSync(TransactionCase): + """Cover the supplierinfo -> last_purchase_price_received sync, which applies + the vendor discounts according to last_purchase_price_compute_type.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.tax = cls.env["account.tax"].create( + { + "name": "Test Tax 21%", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + } + ) + + # Pricelist applies a 50% markup on the last purchase price, so the + # theoretical price is always net_cost * 1.5 (same setup as stock tests). + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test Pricelist", + "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", "supplier_rank": 1} + ) + cls.supplier2 = cls.env["res.partner"].create( + {"name": "Test Supplier 2", "supplier_rank": 1} + ) + + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.product = cls.env["product.product"].create( + { + "name": "Test Product Supplierinfo", + "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": 0.0, + "last_purchase_price_compute_type": "without_discounts", + } + ) + + cls.has_discounts = hasattr(cls.env["product.supplierinfo"], "discount1") + + def setUp(self): + super().setUp() + if not self.has_discounts: + self.skipTest("purchase_triple_discount module not installed") + + def _create_supplierinfo( + self, + product, + price, + discount1=0.0, + discount2=0.0, + discount3=0.0, + sequence=1, + partner=None, + ): + return self.env["product.supplierinfo"].create( + { + "partner_id": (partner or self.supplier).id, + "product_tmpl_id": product.product_tmpl_id.id, + "price": price, + "discount1": discount1, + "discount2": discount2, + "discount3": discount3, + "sequence": sequence, + } + ) + + def test_sync_without_discounts(self): + """without_discounts: vendor discounts are ignored, gross price is kept.""" + self.product.last_purchase_price_compute_type = "without_discounts" + self._create_supplierinfo(self.product, price=1.53, discount1=15.0) + self.assertAlmostEqual( + self.product.last_purchase_price_received, 1.53, places=2 + ) + + def test_sync_with_first_discount(self): + """with_discount: only discount1 is applied.""" + self.product.last_purchase_price_compute_type = "with_discount" + self._create_supplierinfo( + self.product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + ) + # 10 * (1 - 0.20) = 8.0 + self.assertAlmostEqual(self.product.last_purchase_price_received, 8.0, places=2) + + def test_sync_with_two_discounts(self): + """with_two_discounts: discount1 and discount2 are applied, discount3 ignored.""" + self.product.last_purchase_price_compute_type = "with_two_discounts" + self._create_supplierinfo( + self.product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + ) + # 10 * 0.80 * 0.90 = 7.2 + self.assertAlmostEqual(self.product.last_purchase_price_received, 7.2, places=2) + + def test_sync_with_three_discounts(self): + """with_three_discounts: all three discounts are applied (aggregated).""" + self.product.last_purchase_price_compute_type = "with_three_discounts" + self._create_supplierinfo( + self.product, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0 + ) + # 10 * 0.80 * 0.90 * 0.95 = 6.84 + self.assertAlmostEqual( + self.product.last_purchase_price_received, 6.84, places=2 + ) + + def test_sync_with_three_discounts_reported_case(self): + """Reproduces the reported DIS0002 case: 1.53 with a single 15% discount.""" + self.product.last_purchase_price_compute_type = "with_three_discounts" + self._create_supplierinfo(self.product, price=1.53, discount1=15.0) + # 1.53 * 0.85 = 1.3005 -> 1.30 + self.assertAlmostEqual( + self.product.last_purchase_price_received, 1.30, places=2 + ) + + def test_sync_updates_theoretical_price(self): + """The sync also refreshes the theoretical price and the update flag.""" + self.product.last_purchase_price_compute_type = "with_three_discounts" + self._create_supplierinfo(self.product, price=1.53, discount1=15.0) + # net 1.30 * 1.5 markup = 1.95 + self.assertAlmostEqual(self.product.list_price_theoritical, 1.95, places=2) + self.assertTrue( + self.product.last_purchase_price_updated, + "last_purchase_price_updated should flag a pending sale price change", + ) + + def test_manual_update_is_not_synced(self): + """manual_update: the cost is never touched by the supplierinfo sync.""" + self.product.write( + { + "last_purchase_price_compute_type": "manual_update", + "last_purchase_price_received": 99.0, + } + ) + self._create_supplierinfo(self.product, price=10.0, discount1=20.0) + self.assertAlmostEqual( + self.product.last_purchase_price_received, 99.0, places=2 + ) + + def test_write_resyncs(self): + """Editing the discount on an existing supplierinfo re-syncs the cost.""" + self.product.last_purchase_price_compute_type = "with_three_discounts" + seller = self._create_supplierinfo(self.product, price=1.53, discount1=15.0) + self.assertAlmostEqual( + self.product.last_purchase_price_received, 1.30, places=2 + ) + seller.discount1 = 0.0 + self.assertAlmostEqual( + self.product.last_purchase_price_received, 1.53, places=2 + ) + + def test_secondary_seller_does_not_override(self): + """Only the main vendor (lowest sequence) drives the cost basis.""" + self.product.last_purchase_price_compute_type = "with_three_discounts" + self._create_supplierinfo( + self.product, price=10.0, sequence=1, partner=self.supplier + ) + self.assertAlmostEqual( + self.product.last_purchase_price_received, 10.0, places=2 + ) + # A cheaper, heavily discounted secondary vendor must not change the cost. + self._create_supplierinfo( + self.product, + price=5.0, + discount1=50.0, + sequence=5, + partner=self.supplier2, + ) + self.assertAlmostEqual( + self.product.last_purchase_price_received, 10.0, places=2 + )