[IMP] product_pricelist_total_margin: Add margin_type selector (Markup vs Commercial Margin)
- Add margin_type field to choose between calculation methods: * Markup (on cost): PVP = Cost × (1 + markup%) * Commercial Margin (on PVP): PVP = Cost / (1 - margin%) - Update _compute_price() to apply correct formula based on margin_type - Add safety cap for commercial margin >= 100% (caps at 99%) - Add detailed logging for both calculation types - Update views to show margin_type field when use_total_margin is enabled - Add 2 new tests to validate both calculation methods: * test_total_margin_markup_type: validates markup formula * test_total_margin_commercial_margin_type: validates commercial margin formula - All 11 tests passing (9 existing + 2 new) Example with Commercial Margin: Base: 4.68€, Total Margin: 20% - Markup: 4.68 × 1.20 = 5.616€ (margin = 16.67% of PVP) - Commercial Margin: 4.68 / 0.80 = 5.85€ (margin = 20% of PVP) ✓
This commit is contained in:
parent
cafa19ffea
commit
0f239601ce
4 changed files with 107 additions and 11 deletions
|
|
@ -5,8 +5,8 @@
|
||||||
"version": "18.0.1.0.0",
|
"version": "18.0.1.0.0",
|
||||||
"category": "Sales/Products",
|
"category": "Sales/Products",
|
||||||
"summary": "Calculate total margin additively instead of compounding in chained pricelists",
|
"summary": "Calculate total margin additively instead of compounding in chained pricelists",
|
||||||
"author": "Odoo Community Association (OCA), Kidekoop",
|
"author": "Odoo Community Association (OCA), Criptomart",
|
||||||
"website": "https://github.com/kidekoop",
|
"website": "https://git.criptomart.net/criptomart/addons-cm",
|
||||||
"license": "AGPL-3",
|
"license": "AGPL-3",
|
||||||
"depends": [
|
"depends": [
|
||||||
"product",
|
"product",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,20 @@ class ProductPricelistItem(models.Model):
|
||||||
"- Total (this option): 100 * (1 + 0.20) = 120€",
|
"- Total (this option): 100 * (1 + 0.20) = 120€",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
margin_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
("markup", "Markup (on cost)"),
|
||||||
|
("margin", "Commercial Margin (on PVP)"),
|
||||||
|
],
|
||||||
|
string="Margin Type",
|
||||||
|
default="markup",
|
||||||
|
help="Type of margin calculation:\n"
|
||||||
|
"- Markup: PVP = Cost × (1 + markup%). Margin is calculated on cost.\n"
|
||||||
|
" Example: Cost 100€, Markup 25% → PVP = 125€\n\n"
|
||||||
|
"- Commercial Margin: PVP = Cost / (1 - margin%). Margin is calculated on PVP.\n"
|
||||||
|
" Example: Cost 100€, Margin 20% → PVP = 125€ (margin = 25€/125€ = 20%)",
|
||||||
|
)
|
||||||
|
|
||||||
def _get_base_price_and_margins(self, product, quantity, uom, date, currency):
|
def _get_base_price_and_margins(self, product, quantity, uom, date, currency):
|
||||||
"""
|
"""
|
||||||
Traverse the pricelist chain to get the original base price and collect
|
Traverse the pricelist chain to get the original base price and collect
|
||||||
|
|
@ -367,10 +381,28 @@ class ProductPricelistItem(models.Model):
|
||||||
# Apply global min/max margin limits
|
# Apply global min/max margin limits
|
||||||
total_margin = self._apply_global_margin_limits(total_margin, base_price)
|
total_margin = self._apply_global_margin_limits(total_margin, base_price)
|
||||||
|
|
||||||
# Apply total margin to base price
|
# Apply total margin to base price using selected margin type
|
||||||
|
margin_type = self.margin_type or "markup"
|
||||||
|
if margin_type == "margin":
|
||||||
|
# Commercial Margin: PVP = Cost / (1 - margin%)
|
||||||
|
if total_margin >= 100:
|
||||||
|
_logger.warning(
|
||||||
|
"[TOTAL MARGIN] Commercial margin %.2f%% >= 100%%, capping at 99%%",
|
||||||
|
total_margin,
|
||||||
|
)
|
||||||
|
total_margin = 99.0
|
||||||
|
price = base_price / (1 - total_margin / 100)
|
||||||
|
_logger.info(
|
||||||
|
"[TOTAL MARGIN] Commercial Margin: Base %.2f / (1 - %.2f%%) = %.2f",
|
||||||
|
base_price,
|
||||||
|
total_margin,
|
||||||
|
price,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Markup: PVP = Cost × (1 + markup%)
|
||||||
price = base_price * (1 + total_margin / 100)
|
price = base_price * (1 + total_margin / 100)
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"[TOTAL MARGIN] Base price %.2f * (1 + %.2f%%) = %.2f",
|
"[TOTAL MARGIN] Markup: Base %.2f * (1 + %.2f%%) = %.2f",
|
||||||
base_price,
|
base_price,
|
||||||
total_margin,
|
total_margin,
|
||||||
price,
|
price,
|
||||||
|
|
|
||||||
|
|
@ -351,3 +351,63 @@ class TestTotalMargin(TransactionCase):
|
||||||
self.env["ir.config_parameter"].sudo().set_param(
|
self.env["ir.config_parameter"].sudo().set_param(
|
||||||
"product_pricelist_total_margin.max_percent", "0.0"
|
"product_pricelist_total_margin.max_percent", "0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_total_margin_markup_type(self):
|
||||||
|
"""Test total margin with Markup calculation (on cost)."""
|
||||||
|
# Enable total margin and set to markup
|
||||||
|
self.item_base.use_total_margin = True
|
||||||
|
self.item_chained.use_total_margin = True
|
||||||
|
self.item_chained.margin_type = "markup"
|
||||||
|
|
||||||
|
price = self.pricelist_chained._get_product_price(
|
||||||
|
product=self.product,
|
||||||
|
quantity=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expected calculation (Markup):
|
||||||
|
# Base: 4.68
|
||||||
|
# Total margin: -5% + 25% = 20%
|
||||||
|
# Markup formula: PVP = Cost × (1 + markup%)
|
||||||
|
# Final: 4.68 * (1 + 0.20) = 5.616
|
||||||
|
expected_price = 4.68 * 1.20
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
price,
|
||||||
|
expected_price,
|
||||||
|
places=2,
|
||||||
|
msg=f"Markup calculation should give {expected_price}, got {price}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_total_margin_commercial_margin_type(self):
|
||||||
|
"""Test total margin with Commercial Margin calculation (on PVP)."""
|
||||||
|
# Enable total margin and set to commercial margin
|
||||||
|
self.item_base.use_total_margin = True
|
||||||
|
self.item_chained.use_total_margin = True
|
||||||
|
self.item_chained.margin_type = "margin"
|
||||||
|
|
||||||
|
price = self.pricelist_chained._get_product_price(
|
||||||
|
product=self.product,
|
||||||
|
quantity=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expected calculation (Commercial Margin):
|
||||||
|
# Base: 4.68
|
||||||
|
# Total margin: -5% + 25% = 20%
|
||||||
|
# Commercial margin formula: PVP = Cost / (1 - margin%)
|
||||||
|
# Final: 4.68 / (1 - 0.20) = 5.85
|
||||||
|
expected_price = 4.68 / 0.80
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
price,
|
||||||
|
expected_price,
|
||||||
|
places=2,
|
||||||
|
msg=f"Commercial margin calculation should give {expected_price}, got {price}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the commercial margin is indeed 20%
|
||||||
|
# Commercial margin = (PVP - Cost) / PVP
|
||||||
|
commercial_margin = (price - 4.68) / price
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
commercial_margin,
|
||||||
|
0.20,
|
||||||
|
places=4,
|
||||||
|
msg=f"Commercial margin should be 20%, got {commercial_margin * 100}%",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,16 @@
|
||||||
<field name="model">product.pricelist.item</field>
|
<field name="model">product.pricelist.item</field>
|
||||||
<field name="inherit_id" ref="product.product_pricelist_item_form_view" />
|
<field name="inherit_id" ref="product.product_pricelist_item_form_view" />
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<!-- Add use_total_margin field after compute_price -->
|
<!-- Add use_total_margin and margin_type fields after compute_price -->
|
||||||
<field name="compute_price" position="after">
|
<field name="compute_price" position="after">
|
||||||
<field
|
<field
|
||||||
name="use_total_margin"
|
name="use_total_margin"
|
||||||
invisible="compute_price != 'formula' or base != 'pricelist'"
|
invisible="compute_price != 'formula' or base != 'pricelist'"
|
||||||
/>
|
/>
|
||||||
|
<field
|
||||||
|
name="margin_type"
|
||||||
|
invisible="not use_total_margin"
|
||||||
|
/>
|
||||||
</field>
|
</field>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue