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 @@ /> +
+