[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 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-06-04 13:51:48 +02:00
parent 9a7dfe02db
commit d35c3383b2
6 changed files with 329 additions and 11 deletions

View file

@ -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",

View file

@ -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

View file

@ -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 = []

View file

@ -0,0 +1,83 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# @author Santi Noreña (<santi@criptomart.net>)
# 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()

View file

@ -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

View file

@ -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
)