diff --git a/product_pricelist_total_margin/models/product_pricelist_item.py b/product_pricelist_total_margin/models/product_pricelist_item.py
index 9b1981f..a0f90af 100644
--- a/product_pricelist_total_margin/models/product_pricelist_item.py
+++ b/product_pricelist_total_margin/models/product_pricelist_item.py
@@ -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)
diff --git a/product_pricelist_total_margin/models/res_config_settings.py b/product_pricelist_total_margin/models/res_config_settings.py
index 76d720a..8e92050 100644
--- a/product_pricelist_total_margin/models/res_config_settings.py
+++ b/product_pricelist_total_margin/models/res_config_settings.py
@@ -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",
+ )
diff --git a/product_pricelist_total_margin/tests/test_total_margin.py b/product_pricelist_total_margin/tests/test_total_margin.py
index 2929015..6ace425 100644
--- a/product_pricelist_total_margin/tests/test_total_margin.py
+++ b/product_pricelist_total_margin/tests/test_total_margin.py
@@ -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"
+ )
diff --git a/product_pricelist_total_margin/views/res_config_settings_views.xml b/product_pricelist_total_margin/views/res_config_settings_views.xml
index 2139c57..677ae33 100644
--- a/product_pricelist_total_margin/views/res_config_settings_views.xml
+++ b/product_pricelist_total_margin/views/res_config_settings_views.xml
@@ -25,6 +25,14 @@
/>