[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:
parent
07cc0eb517
commit
449bb75bb6
4 changed files with 180 additions and 25 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue