[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:
parent
9a7dfe02db
commit
d35c3383b2
6 changed files with 329 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue