[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).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
{ # noqa: B018
|
{ # noqa: B018
|
||||||
"name": "Product Sale Price from Pricelist",
|
"name": "Product Sale Price from Pricelist",
|
||||||
"version": "18.0.2.7.0",
|
"version": "18.0.2.8.0",
|
||||||
"category": "product",
|
"category": "product",
|
||||||
"summary": "Set sale price from pricelist based on last purchase price",
|
"summary": "Set sale price from pricelist based on last purchase price",
|
||||||
"author": "Odoo Community Association (OCA), Criptomart",
|
"author": "Odoo Community Association (OCA), Criptomart",
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"depends": [
|
"depends": [
|
||||||
"product_get_price_helper",
|
"product_get_price_helper",
|
||||||
"account_invoice_triple_discount_readonly",
|
"account_invoice_triple_discount_readonly",
|
||||||
|
"purchase_triple_discount",
|
||||||
"sale_management",
|
"sale_management",
|
||||||
"purchase",
|
"purchase",
|
||||||
"account",
|
"account",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from . import product_pricelist # noqa: F401
|
from . import product_pricelist # noqa: F401
|
||||||
from . import product_pricelist_item # noqa: F401
|
from . import product_pricelist_item # noqa: F401
|
||||||
from . import product_product # noqa: F401
|
from . import product_product # noqa: F401
|
||||||
|
from . import product_supplierinfo # noqa: F401
|
||||||
from . import product_template # noqa: F401
|
from . import product_template # noqa: F401
|
||||||
from . import res_config # noqa: F401
|
from . import res_config # noqa: F401
|
||||||
from . import stock_move # noqa: F401
|
from . import stock_move # noqa: F401
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import logging
|
||||||
from odoo import fields
|
from odoo import fields
|
||||||
from odoo import models
|
from odoo import models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.tools import float_round
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -73,15 +74,6 @@ class ProductProduct(models.Model):
|
||||||
and product.id
|
and product.id
|
||||||
and product.last_purchase_price_compute_type != "manual_update"
|
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
|
# Compute taxes to add
|
||||||
if not product.taxes_id:
|
if not product.taxes_id:
|
||||||
raise UserError(
|
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(
|
_logger.info(
|
||||||
"[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f",
|
"[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f",
|
||||||
product.default_code or product.name,
|
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):
|
def action_update_list_price(self):
|
||||||
updated_products = []
|
updated_products = []
|
||||||
skipped_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_pricelist
|
||||||
from . import test_res_config
|
from . import test_res_config
|
||||||
from . import test_theoretical_price
|
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