[ADD] product_pricelist_total_margin: New module for additive margin calculation

- Add use_total_margin field to pricelist items
- Override _compute_price() to sum margins additively instead of compounding
- Support chained pricelists with custom base types (last_purchase_price)
- Add global minimum and maximum margin limits configuration
- Store limits in ir.config_parameter via res.config.settings
- Apply global limits after total margin calculation
- Add comprehensive test suite (9 tests) covering:
  * Basic additive vs compound margin behavior
  * Three-level pricelist chains
  * Global minimum/maximum margin enforcement
  * Rounding and surcharge compatibility
- Add configuration UI in Settings > Sales
- All tests passing (9/9)

This module fixes the issue where chained pricelists were compounding margins
instead of calculating total margins. Example: base 4.68€ with -5% and +25%
now correctly results in 5.616€ (20% total) instead of 5.56€ (compound).
This commit is contained in:
snt 2026-02-21 16:11:13 +01:00
parent f35bf0c5a1
commit 32f345bc44
12 changed files with 1143 additions and 0 deletions

View file

@ -0,0 +1,4 @@
# Copyright 2026 - Today Kidekoop
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_total_margin # noqa: F401

View file

@ -0,0 +1,353 @@
# Copyright 2026 - Today Kidekoop
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestTotalMargin(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create a product with last_purchase_price
cls.product = cls.env["product.product"].create(
{
"name": "Test Product Total Margin",
"default_code": "TEST-MARGIN-001",
"list_price": 100.0,
"last_purchase_price_received": 4.68,
}
)
# Create tax (required for some price calculations)
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
cls.product.taxes_id = [(6, 0, [cls.tax.id])]
# Create base pricelist with last_purchase_price
cls.pricelist_base = cls.env["product.pricelist"].create(
{
"name": "Base Pricelist (Last Purchase Price)",
"currency_id": cls.env.company.currency_id.id,
}
)
# Create rule with -5% discount (simulating "cesta básica")
cls.item_base = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist_base.id,
"applied_on": "3_global",
"base": "last_purchase_price",
"compute_price": "formula",
"price_discount": 5.0, # 5% discount
}
)
# Create chained pricelist with 25% markup (simulating category margin)
cls.pricelist_chained = cls.env["product.pricelist"].create(
{
"name": "Chained Pricelist (Category Margin)",
"currency_id": cls.env.company.currency_id.id,
}
)
cls.item_chained = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist_chained.id,
"applied_on": "3_global",
"base": "pricelist",
"base_pricelist_id": cls.pricelist_base.id,
"compute_price": "formula",
"price_discount": -25.0, # 25% markup (negative discount)
"use_total_margin": False, # Will be toggled in tests
}
)
def test_compound_margin_default_behavior(self):
"""Test that without use_total_margin, margins are compounded (standard Odoo)."""
# Ensure use_total_margin is False
self.item_chained.use_total_margin = False
# Get price through chained pricelist
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected calculation (compound):
# Base: 4.68
# After -5% discount: 4.68 * 0.95 = 4.446
# After 25% markup: 4.446 * 1.25 = 5.5575
expected_price = 4.68 * 0.95 * 1.25
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Compound margin should be {expected_price}, got {price}",
)
def test_total_margin_additive_behavior(self):
"""Test that with use_total_margin=True, margins are added instead of compounded."""
# Enable total margin
self.item_chained.use_total_margin = True
# Get price through chained pricelist
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected calculation (total/additive):
# Base: 4.68
# Total margin: -5% + 25% = 20%
# Final: 4.68 * 1.20 = 5.616
expected_price = 4.68 * 1.20
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Total margin should be {expected_price}, got {price}",
)
def test_total_margin_with_price_round(self):
"""Test that price_round is applied correctly with total margin."""
# Enable total margin and set rounding
self.item_chained.use_total_margin = True
self.item_chained.price_round = 0.05 # Round to nearest 0.05
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Base calculation: 4.68 * 1.20 = 5.616
# Rounded to nearest 0.05 = 5.60
expected_price = 5.60
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Rounded total margin should be {expected_price}, got {price}",
)
def test_total_margin_with_surcharge(self):
"""Test that price_surcharge is applied correctly with total margin."""
# Enable total margin and set surcharge
self.item_chained.use_total_margin = True
self.item_chained.price_surcharge = 1.0 # Add 1€
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Base calculation: 4.68 * 1.20 = 5.616
# Plus surcharge: 5.616 + 1.0 = 6.616
expected_price = 5.616 + 1.0
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Total margin with surcharge should be {expected_price}, got {price}",
)
def test_total_margin_three_level_chain(self):
"""Test total margin with 3 pricelists in chain."""
# Create a third pricelist in the chain
pricelist_3rd = self.env["product.pricelist"].create(
{
"name": "Third Level Pricelist",
"currency_id": self.env.company.currency_id.id,
}
)
_item_3rd = self.env["product.pricelist.item"].create( # noqa: F841
{
"pricelist_id": pricelist_3rd.id,
"applied_on": "3_global",
"base": "pricelist",
"base_pricelist_id": self.pricelist_chained.id,
"compute_price": "formula",
"price_discount": -10.0, # 10% markup
"use_total_margin": True,
}
)
# Also enable on chained
self.item_chained.use_total_margin = True
price = pricelist_3rd._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected calculation (3-level total):
# Base: 4.68
# Total margin: -5% + 25% + 10% = 30%
# Final: 4.68 * 1.30 = 6.084
expected_price = 4.68 * 1.30
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"3-level total margin should be {expected_price}, got {price}",
)
def test_total_margin_with_list_price_base(self):
"""Test total margin when base is list_price instead of last_purchase_price."""
# Create new base pricelist with list_price
pricelist_list = self.env["product.pricelist"].create(
{
"name": "List Price Base Pricelist",
"currency_id": self.env.company.currency_id.id,
}
)
_item_list = self.env["product.pricelist.item"].create( # noqa: F841
{
"pricelist_id": pricelist_list.id,
"applied_on": "3_global",
"base": "list_price",
"compute_price": "formula",
"price_discount": 10.0, # 10% discount
}
)
# Create chained pricelist
pricelist_chained_list = self.env["product.pricelist"].create(
{
"name": "Chained from List Price",
"currency_id": self.env.company.currency_id.id,
}
)
_item_chained_list = self.env["product.pricelist.item"].create( # noqa: F841
{
"pricelist_id": pricelist_chained_list.id,
"applied_on": "3_global",
"base": "pricelist",
"base_pricelist_id": pricelist_list.id,
"compute_price": "formula",
"price_discount": -20.0, # 20% markup
"use_total_margin": True,
}
)
price = pricelist_chained_list._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected calculation:
# Base: 100.0 (list_price)
# Total margin: -10% + 20% = 10%
# Final: 100.0 * 1.10 = 110.0
expected_price = 100.0 * 1.10
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Total margin from list_price should be {expected_price}, got {price}",
)
def test_total_margin_with_global_minimum(self):
"""Test that global minimum margin is enforced."""
# Set global minimum margin to 25%
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.min_percent", "25.0"
)
# Enable total margin (calculated: -5% + 25% = 20%, below min 25%)
self.item_chained.use_total_margin = True
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected: Base 4.68 * (1 + 25%) = 5.85 (forced to minimum)
# Not the calculated 20%: 4.68 * 1.20 = 5.616
expected_price = 4.68 * 1.25
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Global minimum margin should force price to {expected_price}, got {price}",
)
# Clean up
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.min_percent", "0.0"
)
def test_total_margin_with_global_maximum(self):
"""Test that global maximum margin is enforced."""
# Set global maximum margin to 15%
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.max_percent", "15.0"
)
# Enable total margin (calculated: -5% + 25% = 20%, above max 15%)
self.item_chained.use_total_margin = True
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected: Base 4.68 * (1 + 15%) = 5.382 (capped at maximum)
# Not the calculated 20%: 4.68 * 1.20 = 5.616
expected_price = 4.68 * 1.15
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Global maximum margin should cap price at {expected_price}, got {price}",
)
# Clean up
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.max_percent", "0.0"
)
def test_total_margin_with_both_limits(self):
"""Test that both min and max limits can work together."""
# Set both limits: min 10%, max 30%
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.min_percent", "10.0"
)
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.max_percent", "30.0"
)
# Test within range (calculated: -5% + 25% = 20%, within [10%, 30%])
self.item_chained.use_total_margin = True
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Should use calculated margin: 4.68 * 1.20 = 5.616
expected_price = 4.68 * 1.20
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Margin within limits should not be adjusted: {expected_price}, got {price}",
)
# Clean up
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.min_percent", "0.0"
)
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.max_percent", "0.0"
)