[IMP] product_pricelist_total_margin: Add global_margin_type for min/max limits

- Add global_margin_type field in res.config.settings
  * Options: 'markup' (default) or 'margin' (commercial margin)
  * Determines how global min/max percentages are interpreted
- Refactor _apply_global_margin_limits():
  * Now receives price instead of margin percentage
  * Calculates min/max prices based on global_margin_type
  * Returns adjusted price instead of adjusted margin
  * Supports both markup and commercial margin formulas
- Update _compute_price() to apply limits after price calculation
- Update res_config_settings_views.xml to show global_margin_type selector
- Update help texts with examples for both calculation methods
- Add 2 new tests to validate global limits with commercial margin type:
  * test_global_minimum_with_commercial_margin_type
  * test_global_maximum_with_commercial_margin_type
- All 13 tests passing (11 existing + 2 new)

Example with global min 25%:
- Markup: Min price = Cost × 1.25
- Commercial Margin: Min price = Cost / 0.75 (ensures 25% margin on PVP)
This commit is contained in:
snt 2026-02-21 19:16:16 +01:00
parent 07cc0eb517
commit 449bb75bb6
4 changed files with 180 additions and 25 deletions

View file

@ -274,16 +274,21 @@ class ProductPricelistItem(models.Model):
return price
def _apply_global_margin_limits(self, total_margin, base_price):
def _apply_global_margin_limits(self, price, base_price):
"""
Apply global minimum and maximum margin limits from configuration.
Instead of adjusting the margin percentage, this method checks if the
calculated price respects the global limits and adjusts the price directly
if needed. The limits are interpreted according to the global_margin_type
configuration.
Args:
total_margin: The calculated total margin percentage
base_price: The base price (for logging)
price: The calculated price
base_price: The base price (cost)
Returns:
float: The adjusted margin percentage
float: The adjusted price
"""
# Get global margin limits from configuration
IrConfigParam = self.env["ir.config_parameter"].sudo()
@ -297,32 +302,64 @@ class ProductPricelistItem(models.Model):
"product_pricelist_total_margin.max_percent", default="0.0"
)
)
global_margin_type = IrConfigParam.get_param(
"product_pricelist_total_margin.global_margin_type", default="markup"
)
original_margin = total_margin
if min_margin <= 0.0 and max_margin <= 0.0:
# No limits configured
return price
# Apply minimum margin if configured (> 0)
if min_margin > 0.0 and total_margin < min_margin:
total_margin = min_margin
# Calculate min/max prices based on global margin type
if global_margin_type == "margin":
# Commercial Margin: PVP = Cost / (1 - margin%)
if min_margin > 0.0:
if min_margin >= 100:
min_margin = 99.0
min_price = base_price / (1 - min_margin / 100)
else:
min_price = 0.0
if max_margin > 0.0:
if max_margin >= 100:
max_margin = 99.0
max_price = base_price / (1 - max_margin / 100)
else:
max_price = float("inf")
else:
# Markup: PVP = Cost × (1 + markup%)
min_price = base_price * (1 + min_margin / 100) if min_margin > 0.0 else 0.0
max_price = (
base_price * (1 + max_margin / 100)
if max_margin > 0.0
else float("inf")
)
original_price = price
# Apply minimum price limit
if min_margin > 0.0 and price < min_price:
price = min_price
_logger.info(
"[TOTAL MARGIN] Applied global minimum margin: %.2f%% -> %.2f%% "
"(configured min: %.2f%%)",
original_margin,
total_margin,
"[TOTAL MARGIN] Applied global minimum (%s %.2f%%): %.2f -> %.2f",
global_margin_type,
min_margin,
original_price,
price,
)
# Apply maximum margin if configured (> 0)
if max_margin > 0.0 and total_margin > max_margin:
total_margin = max_margin
# Apply maximum price limit
if max_margin > 0.0 and price > max_price:
price = max_price
_logger.info(
"[TOTAL MARGIN] Applied global maximum margin: %.2f%% -> %.2f%% "
"(configured max: %.2f%%)",
original_margin,
total_margin,
"[TOTAL MARGIN] Applied global maximum (%s %.2f%%): %.2f -> %.2f",
global_margin_type,
max_margin,
original_price,
price,
)
return total_margin
return price
def _compute_price(
self,
@ -378,9 +415,6 @@ class ProductPricelistItem(models.Model):
total_margin,
)
# Apply global min/max margin limits
total_margin = self._apply_global_margin_limits(total_margin, base_price)
# Apply total margin to base price using selected margin type
margin_type = self.margin_type or "markup"
if margin_type == "margin":
@ -408,6 +442,9 @@ class ProductPricelistItem(models.Model):
price,
)
# Apply global min/max margin limits (checks and adjusts price)
price = self._apply_global_margin_limits(price, base_price)
# Apply formula extras (round, surcharge, min/max margins)
price = self._apply_formula_extras(price, base_price, currency)

View file

@ -15,7 +15,7 @@ class ResConfigSettings(models.TransientModel):
"Total Margin Mode. If the calculated margin is below this value, "
"the price will be adjusted to meet the minimum.\n\n"
"Example: If set to 10%, a product with base price 100€ will have "
"a minimum final price of 110€, regardless of calculated margins.\n\n"
"a minimum final price of 110€ (markup) or 111.11€ (commercial margin).\n\n"
"Set to 0 to disable minimum margin control.",
config_parameter="product_pricelist_total_margin.min_percent",
)
@ -27,7 +27,23 @@ class ResConfigSettings(models.TransientModel):
"Total Margin Mode. If the calculated margin exceeds this value, "
"the price will be adjusted to meet the maximum.\n\n"
"Example: If set to 50%, a product with base price 100€ will have "
"a maximum final price of 150€, regardless of calculated margins.\n\n"
"a maximum final price of 150€ (markup) or 200€ (commercial margin).\n\n"
"Set to 0 to disable maximum margin control.",
config_parameter="product_pricelist_total_margin.max_percent",
)
global_margin_type = fields.Selection(
selection=[
("markup", "Markup (on cost)"),
("margin", "Commercial Margin (on list price)"),
],
string="Global Limits Calculation",
default="markup",
help="Calculation method for global minimum and maximum margin limits:\n"
"- Markup: Price = Cost × (1 + margin%). Applied limits are calculated as markup.\n"
" Example: Min 10% → Price >= Cost × 1.10\n\n"
"- Commercial Margin: Price = Cost / (1 - margin%). Applied limits are commercial margin.\n"
" Example: Min 10% → Price >= Cost / 0.90\n\n"
"This affects how the minimum and maximum percentages above are interpreted.",
config_parameter="product_pricelist_total_margin.global_margin_type",
)

View file

@ -411,3 +411,97 @@ class TestTotalMargin(TransactionCase):
places=4,
msg=f"Commercial margin should be 20%, got {commercial_margin * 100}%",
)
def test_global_minimum_with_commercial_margin_type(self):
"""Test global minimum margin with commercial margin calculation."""
# Set global minimum to 25% and type to commercial margin
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.min_percent", "25.0"
)
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.global_margin_type", "margin"
)
# Enable total margin (calculated: -5% + 25% = 20%, below min 25%)
self.item_chained.use_total_margin = True
self.item_chained.margin_type = "markup" # Item uses markup
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected: Global limit is interpreted as commercial margin
# Min 25% commercial margin: PVP = Cost / (1 - 0.25) = 4.68 / 0.75 = 6.24
# Item calculates 20% markup: 4.68 * 1.20 = 5.616, but global min forces 6.24
expected_price = 4.68 / 0.75
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Global minimum (commercial margin 25%) should give {expected_price}, got {price}",
)
# Verify the actual commercial margin is 25%
commercial_margin = (price - 4.68) / price
self.assertAlmostEqual(
commercial_margin,
0.25,
places=4,
msg=f"Commercial margin should be 25%, got {commercial_margin * 100}%",
)
# 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.global_margin_type", "markup"
)
def test_global_maximum_with_commercial_margin_type(self):
"""Test global maximum margin with commercial margin calculation."""
# Set global maximum to 15% and type to commercial margin
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.max_percent", "15.0"
)
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.global_margin_type", "margin"
)
# Enable total margin (calculated: -5% + 25% = 20%, above max 15%)
self.item_chained.use_total_margin = True
self.item_chained.margin_type = "margin" # Item uses commercial margin
price = self.pricelist_chained._get_product_price(
product=self.product,
quantity=1.0,
)
# Expected: Global limit is interpreted as commercial margin
# Max 15% commercial margin: PVP = Cost / (1 - 0.15) = 4.68 / 0.85 = 5.506
# Item calculates 20% margin: 4.68 / 0.80 = 5.85, but global max limits to 5.506
expected_price = 4.68 / 0.85
self.assertAlmostEqual(
price,
expected_price,
places=2,
msg=f"Global maximum (commercial margin 15%) should give {expected_price}, got {price}",
)
# Verify the actual commercial margin is 15%
commercial_margin = (price - 4.68) / price
self.assertAlmostEqual(
commercial_margin,
0.15,
places=4,
msg=f"Commercial margin should be 15%, got {commercial_margin * 100}%",
)
# Clean up
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.max_percent", "0.0"
)
self.env["ir.config_parameter"].sudo().set_param(
"product_pricelist_total_margin.global_margin_type", "markup"
)

View file

@ -25,6 +25,14 @@
/>
<field name="total_margin_max_percent" class="oe_inline" />
</div>
<div class="row">
<label
string="Calculation Method"
for="global_margin_type"
class="col-lg-3 o_light_label"
/>
<field name="global_margin_type" class="oe_inline" />
</div>
</div>
</setting>
</xpath>